mirror of
https://github.com/libre-tube/LibreTube.git
synced 2024-12-13 05:40:31 +05:30
Merge pull request #6732 from Bnyro/medialibraryservice
refactor: Migrate to MediaLibraryService
This commit is contained in:
commit
d2e28e2d17
@ -404,13 +404,49 @@
|
||||
android:name=".services.OnlinePlayerService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
</intent-filter>
|
||||
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.OfflinePlayerService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
</intent-filter>
|
||||
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.VideoOnlinePlayerService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
</intent-filter>
|
||||
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.VideoOfflinePlayerService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
</intent-filter>
|
||||
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.OnClearFromRecentService"
|
||||
|
@ -1,17 +1,23 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class ChapterSegment(
|
||||
val title: String,
|
||||
val image: String = "",
|
||||
val start: Long,
|
||||
// Used only for video highlights
|
||||
@Transient var highlightDrawable: Drawable? = null
|
||||
) {
|
||||
@Transient
|
||||
@IgnoredOnParcel
|
||||
var highlightDrawable: Drawable? = null
|
||||
): Parcelable {
|
||||
companion object {
|
||||
/**
|
||||
* Length to show for a highlight in seconds
|
||||
|
@ -1,11 +1,14 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class MetaInfo(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val urls: List<String>,
|
||||
val urlTexts: List<String>
|
||||
)
|
||||
): Parcelable
|
||||
|
@ -1,11 +1,14 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.github.libretube.db.obj.DownloadItem
|
||||
import com.github.libretube.enums.FileType
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.io.path.Path
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class PipedStream(
|
||||
var url: String? = null,
|
||||
val format: String? = null,
|
||||
@ -26,7 +29,7 @@ data class PipedStream(
|
||||
val contentLength: Long = -1,
|
||||
val audioTrackType: String? = null,
|
||||
val audioTrackLocale: String? = null
|
||||
) {
|
||||
): Parcelable {
|
||||
private fun getQualityString(fileName: String): String {
|
||||
return "${fileName}_${quality?.replace(" ", "_")}_$format." +
|
||||
mimeType?.split("/")?.last()
|
||||
|
@ -1,8 +1,11 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class PreviewFrames(
|
||||
val urls: List<String>,
|
||||
val frameWidth: Int,
|
||||
@ -11,4 +14,4 @@ data class PreviewFrames(
|
||||
val durationPerFrame: Long,
|
||||
val framesPerPageX: Int,
|
||||
val framesPerPageY: Int
|
||||
)
|
||||
): Parcelable
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.github.libretube.db.obj.DownloadItem
|
||||
import com.github.libretube.enums.FileType
|
||||
import com.github.libretube.helpers.ProxyHelper
|
||||
@ -8,18 +9,22 @@ import com.github.libretube.parcelable.DownloadData
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.io.path.Path
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Streams(
|
||||
var title: String,
|
||||
val description: String,
|
||||
|
||||
@Serializable(SafeInstantSerializer::class)
|
||||
@SerialName("uploadDate")
|
||||
val uploadTimestamp: Instant?,
|
||||
@IgnoredOnParcel
|
||||
val uploadTimestamp: Instant? = null,
|
||||
val uploaded: Long? = null,
|
||||
|
||||
val uploader: String,
|
||||
@ -48,7 +53,8 @@ data class Streams(
|
||||
val chapters: List<ChapterSegment> = emptyList(),
|
||||
val uploaderSubscriberCount: Long = 0,
|
||||
val previewFrames: List<PreviewFrames> = emptyList()
|
||||
) {
|
||||
): Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val isLive = livestream || duration <= 0
|
||||
|
||||
fun toDownloadItems(downloadData: DownloadData): List<DownloadItem> {
|
||||
|
@ -1,17 +1,20 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import com.github.libretube.R
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Subtitle(
|
||||
val url: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val name: String? = null,
|
||||
val code: String? = null,
|
||||
val autoGenerated: Boolean? = null
|
||||
) {
|
||||
): Parcelable {
|
||||
fun getDisplayName(context: Context) = if (autoGenerated != true) {
|
||||
name!!
|
||||
} else {
|
||||
|
@ -55,4 +55,5 @@ object IntentData {
|
||||
const val noInternet = "noInternet"
|
||||
const val isPlayingOffline = "isPlayingOffline"
|
||||
const val downloadInfo = "downloadInfo"
|
||||
const val streams = "streams"
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -1,22 +1,15 @@
|
||||
package com.github.libretube.extensions
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.BitmapFactory
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.api.obj.Streams
|
||||
import com.github.libretube.db.obj.Download
|
||||
|
||||
fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
|
||||
val appIcon = BitmapFactory.decodeResource(
|
||||
Resources.getSystem(),
|
||||
R.drawable.ic_launcher_monochrome
|
||||
)
|
||||
val extras = bundleOf(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON to appIcon,
|
||||
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
|
||||
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader
|
||||
)
|
||||
@ -29,3 +22,18 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
fun MediaItem.Builder.setMetadata(download: Download) = apply {
|
||||
val extras = bundleOf(
|
||||
MediaMetadataCompat.METADATA_KEY_TITLE to download.title,
|
||||
MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader
|
||||
)
|
||||
setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(download.title)
|
||||
.setArtist(download.uploader)
|
||||
.setArtworkUri(download.thumbnailPath?.toAndroidUri())
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
@ -1,25 +1,34 @@
|
||||
package com.github.libretube.helpers
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.parcelable.PlayerData
|
||||
import com.github.libretube.services.AbstractPlayerService
|
||||
import com.github.libretube.services.OfflinePlayerService
|
||||
import com.github.libretube.services.OnlinePlayerService
|
||||
import com.github.libretube.services.VideoOfflinePlayerService
|
||||
import com.github.libretube.services.VideoOnlinePlayerService
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import com.github.libretube.ui.activities.NoInternetActivity
|
||||
import com.github.libretube.ui.fragments.DownloadTab
|
||||
import com.github.libretube.ui.fragments.PlayerFragment
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
||||
/**
|
||||
* Helper for starting a new Instance of the [OnlinePlayerService]
|
||||
*/
|
||||
object BackgroundHelper {
|
||||
|
||||
/**
|
||||
* Start the foreground service [OnlinePlayerService] to play in background. [position]
|
||||
* is seek to position specified in milliseconds in the current [videoId].
|
||||
@ -35,26 +44,31 @@ object BackgroundHelper {
|
||||
) {
|
||||
// close the previous video player if open
|
||||
if (!keepVideoPlayerAlive) {
|
||||
val fragmentManager = ContextHelper.unwrapActivity<MainActivity>(context).supportFragmentManager
|
||||
val fragmentManager =
|
||||
ContextHelper.unwrapActivity<MainActivity>(context).supportFragmentManager
|
||||
fragmentManager.fragments.firstOrNull { it is PlayerFragment }?.let {
|
||||
fragmentManager.commit { remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// create an intent for the background mode service
|
||||
val playerData = PlayerData(videoId, playlistId, channelId, keepQueue, position)
|
||||
val intent = Intent(context, OnlinePlayerService::class.java)
|
||||
.putExtra(IntentData.playerData, playerData)
|
||||
|
||||
// start the background mode as foreground service
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
val sessionToken =
|
||||
SessionToken(context, ComponentName(context, OnlinePlayerService::class.java))
|
||||
|
||||
startMediaService(context, sessionToken, bundleOf(IntentData.playerData to playerData))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the [OnlinePlayerService] service if it is running.
|
||||
*/
|
||||
fun stopBackgroundPlay(context: Context) {
|
||||
arrayOf(OnlinePlayerService::class.java, OfflinePlayerService::class.java).forEach {
|
||||
arrayOf(
|
||||
OnlinePlayerService::class.java,
|
||||
OfflinePlayerService::class.java,
|
||||
VideoOfflinePlayerService::class.java,
|
||||
VideoOnlinePlayerService::class.java
|
||||
).forEach {
|
||||
val intent = Intent(context, it)
|
||||
context.stopService(intent)
|
||||
}
|
||||
@ -78,18 +92,46 @@ object BackgroundHelper {
|
||||
* @param context the current context
|
||||
* @param videoId the videoId of the video or null if all available downloads should be shuffled
|
||||
*/
|
||||
fun playOnBackgroundOffline(context: Context, videoId: String?, downloadTab: DownloadTab, shuffle: Boolean = false) {
|
||||
fun playOnBackgroundOffline(
|
||||
context: Context,
|
||||
videoId: String?,
|
||||
downloadTab: DownloadTab,
|
||||
shuffle: Boolean = false
|
||||
) {
|
||||
stopBackgroundPlay(context)
|
||||
|
||||
// whether the service is started from the MainActivity or NoInternetActivity
|
||||
val noInternet = ContextHelper.tryUnwrapActivity<NoInternetActivity>(context) != null
|
||||
|
||||
val playerIntent = Intent(context, OfflinePlayerService::class.java)
|
||||
.putExtra(IntentData.videoId, videoId)
|
||||
.putExtra(IntentData.shuffle, shuffle)
|
||||
.putExtra(IntentData.downloadTab, downloadTab)
|
||||
.putExtra(IntentData.noInternet, noInternet)
|
||||
val arguments = bundleOf(
|
||||
IntentData.videoId to videoId,
|
||||
IntentData.shuffle to shuffle,
|
||||
IntentData.downloadTab to downloadTab,
|
||||
IntentData.noInternet to noInternet
|
||||
)
|
||||
|
||||
ContextCompat.startForegroundService(context, playerIntent)
|
||||
val sessionToken =
|
||||
SessionToken(context, ComponentName(context, OfflinePlayerService::class.java))
|
||||
|
||||
startMediaService(context, sessionToken, arguments)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun startMediaService(
|
||||
context: Context,
|
||||
sessionToken: SessionToken,
|
||||
arguments: Bundle,
|
||||
onController: (MediaController) -> Unit = {}
|
||||
) {
|
||||
val controllerFuture =
|
||||
MediaController.Builder(context, sessionToken).buildAsync()
|
||||
controllerFuture.addListener({
|
||||
val controller = controllerFuture.get()
|
||||
controller.sendCustomCommand(
|
||||
AbstractPlayerService.startServiceCommand,
|
||||
arguments
|
||||
)
|
||||
onController(controller)
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
}
|
||||
|
@ -598,7 +598,7 @@ object PlayerHelper {
|
||||
* @param segments List of the SponsorBlock segments
|
||||
* @return If segment found and should skip manually, the end position of the segment in ms, otherwise null
|
||||
*/
|
||||
fun ExoPlayer.checkForSegments(
|
||||
fun Player.checkForSegments(
|
||||
context: Context,
|
||||
segments: List<Segment>,
|
||||
sponsorBlockConfig: MutableMap<String, SbSkipOptions>
|
||||
@ -633,7 +633,7 @@ object PlayerHelper {
|
||||
return null
|
||||
}
|
||||
|
||||
fun ExoPlayer.isInSegment(segments: List<Segment>): Boolean {
|
||||
fun Player.isInSegment(segments: List<Segment>): Boolean {
|
||||
return segments.any {
|
||||
val (start, end) = it.segmentStartAndEnd
|
||||
val (segmentStart, segmentEnd) = (start * 1000f).toLong() to (end * 1000f).toLong()
|
||||
@ -835,7 +835,7 @@ object PlayerHelper {
|
||||
else -> R.drawable.ic_play
|
||||
}
|
||||
|
||||
fun saveWatchPosition(player: ExoPlayer, videoId: String) {
|
||||
fun saveWatchPosition(player: Player, videoId: String) {
|
||||
if (player.duration == C.TIME_UNSET || player.currentPosition in listOf(0L, C.TIME_UNSET)) {
|
||||
return
|
||||
}
|
||||
|
@ -1,44 +1,47 @@
|
||||
package com.github.libretube.services
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.api.obj.ChapterSegment
|
||||
import com.github.libretube.api.obj.StreamItem
|
||||
import com.github.libretube.enums.NotificationId
|
||||
import com.github.libretube.enums.PlayerCommand
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.extensions.serializableExtra
|
||||
import com.github.libretube.extensions.updateParameters
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.util.NowPlayingNotification
|
||||
import com.github.libretube.util.PauseableTimer
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@UnstableApi
|
||||
abstract class AbstractPlayerService : LifecycleService() {
|
||||
var player: ExoPlayer? = null
|
||||
var nowPlayingNotification: NowPlayingNotification? = null
|
||||
abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySession.Callback {
|
||||
private var mediaLibrarySession: MediaLibrarySession? = null
|
||||
var exoPlayer: ExoPlayer? = null
|
||||
|
||||
private var nowPlayingNotification: NowPlayingNotification? = null
|
||||
var trackSelector: DefaultTrackSelector? = null
|
||||
|
||||
lateinit var videoId: String
|
||||
@ -76,7 +79,7 @@ abstract class AbstractPlayerService : LifecycleService() {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
onStateOrPlayingChanged?.let { it(player?.isPlaying ?: false) }
|
||||
onStateOrPlayingChanged?.let { it(exoPlayer?.isPlaying ?: false) }
|
||||
|
||||
this@AbstractPlayerService.onPlaybackStateChanged(playbackState)
|
||||
}
|
||||
@ -96,103 +99,162 @@ abstract class AbstractPlayerService : LifecycleService() {
|
||||
super.onEvents(player, events)
|
||||
|
||||
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||
PlayerHelper.setPreferredAudioQuality(this@AbstractPlayerService, player, trackSelector ?: return)
|
||||
PlayerHelper.setPreferredAudioQuality(
|
||||
this@AbstractPlayerService,
|
||||
player,
|
||||
trackSelector ?: return
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val playerActionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
|
||||
val player = player ?: return
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
if (customCommand.customAction == START_SERVICE_ACTION) {
|
||||
PlayingQueue.resetToDefaults()
|
||||
|
||||
if (PlayerHelper.handlePlayerAction(player, event)) return
|
||||
|
||||
when (event) {
|
||||
PlayerEvent.Next -> {
|
||||
PlayingQueue.navigateNext()
|
||||
}
|
||||
PlayerEvent.Prev -> {
|
||||
PlayingQueue.navigatePrev()
|
||||
}
|
||||
PlayerEvent.Stop -> {
|
||||
onDestroy()
|
||||
}
|
||||
else -> Unit
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
onServiceCreated(args)
|
||||
startPlayback()
|
||||
}
|
||||
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
if (customCommand.customAction == RUN_PLAYER_COMMAND_ACTION) {
|
||||
runPlayerCommand(args)
|
||||
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
handlePlayerAction(PlayerEvent.valueOf(customCommand.customAction))
|
||||
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
open fun runPlayerCommand(args: Bundle) {
|
||||
when {
|
||||
args.containsKey(PlayerCommand.SKIP_SILENCE.name) ->
|
||||
exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlayerAction(event: PlayerEvent) {
|
||||
if (PlayerHelper.handlePlayerAction(exoPlayer ?: return, event)) return
|
||||
|
||||
when (event) {
|
||||
PlayerEvent.Next -> {
|
||||
PlayingQueue.navigateNext()
|
||||
}
|
||||
|
||||
PlayerEvent.Prev -> {
|
||||
PlayingQueue.navigatePrev()
|
||||
}
|
||||
|
||||
PlayerEvent.Stop -> {
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
abstract val isOfflinePlayer: Boolean
|
||||
abstract val isAudioOnlyPlayer: Boolean
|
||||
abstract val intentActivity: Class<*>
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? =
|
||||
mediaLibrarySession
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.playingOnBackground))
|
||||
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
|
||||
.build()
|
||||
|
||||
startForeground(NotificationId.PLAYER_PLAYBACK.id, notification)
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
val notificationProvider = NowPlayingNotification(
|
||||
this,
|
||||
playerActionReceiver,
|
||||
IntentFilter(PlayerHelper.getIntentActionName(this)),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
PlayingQueue.resetToDefaults()
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (intent != null) {
|
||||
onServiceCreated(intent)
|
||||
createPlayerAndNotification()
|
||||
startPlaybackAndUpdateNotification()
|
||||
}
|
||||
else stopSelf()
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
abstract suspend fun onServiceCreated(intent: Intent)
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createPlayerAndNotification() {
|
||||
val trackSelector = DefaultTrackSelector(this)
|
||||
this.trackSelector = trackSelector
|
||||
|
||||
trackSelector.updateParameters {
|
||||
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
||||
}
|
||||
|
||||
player = PlayerHelper.createPlayer(this, trackSelector, true)
|
||||
// prevent android from putting LibreTube to sleep when locked
|
||||
player!!.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
player!!.addListener(playerListener)
|
||||
|
||||
PlayerHelper.setPreferredCodecs(trackSelector)
|
||||
|
||||
nowPlayingNotification = NowPlayingNotification(
|
||||
this,
|
||||
player!!,
|
||||
backgroundOnly = true,
|
||||
offlinePlayer = isOfflinePlayer,
|
||||
intentActivity = intentActivity
|
||||
)
|
||||
setMediaNotificationProvider(notificationProvider)
|
||||
|
||||
createPlayerAndMediaSession()
|
||||
}
|
||||
|
||||
abstract suspend fun startPlaybackAndUpdateNotification()
|
||||
abstract suspend fun onServiceCreated(args: Bundle)
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
val connectionResult = super.onConnect(session, controller)
|
||||
|
||||
// Select the button to display.
|
||||
val customLayout = listOf(
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(getString(R.string.rewind))
|
||||
.setSessionCommand(SessionCommand(PlayerEvent.Prev.name, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_prev_outlined)
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(getString(R.string.play_next))
|
||||
.setSessionCommand(SessionCommand(PlayerEvent.Next.name, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_next_outlined)
|
||||
.build(),
|
||||
)
|
||||
val mediaNotificationSessionCommands =
|
||||
connectionResult.availableSessionCommands.buildUpon()
|
||||
.also { builder ->
|
||||
builder.add(startServiceCommand)
|
||||
builder.add(runPlayerActionCommand)
|
||||
customLayout.forEach { commandButton ->
|
||||
commandButton.sessionCommand?.let { builder.add(it) }
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
val playerCommands = connectionResult.availablePlayerCommands.buildUpon()
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.build()
|
||||
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(mediaNotificationSessionCommands)
|
||||
.setAvailablePlayerCommands(playerCommands)
|
||||
.setCustomLayout(customLayout)
|
||||
.build()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createPlayerAndMediaSession() {
|
||||
val trackSelector = DefaultTrackSelector(this)
|
||||
this.trackSelector = trackSelector
|
||||
|
||||
if (isAudioOnlyPlayer) {
|
||||
trackSelector.updateParameters {
|
||||
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
||||
}
|
||||
}
|
||||
|
||||
val player = PlayerHelper.createPlayer(this, trackSelector, true)
|
||||
// prevent android from putting LibreTube to sleep when locked
|
||||
player.setWakeMode(if (isOfflinePlayer) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NETWORK)
|
||||
player.addListener(playerListener)
|
||||
this.exoPlayer = player
|
||||
|
||||
PlayerHelper.setPreferredCodecs(trackSelector)
|
||||
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, this).build()
|
||||
}
|
||||
|
||||
abstract suspend fun startPlayback()
|
||||
|
||||
fun saveWatchPosition() {
|
||||
if (isTransitioning || !PlayerHelper.watchPositionsVideo) return
|
||||
|
||||
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
|
||||
exoPlayer?.let { PlayerHelper.saveWatchPosition(it, videoId) }
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@ -200,20 +262,19 @@ abstract class AbstractPlayerService : LifecycleService() {
|
||||
|
||||
saveWatchPosition()
|
||||
|
||||
nowPlayingNotification?.destroySelf()
|
||||
nowPlayingNotification = null
|
||||
watchPositionTimer.destroy()
|
||||
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
|
||||
runCatching {
|
||||
player?.stop()
|
||||
player?.release()
|
||||
exoPlayer?.stop()
|
||||
exoPlayer?.release()
|
||||
}
|
||||
player = null
|
||||
|
||||
runCatching {
|
||||
unregisterReceiver(playerActionReceiver)
|
||||
kotlin.runCatching {
|
||||
mediaLibrarySession?.release()
|
||||
mediaLibrarySession = null
|
||||
}
|
||||
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
@ -234,19 +295,27 @@ abstract class AbstractPlayerService : LifecycleService() {
|
||||
|
||||
abstract fun getChapters(): List<ChapterSegment>
|
||||
|
||||
fun getCurrentPosition() = player?.currentPosition
|
||||
fun getCurrentPosition() = exoPlayer?.currentPosition
|
||||
|
||||
fun getDuration() = player?.duration
|
||||
fun getDuration() = exoPlayer?.duration
|
||||
|
||||
fun seekToPosition(position: Long) = player?.seekTo(position)
|
||||
fun seekToPosition(position: Long) = exoPlayer?.seekTo(position)
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
// Return this instance of [AbstractPlayerService] so clients can call public methods
|
||||
fun getService(): AbstractPlayerService = this@AbstractPlayerService
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
super.onBind(intent)
|
||||
return binder
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
// attempt to return [MediaLibraryServiceBinder] first if matched
|
||||
return super.onBind(intent) ?: binder
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val START_SERVICE_ACTION = "start_service_action"
|
||||
private const val RUN_PLAYER_COMMAND_ACTION = "run_player_command_action"
|
||||
|
||||
val startServiceCommand = SessionCommand(START_SERVICE_ACTION, Bundle.EMPTY)
|
||||
val runPlayerActionCommand = SessionCommand(RUN_PLAYER_COMMAND_ACTION, Bundle.EMPTY)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
package com.github.libretube.services
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@ -12,15 +13,16 @@ import com.github.libretube.db.obj.DownloadChapter
|
||||
import com.github.libretube.db.obj.DownloadWithItems
|
||||
import com.github.libretube.db.obj.filterByTab
|
||||
import com.github.libretube.enums.FileType
|
||||
import com.github.libretube.extensions.serializableExtra
|
||||
import com.github.libretube.extensions.serializable
|
||||
import com.github.libretube.extensions.setMetadata
|
||||
import com.github.libretube.extensions.toAndroidUri
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import com.github.libretube.ui.activities.NoInternetActivity
|
||||
import com.github.libretube.ui.fragments.DownloadTab
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@ -30,9 +32,10 @@ import kotlin.io.path.exists
|
||||
/**
|
||||
* A service to play downloaded audio in the background
|
||||
*/
|
||||
@UnstableApi
|
||||
class OfflinePlayerService : AbstractPlayerService() {
|
||||
@OptIn(UnstableApi::class)
|
||||
open class OfflinePlayerService : AbstractPlayerService() {
|
||||
override val isOfflinePlayer: Boolean = true
|
||||
override val isAudioOnlyPlayer: Boolean = true
|
||||
private var noInternetService: Boolean = false
|
||||
override val intentActivity: Class<*>
|
||||
get() = if (noInternetService) NoInternetActivity::class.java else MainActivity::class.java
|
||||
@ -41,17 +44,19 @@ class OfflinePlayerService : AbstractPlayerService() {
|
||||
private lateinit var downloadTab: DownloadTab
|
||||
private var shuffle: Boolean = false
|
||||
|
||||
override suspend fun onServiceCreated(intent: Intent) {
|
||||
downloadTab = intent.serializableExtra(IntentData.downloadTab)!!
|
||||
shuffle = intent.getBooleanExtra(IntentData.shuffle, false)
|
||||
noInternetService = intent.getBooleanExtra(IntentData.noInternet, false)
|
||||
private val scope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
override suspend fun onServiceCreated(args: Bundle) {
|
||||
downloadTab = args.serializable(IntentData.downloadTab)!!
|
||||
shuffle = args.getBoolean(IntentData.shuffle, false)
|
||||
noInternetService = args.getBoolean(IntentData.noInternet, false)
|
||||
|
||||
videoId = if (shuffle) {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
Database.downloadDao().getRandomVideoIdByFileType(FileType.AUDIO)
|
||||
}
|
||||
} else {
|
||||
intent.getStringExtra(IntentData.videoId)
|
||||
args.getString(IntentData.videoId)
|
||||
} ?: return
|
||||
|
||||
PlayingQueue.clear()
|
||||
@ -66,7 +71,7 @@ class OfflinePlayerService : AbstractPlayerService() {
|
||||
/**
|
||||
* Attempt to start an audio player with the given download items
|
||||
*/
|
||||
override suspend fun startPlaybackAndUpdateNotification() {
|
||||
override suspend fun startPlayback() {
|
||||
val downloadWithItems = withContext(Dispatchers.IO) {
|
||||
Database.downloadDao().findById(videoId)
|
||||
}!!
|
||||
@ -75,13 +80,20 @@ class OfflinePlayerService : AbstractPlayerService() {
|
||||
|
||||
PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem())
|
||||
|
||||
val notificationData = PlayerNotificationData(
|
||||
title = downloadWithItems.download.title,
|
||||
uploaderName = downloadWithItems.download.uploader,
|
||||
thumbnailPath = downloadWithItems.download.thumbnailPath
|
||||
)
|
||||
nowPlayingNotification?.updatePlayerNotification(videoId, notificationData)
|
||||
withContext(Dispatchers.Main) {
|
||||
setMediaItem(downloadWithItems)
|
||||
exoPlayer?.playWhenReady = PlayerHelper.playAutomatically
|
||||
exoPlayer?.prepare()
|
||||
|
||||
if (PlayerHelper.watchPositionsAudio) {
|
||||
PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let {
|
||||
exoPlayer?.seekTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun setMediaItem(downloadWithItems: DownloadWithItems) {
|
||||
val audioItem = downloadWithItems.downloadItems.filter { it.path.exists() }
|
||||
.firstOrNull { it.type == FileType.AUDIO }
|
||||
?: // in some rare cases, video files can contain audio
|
||||
@ -94,17 +106,10 @@ class OfflinePlayerService : AbstractPlayerService() {
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(audioItem.path.toAndroidUri())
|
||||
.setMetadata(downloadWithItems.download)
|
||||
.build()
|
||||
|
||||
player?.setMediaItem(mediaItem)
|
||||
player?.playWhenReady = PlayerHelper.playAutomatically
|
||||
player?.prepare()
|
||||
|
||||
if (PlayerHelper.watchPositionsAudio) {
|
||||
PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let {
|
||||
player?.seekTo(it)
|
||||
}
|
||||
}
|
||||
exoPlayer?.setMediaItem(mediaItem)
|
||||
}
|
||||
|
||||
private suspend fun fillQueue() {
|
||||
@ -124,8 +129,8 @@ class OfflinePlayerService : AbstractPlayerService() {
|
||||
|
||||
this.videoId = videoId
|
||||
|
||||
lifecycleScope.launch {
|
||||
startPlaybackAndUpdateNotification()
|
||||
scope.launch {
|
||||
startPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
package com.github.libretube.services
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
@ -14,17 +13,17 @@ import com.github.libretube.api.obj.Segment
|
||||
import com.github.libretube.api.obj.Streams
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.db.DatabaseHelper
|
||||
import com.github.libretube.extensions.parcelableExtra
|
||||
import com.github.libretube.extensions.parcelable
|
||||
import com.github.libretube.extensions.setMetadata
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.helpers.PlayerHelper.checkForSegments
|
||||
import com.github.libretube.helpers.ProxyHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
import com.github.libretube.parcelable.PlayerData
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -36,6 +35,7 @@ import kotlinx.serialization.encodeToString
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class OnlinePlayerService : AbstractPlayerService() {
|
||||
override val isOfflinePlayer: Boolean = false
|
||||
override val isAudioOnlyPlayer: Boolean = true
|
||||
override val intentActivity: Class<*> = MainActivity::class.java
|
||||
|
||||
// PlaylistId/ChannelId for autoplay
|
||||
@ -53,8 +53,10 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
private var sponsorBlockSegments = listOf<Segment>()
|
||||
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
||||
|
||||
override suspend fun onServiceCreated(intent: Intent) {
|
||||
val playerData = intent.parcelableExtra<PlayerData>(IntentData.playerData)
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
override suspend fun onServiceCreated(args: Bundle) {
|
||||
val playerData = args.parcelable<PlayerData>(IntentData.playerData)
|
||||
if (playerData == null) {
|
||||
stopSelf()
|
||||
return
|
||||
@ -72,7 +74,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startPlaybackAndUpdateNotification() {
|
||||
override suspend fun startPlayback() {
|
||||
val timestamp = startTimestamp ?: 0L
|
||||
startTimestamp = null
|
||||
|
||||
@ -110,30 +112,24 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
}
|
||||
|
||||
private fun playAudio(seekToPosition: Long) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
scope.launch {
|
||||
setMediaItem()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// seek to the previous position if available
|
||||
if (seekToPosition != 0L) {
|
||||
player?.seekTo(seekToPosition)
|
||||
exoPlayer?.seekTo(seekToPosition)
|
||||
} else if (PlayerHelper.watchPositionsAudio) {
|
||||
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
|
||||
player?.seekTo(it)
|
||||
exoPlayer?.seekTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val playerNotificationData = PlayerNotificationData(
|
||||
streams?.title,
|
||||
streams?.uploader,
|
||||
streams?.thumbnailUrl
|
||||
)
|
||||
nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData)
|
||||
streams?.let { onNewVideoStarted?.invoke(it.toStreamItem(videoId)) }
|
||||
|
||||
player?.apply {
|
||||
exoPlayer?.apply {
|
||||
playWhenReady = PlayerHelper.playAutomatically
|
||||
prepare()
|
||||
}
|
||||
@ -146,7 +142,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
*/
|
||||
private fun playNextVideo(nextId: String? = null) {
|
||||
if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
|
||||
player?.seekTo(0)
|
||||
exoPlayer?.seekTo(0)
|
||||
return
|
||||
}
|
||||
|
||||
@ -161,13 +157,13 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
this.streams = null
|
||||
this.sponsorBlockSegments = emptyList()
|
||||
|
||||
lifecycleScope.launch {
|
||||
startPlaybackAndUpdateNotification()
|
||||
scope.launch {
|
||||
startPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the [MediaItem] with the [streams] into the [player]
|
||||
* Sets the [MediaItem] with the [streams] into the [exoPlayer]
|
||||
*/
|
||||
private suspend fun setMediaItem() {
|
||||
val streams = streams ?: return
|
||||
@ -185,14 +181,14 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
.setMimeType(mimeType)
|
||||
.setMetadata(streams)
|
||||
.build()
|
||||
withContext(Dispatchers.Main) { player?.setMediaItem(mediaItem) }
|
||||
withContext(Dispatchers.Main) { exoPlayer?.setMediaItem(mediaItem) }
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch the segments for SponsorBlock
|
||||
*/
|
||||
private fun fetchSponsorBlockSegments() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (sponsorBlockConfig.isEmpty()) return@runCatching
|
||||
sponsorBlockSegments = RetrofitInstance.api.getSegments(
|
||||
@ -210,7 +206,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
private fun checkForSegments() {
|
||||
handler.postDelayed(this::checkForSegments, 100)
|
||||
|
||||
player?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig)
|
||||
exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
@ -230,7 +226,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
// save video to watch history when the video starts playing or is being resumed
|
||||
// waiting for the player to be ready since the video can't be claimed to be watched
|
||||
// while it did not yet start actually, but did buffer only so far
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) }
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -1,29 +1,23 @@
|
||||
package com.github.libretube.ui.activities
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.SubtitleConfiguration
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.FileDataSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.github.libretube.compat.PictureInPictureCompat
|
||||
import com.github.libretube.compat.PictureInPictureParamsCompat
|
||||
@ -36,19 +30,16 @@ import com.github.libretube.db.obj.filterByTab
|
||||
import com.github.libretube.enums.FileType
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.extensions.serializableExtra
|
||||
import com.github.libretube.extensions.toAndroidUri
|
||||
import com.github.libretube.extensions.updateParameters
|
||||
import com.github.libretube.helpers.BackgroundHelper
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.helpers.WindowHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
import com.github.libretube.services.VideoOfflinePlayerService
|
||||
import com.github.libretube.ui.base.BaseActivity
|
||||
import com.github.libretube.ui.fragments.DownloadTab
|
||||
import com.github.libretube.ui.interfaces.TimeFrameReceiver
|
||||
import com.github.libretube.ui.listeners.SeekbarPreviewListener
|
||||
import com.github.libretube.ui.models.ChaptersViewModel
|
||||
import com.github.libretube.ui.models.CommonPlayerViewModel
|
||||
import com.github.libretube.ui.models.OfflinePlayerViewModel
|
||||
import com.github.libretube.util.NowPlayingNotification
|
||||
import com.github.libretube.util.OfflineTimeFrameReceiver
|
||||
import com.github.libretube.util.PauseableTimer
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
@ -61,13 +52,13 @@ import kotlin.io.path.exists
|
||||
class OfflinePlayerActivity : BaseActivity() {
|
||||
private lateinit var binding: ActivityOfflinePlayerBinding
|
||||
private lateinit var videoId: String
|
||||
|
||||
private lateinit var playerController: MediaController
|
||||
private lateinit var playerView: PlayerView
|
||||
private var timeFrameReceiver: TimeFrameReceiver? = null
|
||||
private var nowPlayingNotification: NowPlayingNotification? = null
|
||||
|
||||
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
|
||||
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
|
||||
private val viewModel: OfflinePlayerViewModel by viewModels { OfflinePlayerViewModel.Factory }
|
||||
private val chaptersViewModel: ChaptersViewModel by viewModels()
|
||||
|
||||
private val watchPositionTimer = PauseableTimer(
|
||||
@ -82,6 +73,13 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
playerBinding.duration.text = DateUtils.formatElapsedTime(
|
||||
player.duration / 1000
|
||||
)
|
||||
|
||||
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||
requestedOrientation = PlayerHelper.getOrientation(
|
||||
playerController.videoSize.width,
|
||||
playerController.videoSize.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
@ -110,7 +108,7 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
SeekbarPreviewListener(
|
||||
timeFrameReceiver ?: return,
|
||||
binding.player.binding,
|
||||
viewModel.player.duration
|
||||
playerController.duration
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -124,7 +122,7 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
private val playerActionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
|
||||
if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return
|
||||
if (PlayerHelper.handlePlayerAction(playerController, event)) return
|
||||
|
||||
when (event) {
|
||||
PlayerEvent.Prev -> playNextVideo(PlayingQueue.getPrev() ?: return)
|
||||
@ -135,17 +133,23 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private val pipParams
|
||||
get() = PictureInPictureParamsCompat.Builder()
|
||||
.setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying))
|
||||
.setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying)
|
||||
.setAspectRatio(viewModel.player.videoSize)
|
||||
.build()
|
||||
get() = run {
|
||||
val isPlaying = ::playerController.isInitialized && playerController.isPlaying
|
||||
|
||||
PictureInPictureParamsCompat.Builder()
|
||||
.setActions(PlayerHelper.getPiPModeActions(this,isPlaying))
|
||||
.setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying)
|
||||
.apply {
|
||||
if (isPlaying) {
|
||||
setAspectRatio(playerController.videoSize)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
WindowHelper.toggleFullscreen(window, true)
|
||||
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
videoId = intent?.getStringExtra(IntentData.videoId)!!
|
||||
@ -160,13 +164,19 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
|
||||
}
|
||||
|
||||
initializePlayer()
|
||||
playVideo()
|
||||
|
||||
requestedOrientation = PlayerHelper.getOrientation(
|
||||
viewModel.player.videoSize.width,
|
||||
viewModel.player.videoSize.height
|
||||
val sessionToken = SessionToken(
|
||||
this,
|
||||
ComponentName(this, VideoOfflinePlayerService::class.java)
|
||||
)
|
||||
val arguments = bundleOf(
|
||||
IntentData.downloadTab to DownloadTab.VIDEO,
|
||||
IntentData.videoId to videoId
|
||||
)
|
||||
BackgroundHelper.startMediaService(this, sessionToken, arguments) {
|
||||
playerController = it
|
||||
playerController.addListener(playerListener)
|
||||
initializePlayerView()
|
||||
}
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
@ -188,14 +198,11 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
playVideo()
|
||||
}
|
||||
|
||||
private fun initializePlayer() {
|
||||
viewModel.player.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
viewModel.player.addListener(playerListener)
|
||||
|
||||
private fun initializePlayerView() {
|
||||
playerView = binding.player
|
||||
playerView.setShowSubtitleButton(true)
|
||||
playerView.subtitleView?.isVisible = true
|
||||
playerView.player = viewModel.player
|
||||
playerView.player = playerController
|
||||
playerBinding = binding.player.binding
|
||||
|
||||
playerBinding.fullscreen.isInvisible = true
|
||||
@ -216,13 +223,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
binding.playerGestureControlsView.binding,
|
||||
chaptersViewModel
|
||||
)
|
||||
|
||||
nowPlayingNotification = NowPlayingNotification(
|
||||
this,
|
||||
viewModel.player,
|
||||
offlinePlayer = true,
|
||||
intentActivity = OfflinePlayerActivity::class.java
|
||||
)
|
||||
}
|
||||
|
||||
private fun playVideo() {
|
||||
@ -230,7 +230,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) {
|
||||
Database.downloadDao().findById(videoId)
|
||||
}!!
|
||||
PlayingQueue.updateCurrent(downloadInfo.toStreamItem())
|
||||
|
||||
val chapters = downloadChapters.map(DownloadChapter::toChapterSegment)
|
||||
chaptersViewModel.chaptersLiveData.value = chapters
|
||||
@ -240,88 +239,15 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
playerBinding.exoTitle.text = downloadInfo.title
|
||||
playerBinding.exoTitle.isVisible = true
|
||||
|
||||
val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO }
|
||||
val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO }
|
||||
val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE }
|
||||
|
||||
val videoUri = video?.path?.toAndroidUri()
|
||||
val audioUri = audio?.path?.toAndroidUri()
|
||||
val subtitleUri = subtitle?.path?.toAndroidUri()
|
||||
|
||||
setMediaSource(videoUri, audioUri, subtitleUri)
|
||||
|
||||
viewModel.trackSelector.updateParameters {
|
||||
setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)
|
||||
setPreferredTextLanguage("en")
|
||||
}
|
||||
|
||||
timeFrameReceiver = video?.path?.let {
|
||||
timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let {
|
||||
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it)
|
||||
}
|
||||
|
||||
viewModel.player.playWhenReady = PlayerHelper.playAutomatically
|
||||
viewModel.player.prepare()
|
||||
|
||||
if (PlayerHelper.watchPositionsVideo) {
|
||||
PlayerHelper.getStoredWatchPosition(videoId, downloadInfo.duration)?.let {
|
||||
viewModel.player.seekTo(it)
|
||||
playerController.seekTo(it)
|
||||
}
|
||||
}
|
||||
|
||||
val data = PlayerNotificationData(
|
||||
downloadInfo.title,
|
||||
downloadInfo.uploader,
|
||||
downloadInfo.thumbnailPath.toString()
|
||||
)
|
||||
nowPlayingNotification?.updatePlayerNotification(videoId, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMediaSource(videoUri: Uri?, audioUri: Uri?, subtitleUri: Uri?) {
|
||||
val subtitle = subtitleUri?.let {
|
||||
SubtitleConfiguration.Builder(it)
|
||||
.setMimeType(MimeTypes.APPLICATION_TTML)
|
||||
.setLanguage("en")
|
||||
.build()
|
||||
}
|
||||
|
||||
when {
|
||||
videoUri != null && audioUri != null -> {
|
||||
val videoItem = MediaItem.Builder()
|
||||
.setUri(videoUri)
|
||||
.setSubtitleConfigurations(listOfNotNull(subtitle))
|
||||
.build()
|
||||
|
||||
val videoSource = ProgressiveMediaSource.Factory(FileDataSource.Factory())
|
||||
.createMediaSource(videoItem)
|
||||
|
||||
val audioSource = ProgressiveMediaSource.Factory(FileDataSource.Factory())
|
||||
.createMediaSource(MediaItem.fromUri(audioUri))
|
||||
|
||||
var mediaSource = MergingMediaSource(audioSource, videoSource)
|
||||
if (subtitle != null) {
|
||||
val subtitleSource = SingleSampleMediaSource.Factory(FileDataSource.Factory())
|
||||
.createMediaSource(subtitle, C.TIME_UNSET)
|
||||
|
||||
mediaSource = MergingMediaSource(mediaSource, subtitleSource)
|
||||
}
|
||||
|
||||
viewModel.player.setMediaSource(mediaSource)
|
||||
}
|
||||
|
||||
videoUri != null -> viewModel.player.setMediaItem(
|
||||
MediaItem.Builder()
|
||||
.setUri(videoUri)
|
||||
.setSubtitleConfigurations(listOfNotNull(subtitle))
|
||||
.build()
|
||||
)
|
||||
|
||||
audioUri != null -> viewModel.player.setMediaItem(
|
||||
MediaItem.Builder()
|
||||
.setUri(audioUri)
|
||||
.setSubtitleConfigurations(listOfNotNull(subtitle))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,7 +262,7 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
private fun saveWatchPosition() {
|
||||
if (!PlayerHelper.watchPositionsVideo) return
|
||||
|
||||
PlayerHelper.saveWatchPosition(viewModel.player, videoId)
|
||||
PlayerHelper.saveWatchPosition(playerController, videoId)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@ -349,19 +275,17 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
super.onPause()
|
||||
|
||||
if (PlayerHelper.pauseOnQuit) {
|
||||
viewModel.player.pause()
|
||||
playerController.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
saveWatchPosition()
|
||||
|
||||
nowPlayingNotification?.destroySelf()
|
||||
nowPlayingNotification = null
|
||||
watchPositionTimer.destroy()
|
||||
|
||||
runCatching {
|
||||
viewModel.player.stop()
|
||||
playerController.stop()
|
||||
}
|
||||
|
||||
runCatching {
|
||||
@ -372,7 +296,7 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
if (PlayerHelper.pipEnabled && viewModel.player.isPlaying) {
|
||||
if (PlayerHelper.pipEnabled && playerController.isPlaying) {
|
||||
PictureInPictureCompat.enterPictureInPictureMode(this, pipParams)
|
||||
}
|
||||
|
||||
|
@ -155,10 +155,10 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
it.text = (PlayerHelper.seekIncrement / 1000).toString()
|
||||
}
|
||||
binding.rewindFL.setOnClickListener {
|
||||
playerService?.player?.seekBy(-PlayerHelper.seekIncrement)
|
||||
playerService?.exoPlayer?.seekBy(-PlayerHelper.seekIncrement)
|
||||
}
|
||||
binding.forwardFL.setOnClickListener {
|
||||
playerService?.player?.seekBy(PlayerHelper.seekIncrement)
|
||||
playerService?.exoPlayer?.seekBy(PlayerHelper.seekIncrement)
|
||||
}
|
||||
|
||||
binding.openQueue.setOnClickListener {
|
||||
@ -166,7 +166,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
}
|
||||
|
||||
binding.playbackOptions.setOnClickListener {
|
||||
playerService?.player?.let {
|
||||
playerService?.exoPlayer?.let {
|
||||
PlaybackOptionsSheet(it)
|
||||
.show(childFragmentManager)
|
||||
}
|
||||
@ -182,7 +182,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
NavigationHelper.navigateVideo(
|
||||
context = requireContext(),
|
||||
videoUrlOrId = PlayingQueue.getCurrent()?.url,
|
||||
timestamp = playerService?.player?.currentPosition?.div(1000) ?: 0,
|
||||
timestamp = playerService?.exoPlayer?.currentPosition?.div(1000) ?: 0,
|
||||
keepQueue = true,
|
||||
forceVideo = true
|
||||
)
|
||||
@ -192,7 +192,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
ChaptersBottomSheet.SEEK_TO_POSITION_REQUEST_KEY,
|
||||
viewLifecycleOwner
|
||||
) { _, bundle ->
|
||||
playerService?.player?.seekTo(bundle.getLong(IntentData.currentPosition))
|
||||
playerService?.exoPlayer?.seekTo(bundle.getLong(IntentData.currentPosition))
|
||||
}
|
||||
|
||||
binding.openChapters.setOnClickListener {
|
||||
@ -202,7 +202,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
ChaptersBottomSheet()
|
||||
.apply {
|
||||
arguments = bundleOf(
|
||||
IntentData.duration to playerService.player?.duration?.div(1000)
|
||||
IntentData.duration to playerService.exoPlayer?.duration?.div(1000)
|
||||
)
|
||||
}
|
||||
.show(childFragmentManager)
|
||||
@ -218,11 +218,11 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
binding.thumbnail.setOnTouchListener(listener)
|
||||
|
||||
binding.playPause.setOnClickListener {
|
||||
playerService?.player?.togglePlayPauseState()
|
||||
playerService?.exoPlayer?.togglePlayPauseState()
|
||||
}
|
||||
|
||||
binding.miniPlayerPause.setOnClickListener {
|
||||
playerService?.player?.togglePlayPauseState()
|
||||
playerService?.exoPlayer?.togglePlayPauseState()
|
||||
}
|
||||
|
||||
binding.showMore.setOnClickListener {
|
||||
@ -381,7 +381,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
}
|
||||
|
||||
private fun updatePlayPauseButton() {
|
||||
playerService?.player?.let {
|
||||
playerService?.exoPlayer?.let {
|
||||
val binding = _binding ?: return
|
||||
|
||||
val iconRes = PlayerHelper.getPlayPauseActionIcon(it)
|
||||
@ -396,8 +396,10 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
isPaused = !isPlaying
|
||||
}
|
||||
playerService?.onNewVideoStarted = { streamItem ->
|
||||
updateStreamInfo(streamItem)
|
||||
_binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty()
|
||||
handler.post {
|
||||
updateStreamInfo(streamItem)
|
||||
_binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
initializeSeekBar()
|
||||
|
||||
@ -422,7 +424,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
}
|
||||
|
||||
override fun onSingleTap() {
|
||||
playerService?.player?.togglePlayPauseState()
|
||||
playerService?.exoPlayer?.togglePlayPauseState()
|
||||
}
|
||||
|
||||
override fun onLongTap() {
|
||||
@ -479,7 +481,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
|
||||
if (_binding == null) return
|
||||
handler.postDelayed(this::updateChapterIndex, 100)
|
||||
|
||||
val player = playerService?.player ?: return
|
||||
val player = playerService?.exoPlayer ?: return
|
||||
|
||||
val currentIndex =
|
||||
PlayerHelper.getCurrentChapterIndex(player.currentPosition, chaptersModel.chapters)
|
||||
|
@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
@ -11,7 +12,6 @@ import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
@ -45,16 +45,12 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.SubtitleConfiguration
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.cronet.CronetDataSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.api.CronetHelper
|
||||
import com.github.libretube.api.obj.ChapterSegment
|
||||
import com.github.libretube.api.obj.Segment
|
||||
import com.github.libretube.api.obj.Streams
|
||||
@ -66,17 +62,16 @@ import com.github.libretube.constants.PreferenceKeys
|
||||
import com.github.libretube.databinding.FragmentPlayerBinding
|
||||
import com.github.libretube.db.DatabaseHelper
|
||||
import com.github.libretube.db.DatabaseHolder
|
||||
import com.github.libretube.enums.PlayerCommand
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.enums.ShareObjectType
|
||||
import com.github.libretube.extensions.formatShort
|
||||
import com.github.libretube.extensions.parcelable
|
||||
import com.github.libretube.extensions.serializableExtra
|
||||
import com.github.libretube.extensions.setMetadata
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
||||
import com.github.libretube.extensions.togglePlayPauseState
|
||||
import com.github.libretube.extensions.updateIfChanged
|
||||
import com.github.libretube.extensions.updateParameters
|
||||
import com.github.libretube.helpers.BackgroundHelper
|
||||
import com.github.libretube.helpers.DownloadHelper
|
||||
import com.github.libretube.helpers.ImageHelper
|
||||
@ -85,16 +80,16 @@ import com.github.libretube.helpers.NavBarHelper
|
||||
import com.github.libretube.helpers.NavigationHelper
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.helpers.PlayerHelper.checkForSegments
|
||||
import com.github.libretube.helpers.PlayerHelper.getVideoStats
|
||||
import com.github.libretube.helpers.PlayerHelper.isInSegment
|
||||
import com.github.libretube.helpers.PreferenceHelper
|
||||
import com.github.libretube.helpers.ProxyHelper
|
||||
import com.github.libretube.helpers.ThemeHelper
|
||||
import com.github.libretube.helpers.WindowHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
import com.github.libretube.obj.ShareData
|
||||
import com.github.libretube.obj.VideoResolution
|
||||
import com.github.libretube.parcelable.PlayerData
|
||||
import com.github.libretube.services.AbstractPlayerService
|
||||
import com.github.libretube.services.VideoOnlinePlayerService
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import com.github.libretube.ui.adapters.VideosAdapter
|
||||
import com.github.libretube.ui.base.BaseActivity
|
||||
@ -111,19 +106,15 @@ import com.github.libretube.ui.models.CommonPlayerViewModel
|
||||
import com.github.libretube.ui.models.PlayerViewModel
|
||||
import com.github.libretube.ui.sheets.BaseBottomSheet
|
||||
import com.github.libretube.ui.sheets.CommentsSheet
|
||||
import com.github.libretube.ui.sheets.StatsSheet
|
||||
import com.github.libretube.util.NowPlayingNotification
|
||||
import com.github.libretube.util.OnlineTimeFrameReceiver
|
||||
import com.github.libretube.util.PauseableTimer
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import com.github.libretube.util.TextUtils
|
||||
import com.github.libretube.util.TextUtils.toTimeInSeconds
|
||||
import com.github.libretube.util.YoutubeHlsPlaylistParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
@ -138,9 +129,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
private val playerGestureControlsViewBinding get() = binding.playerGestureControlsView.binding
|
||||
|
||||
private val commonPlayerViewModel: CommonPlayerViewModel by activityViewModels()
|
||||
private val viewModel: PlayerViewModel by viewModels { PlayerViewModel.Factory }
|
||||
private val viewModel: PlayerViewModel by viewModels()
|
||||
private val commentsViewModel: CommentsViewModel by activityViewModels()
|
||||
private val chaptersViewModel: ChaptersViewModel by activityViewModels()
|
||||
private lateinit var playerController: MediaController
|
||||
|
||||
// Video information passed by the intent
|
||||
private lateinit var videoId: String
|
||||
@ -161,10 +153,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
// if null, use same quality as fullscreen
|
||||
private var noFullscreenResolution: Int? = null
|
||||
|
||||
private val cronetDataSourceFactory = CronetDataSource.Factory(
|
||||
CronetHelper.cronetEngine,
|
||||
Executors.newCachedThreadPool()
|
||||
)
|
||||
private var selectedAudioLanguageAndRoleFlags: Pair<String?, @C. RoleFlags Int>? = null
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
@ -211,7 +200,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
|
||||
|
||||
if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return
|
||||
if (PlayerHelper.handlePlayerAction(playerController, event)) return
|
||||
|
||||
when (event) {
|
||||
PlayerEvent.Next -> {
|
||||
@ -291,14 +280,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
) {
|
||||
updatePlayPauseButton()
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||
PlayerHelper.setPreferredAudioQuality(
|
||||
requireContext(),
|
||||
viewModel.player,
|
||||
viewModel.trackSelector
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
@ -306,9 +287,9 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
// set the playback speed to one if having reached the end of a livestream
|
||||
if (playbackState == Player.STATE_BUFFERING && binding.player.isLive &&
|
||||
viewModel.player.duration - viewModel.player.currentPosition < 700
|
||||
playerController.duration - playerController.currentPosition < 700
|
||||
) {
|
||||
viewModel.player.setPlaybackSpeed(1f)
|
||||
playerController.setPlaybackSpeed(1f)
|
||||
}
|
||||
|
||||
// check if video has ended, next video is available and autoplay is enabled/the video is part of a played playlist.
|
||||
@ -342,7 +323,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
if (playbackState == Player.STATE_BUFFERING) {
|
||||
if (bufferingTimeoutTask == null) {
|
||||
bufferingTimeoutTask = Runnable {
|
||||
viewModel.player.pause()
|
||||
playerController.pause()
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,7 +341,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
super.onPlayerError(error)
|
||||
try {
|
||||
viewModel.player.play()
|
||||
playerController.play()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
@ -406,6 +387,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true)
|
||||
noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false)
|
||||
|
||||
val sessionToken = SessionToken(
|
||||
requireContext(),
|
||||
ComponentName(requireContext(), VideoOnlinePlayerService::class.java)
|
||||
)
|
||||
BackgroundHelper.startMediaService(requireContext(), sessionToken, bundleOf()) {
|
||||
playerController = it
|
||||
playerController.addListener(playerListener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@ -431,7 +421,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
playerLayoutOrientation = resources.configuration.orientation
|
||||
|
||||
createExoPlayer()
|
||||
initializeTransitionLayout()
|
||||
initializeOnClickActions()
|
||||
|
||||
@ -591,7 +580,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
|
||||
binding.playImageView.setOnClickListener {
|
||||
viewModel.player.togglePlayPauseState()
|
||||
playerController.togglePlayPauseState()
|
||||
}
|
||||
|
||||
activity?.supportFragmentManager
|
||||
@ -626,7 +615,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
IntentData.shareObjectType to ShareObjectType.VIDEO,
|
||||
IntentData.shareData to ShareData(
|
||||
currentVideo = streams.title,
|
||||
currentPosition = viewModel.player.currentPosition / 1000
|
||||
currentPosition = playerController.currentPosition / 1000
|
||||
)
|
||||
)
|
||||
val newShareDialog = ShareDialog()
|
||||
@ -653,7 +642,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
binding.relPlayerBackground.setOnClickListener {
|
||||
// pause the current player
|
||||
viewModel.player.pause()
|
||||
playerController.pause()
|
||||
|
||||
// start the background mode
|
||||
playOnBackground()
|
||||
@ -712,7 +701,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
PixelCopy.request(surfaceView, bmp, { _ ->
|
||||
screenshotBitmap = bmp
|
||||
val currentPosition = viewModel.player.currentPosition.toFloat() / 1000
|
||||
val currentPosition =
|
||||
playerController.currentPosition.toFloat() / 1000
|
||||
openScreenshotFile.launch("${streams.title}-${currentPosition}.png")
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
}
|
||||
@ -743,7 +733,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
BackgroundHelper.playOnBackground(
|
||||
requireContext(),
|
||||
videoId,
|
||||
viewModel.player.currentPosition,
|
||||
playerController.currentPosition,
|
||||
playlistId,
|
||||
channelId,
|
||||
keepQueue = true,
|
||||
@ -756,8 +746,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
private fun updateFullscreenOrientation() {
|
||||
if (PlayerHelper.autoFullscreenEnabled || !this::streams.isInitialized) return
|
||||
|
||||
val height = streams.videoStreams.firstOrNull()?.height ?: viewModel.player.videoSize.height
|
||||
val width = streams.videoStreams.firstOrNull()?.width ?: viewModel.player.videoSize.width
|
||||
val height = streams.videoStreams.firstOrNull()?.height
|
||||
?: playerController.videoSize.height
|
||||
val width =
|
||||
streams.videoStreams.firstOrNull()?.width ?: playerController.videoSize.width
|
||||
|
||||
mainActivity.requestedOrientation = PlayerHelper.getOrientation(width, height)
|
||||
}
|
||||
@ -839,14 +831,16 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
// disable video stream since it's not needed when screen off
|
||||
if (!isInteractive) {
|
||||
viewModel.trackSelector.updateParameters {
|
||||
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
||||
}
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name to true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// pause player if screen off and setting enabled
|
||||
if (!isInteractive && PlayerHelper.pausePlayerOnScreenOffEnabled) {
|
||||
viewModel.player.pause()
|
||||
playerController.pause()
|
||||
}
|
||||
|
||||
// the app was put somewhere in the background - remember to not automatically continue
|
||||
@ -864,12 +858,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
if (closedVideo) {
|
||||
closedVideo = false
|
||||
viewModel.nowPlayingNotification?.refreshNotification()
|
||||
}
|
||||
|
||||
// re-enable and load video stream
|
||||
viewModel.trackSelector.updateParameters {
|
||||
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false)
|
||||
if (::playerController.isInitialized) {
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name to false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -878,13 +875,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
saveWatchPosition()
|
||||
|
||||
viewModel.nowPlayingNotification?.destroySelf()
|
||||
viewModel.nowPlayingNotification = null
|
||||
watchPositionTimer.destroy()
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
|
||||
viewModel.player.removeListener(playerListener)
|
||||
viewModel.player.pause()
|
||||
playerController.removeListener(playerListener)
|
||||
playerController.pause()
|
||||
|
||||
if (PlayerHelper.pipEnabled) {
|
||||
// disable the auto PiP mode for SDK >= 32
|
||||
@ -940,17 +935,17 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
// save the watch position if video isn't finished and option enabled
|
||||
private fun saveWatchPosition() {
|
||||
if (!isPlayerTransitioning && PlayerHelper.watchPositionsVideo) {
|
||||
PlayerHelper.saveWatchPosition(viewModel.player, videoId)
|
||||
PlayerHelper.saveWatchPosition(playerController, videoId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForSegments() {
|
||||
if (!viewModel.player.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
|
||||
if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
|
||||
|
||||
handler.postDelayed(this::checkForSegments, 100)
|
||||
if (!viewModel.sponsorBlockEnabled || viewModel.segments.isEmpty()) return
|
||||
|
||||
viewModel.player.checkForSegments(
|
||||
playerController.checkForSegments(
|
||||
requireContext(),
|
||||
viewModel.segments,
|
||||
viewModel.sponsorBlockConfig
|
||||
@ -959,12 +954,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let
|
||||
binding.sbSkipBtn.isVisible = true
|
||||
binding.sbSkipBtn.setOnClickListener {
|
||||
viewModel.player.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
|
||||
playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
|
||||
segment.skipped = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!viewModel.player.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true
|
||||
if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone =
|
||||
true
|
||||
}
|
||||
|
||||
private fun playVideo() {
|
||||
@ -983,6 +979,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
|
||||
this@PlayerFragment.streams = streams!!
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.startServiceCommand,
|
||||
bundleOf(IntentData.streams to streams)
|
||||
)
|
||||
}
|
||||
|
||||
val isFirstVideo = PlayingQueue.isEmpty()
|
||||
@ -1019,17 +1019,18 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
binding.player.apply {
|
||||
useController = false
|
||||
player = viewModel.player
|
||||
player = playerController
|
||||
}
|
||||
|
||||
initializePlayerView()
|
||||
|
||||
// don't continue playback when the fragment is re-created after Android killed it
|
||||
val wasIntentStopped = requireArguments().getBoolean(IntentData.wasIntentStopped, false)
|
||||
viewModel.player.playWhenReady = PlayerHelper.playAutomatically && !wasIntentStopped
|
||||
playerController.playWhenReady =
|
||||
PlayerHelper.playAutomatically && !wasIntentStopped
|
||||
requireArguments().putBoolean(IntentData.wasIntentStopped, false)
|
||||
|
||||
viewModel.player.prepare()
|
||||
playerController.prepare()
|
||||
|
||||
if (binding.playerMotionLayout.progress != 1.0f) {
|
||||
// show controllers when not in picture in picture mode
|
||||
@ -1039,13 +1040,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
binding.player.useController = true
|
||||
}
|
||||
}
|
||||
// show the player notification
|
||||
initializePlayerNotification()
|
||||
|
||||
fetchSponsorBlockSegments()
|
||||
|
||||
if (streams.category == Streams.categoryMusic) {
|
||||
viewModel.player.setPlaybackSpeed(1f)
|
||||
playerController.setPlaybackSpeed(1f)
|
||||
}
|
||||
|
||||
viewModel.isOrientationChangeInProgress = false
|
||||
@ -1076,7 +1075,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
*/
|
||||
private fun playNextVideo(nextId: String? = null) {
|
||||
if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
|
||||
viewModel.player.seekTo(0)
|
||||
playerController.seekTo(0)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1117,7 +1116,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
viewModel,
|
||||
commonPlayerViewModel,
|
||||
viewLifecycleOwner,
|
||||
viewModel.trackSelector,
|
||||
this
|
||||
)
|
||||
|
||||
@ -1206,7 +1204,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
if (videoId == this.videoId) {
|
||||
// try finding the time stamp of the url and seek to it if found
|
||||
uri.getQueryParameter("t")?.toTimeInSeconds()?.let {
|
||||
viewModel.player.seekTo(it * 1000)
|
||||
playerController.seekTo(it * 1000)
|
||||
}
|
||||
} else {
|
||||
// YouTube video link without time or not the current video, thus load in player
|
||||
@ -1215,7 +1213,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
|
||||
private fun updatePlayPauseButton() {
|
||||
binding.playImageView.setImageResource(PlayerHelper.getPlayPauseActionIcon(viewModel.player))
|
||||
binding.playImageView.setImageResource(PlayerHelper.getPlayPauseActionIcon(playerController))
|
||||
}
|
||||
|
||||
private suspend fun initializeHighlight(highlight: Segment) {
|
||||
@ -1234,31 +1232,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSubtitleConfigs(): List<SubtitleConfiguration> = streams.subtitles.map {
|
||||
val roleFlags = getSubtitleRoleFlags(it)
|
||||
SubtitleConfiguration.Builder(it.url!!.toUri())
|
||||
.setRoleFlags(roleFlags)
|
||||
.setLanguage(it.code)
|
||||
.setMimeType(it.mimeType).build()
|
||||
}
|
||||
|
||||
private fun createMediaItem(uri: Uri, mimeType: String) = MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMimeType(mimeType)
|
||||
.setSubtitleConfigurations(getSubtitleConfigs())
|
||||
.setMetadata(streams)
|
||||
.build()
|
||||
|
||||
private fun setMediaSource(uri: Uri, mimeType: String) {
|
||||
val mediaItem = createMediaItem(uri, mimeType)
|
||||
viewModel.player.setMediaItem(mediaItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available player resolutions
|
||||
*/
|
||||
private fun getAvailableResolutions(): List<VideoResolution> {
|
||||
val resolutions = viewModel.player.currentTracks.groups.asSequence()
|
||||
val resolutions = playerController.currentTracks.groups.asSequence()
|
||||
.flatMap { group ->
|
||||
(0 until group.length).map {
|
||||
group.getTrackFormat(it).height
|
||||
@ -1274,29 +1252,31 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
private fun initStreamSources() {
|
||||
// use the video's default audio track when starting playback
|
||||
viewModel.trackSelector.updateParameters {
|
||||
setPreferredAudioRoleFlags(C.ROLE_FLAG_MAIN)
|
||||
}
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN
|
||||
)
|
||||
)
|
||||
|
||||
// set the default subtitle if available
|
||||
updateCurrentSubtitle(viewModel.currentSubtitle)
|
||||
|
||||
// set media source and resolution in the beginning
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
setStreamSource()
|
||||
updateResolutionOnFullscreenChange(commonPlayerViewModel.isFullscreen.value == true)
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand,
|
||||
bundleOf(PlayerCommand.START_PLAYBACK.name to true)
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// support for time stamped links
|
||||
if (timeStamp != 0L) {
|
||||
viewModel.player.seekTo(timeStamp * 1000)
|
||||
// delete the time stamp because it already got consumed
|
||||
timeStamp = 0L
|
||||
} else if (!streams.isLive) {
|
||||
// seek to the saved watch position
|
||||
PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let {
|
||||
viewModel.player.seekTo(it)
|
||||
}
|
||||
}
|
||||
// support for time stamped links
|
||||
if (timeStamp != 0L) {
|
||||
playerController.seekTo(timeStamp * 1000)
|
||||
// delete the time stamp because it already got consumed
|
||||
timeStamp = 0L
|
||||
} else if (!streams.isLive) {
|
||||
// seek to the saved watch position
|
||||
PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let {
|
||||
playerController.seekTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1308,10 +1288,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
resolution
|
||||
}
|
||||
|
||||
viewModel.trackSelector.updateParameters {
|
||||
setMaxVideoSize(Int.MAX_VALUE, transformedResolution)
|
||||
setMinVideoSize(Int.MIN_VALUE, transformedResolution)
|
||||
}
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_RESOLUTION.name to transformedResolution
|
||||
)
|
||||
)
|
||||
|
||||
binding.player.selectedResolution = resolution
|
||||
}
|
||||
|
||||
private fun updateResolutionOnFullscreenChange(isFullscreen: Boolean) {
|
||||
@ -1322,86 +1305,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setStreamSource() {
|
||||
updateResolutionOnFullscreenChange(commonPlayerViewModel.isFullscreen.value == true)
|
||||
|
||||
val (uri, mimeType) = when {
|
||||
// LBRY HLS
|
||||
PreferenceHelper.getBoolean(
|
||||
PreferenceKeys.LBRY_HLS,
|
||||
false
|
||||
) && streams.videoStreams.any {
|
||||
it.quality.orEmpty().contains("LBRY HLS")
|
||||
} -> {
|
||||
val lbryHlsUrl = streams.videoStreams.first {
|
||||
it.quality!!.contains("LBRY HLS")
|
||||
}.url!!
|
||||
lbryHlsUrl.toUri() to MimeTypes.APPLICATION_M3U8
|
||||
}
|
||||
// DASH
|
||||
!PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> {
|
||||
// only use the dash manifest generated by YT if either it's a livestream or no other source is available
|
||||
val dashUri =
|
||||
if (streams.isLive && streams.dash != null) {
|
||||
ProxyHelper.unwrapStreamUrl(
|
||||
streams.dash!!
|
||||
).toUri()
|
||||
} else {
|
||||
// skip LBRY urls when checking whether the stream source is usable
|
||||
PlayerHelper.createDashSource(streams, requireContext())
|
||||
}
|
||||
|
||||
dashUri to MimeTypes.APPLICATION_MPD
|
||||
}
|
||||
// HLS
|
||||
streams.hls != null -> {
|
||||
val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory)
|
||||
.setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
|
||||
|
||||
val mediaSource = hlsMediaSourceFactory.createMediaSource(
|
||||
createMediaItem(
|
||||
ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri(),
|
||||
MimeTypes.APPLICATION_M3U8
|
||||
)
|
||||
)
|
||||
withContext(Dispatchers.Main) { viewModel.player.setMediaSource(mediaSource) }
|
||||
return
|
||||
}
|
||||
// NO STREAM FOUND
|
||||
else -> {
|
||||
context?.toastFromMainDispatcher(R.string.unknown_error)
|
||||
return
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) { setMediaSource(uri, mimeType) }
|
||||
}
|
||||
|
||||
private fun createExoPlayer() {
|
||||
viewModel.player.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
viewModel.player.addListener(playerListener)
|
||||
|
||||
// control for the track sources like subtitles and audio source
|
||||
PlayerHelper.setPreferredCodecs(viewModel.trackSelector)
|
||||
}
|
||||
|
||||
/**
|
||||
* show the [NowPlayingNotification] for the current video
|
||||
*/
|
||||
private fun initializePlayerNotification() {
|
||||
if (viewModel.nowPlayingNotification == null) {
|
||||
viewModel.nowPlayingNotification = NowPlayingNotification(
|
||||
requireContext(),
|
||||
viewModel.player
|
||||
)
|
||||
}
|
||||
val playerNotificationData = PlayerNotificationData(
|
||||
streams.title,
|
||||
streams.uploader,
|
||||
streams.thumbnailUrl
|
||||
)
|
||||
viewModel.nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the sensor mode if auto fullscreen is enabled
|
||||
*/
|
||||
@ -1438,24 +1341,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
.show(childFragmentManager)
|
||||
}
|
||||
|
||||
private fun getSubtitleRoleFlags(subtitle: Subtitle?): Int {
|
||||
return if (subtitle?.autoGenerated != true) {
|
||||
C.ROLE_FLAG_CAPTION
|
||||
} else {
|
||||
PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQualityClicked() {
|
||||
// get the available resolutions
|
||||
val resolutions = getAvailableResolutions()
|
||||
val currentQuality = viewModel.trackSelector.parameters.maxVideoHeight
|
||||
|
||||
// Dialog for quality selection
|
||||
BaseBottomSheet()
|
||||
.setSimpleItems(
|
||||
resolutions.map(VideoResolution::name),
|
||||
preselectedItem = resolutions.firstOrNull { it.resolution == currentQuality }?.name
|
||||
preselectedItem = resolutions.firstOrNull { it.resolution == binding.player.selectedResolution }?.name
|
||||
) { which ->
|
||||
val newResolution = resolutions[which].resolution
|
||||
setPlayerResolution(newResolution, true)
|
||||
@ -1473,7 +1367,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
override fun onAudioStreamClicked() {
|
||||
val context = requireContext()
|
||||
val audioLanguagesAndRoleFlags = PlayerHelper.getAudioLanguagesAndRoleFlagsFromTrackGroups(
|
||||
viewModel.player.currentTracks.groups,
|
||||
playerController.currentTracks.groups,
|
||||
false
|
||||
)
|
||||
val audioLanguages = audioLanguagesAndRoleFlags.map {
|
||||
@ -1499,18 +1393,22 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
} else {
|
||||
baseBottomSheet.setSimpleItems(
|
||||
audioLanguages,
|
||||
preselectedItem = audioLanguagesAndRoleFlags.firstOrNull {
|
||||
val format = viewModel.player.audioFormat
|
||||
format?.language == it.first && format?.roleFlags == it.second
|
||||
}?.let {
|
||||
preselectedItem = selectedAudioLanguageAndRoleFlags?.let {
|
||||
PlayerHelper.getAudioTrackNameFromFormat(context, it)
|
||||
},
|
||||
) { index ->
|
||||
val selectedAudioFormat = audioLanguagesAndRoleFlags[index]
|
||||
viewModel.trackSelector.updateParameters {
|
||||
setPreferredAudioLanguage(selectedAudioFormat.first)
|
||||
setPreferredAudioRoleFlags(selectedAudioFormat.second)
|
||||
}
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to selectedAudioFormat.second
|
||||
)
|
||||
)
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_AUDIO_LANGUAGE.name to selectedAudioFormat.first
|
||||
)
|
||||
)
|
||||
selectedAudioLanguageAndRoleFlags = selectedAudioFormat
|
||||
}
|
||||
}
|
||||
|
||||
@ -1523,10 +1421,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
override fun onStatsClicked() {
|
||||
if (!this::streams.isInitialized) return
|
||||
val videoStats = getVideoStats(viewModel.player, videoId)
|
||||
StatsSheet()
|
||||
.apply { arguments = bundleOf(IntentData.videoStats to videoStats) }
|
||||
.show(childFragmentManager)
|
||||
// TODO: reimplement video stats
|
||||
// val videoStats = getVideoStats(playerController, videoId)
|
||||
// StatsSheet()
|
||||
// .apply { arguments = bundleOf(IntentData.videoStats to videoStats) }
|
||||
// .show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
@ -1545,8 +1444,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
// close button got clicked in PiP mode
|
||||
// pause the video and keep the app alive
|
||||
if (lifecycle.currentState == Lifecycle.State.CREATED) {
|
||||
viewModel.player.pause()
|
||||
viewModel.nowPlayingNotification?.cancelNotification()
|
||||
playerController.pause()
|
||||
closedVideo = true
|
||||
}
|
||||
|
||||
@ -1559,36 +1457,43 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentSubtitle(subtitle: Subtitle?) =
|
||||
viewModel.trackSelector.updateParameters {
|
||||
val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0
|
||||
setPreferredTextRoleFlags(roleFlags)
|
||||
setPreferredTextLanguage(subtitle?.code)
|
||||
}
|
||||
private fun updateCurrentSubtitle(subtitle: Subtitle?) {
|
||||
if (!::playerController.isInitialized) return
|
||||
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_SUBTITLE.name to subtitle
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun onUserLeaveHint() {
|
||||
if (shouldStartPiP()) {
|
||||
PictureInPictureCompat.enterPictureInPictureMode(requireActivity(), pipParams)
|
||||
} else if (PlayerHelper.pauseOnQuit) {
|
||||
viewModel.player.pause()
|
||||
playerController.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private val pipParams
|
||||
get() = PictureInPictureParamsCompat.Builder()
|
||||
.setActions(
|
||||
PlayerHelper.getPiPModeActions(
|
||||
requireActivity(),
|
||||
viewModel.player.isPlaying
|
||||
private val pipParams: PictureInPictureParamsCompat
|
||||
get() = run {
|
||||
val isPlaying = ::playerController.isInitialized && playerController.isPlaying
|
||||
|
||||
PictureInPictureParamsCompat.Builder()
|
||||
.setActions(
|
||||
PlayerHelper.getPiPModeActions(
|
||||
requireActivity(),
|
||||
isPlaying
|
||||
)
|
||||
)
|
||||
)
|
||||
.setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying)
|
||||
.apply {
|
||||
if (viewModel.player.isPlaying) {
|
||||
setAspectRatio(viewModel.player.videoSize)
|
||||
.setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying)
|
||||
.apply {
|
||||
if (isPlaying) {
|
||||
setAspectRatio(playerController.videoSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
.build()
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createSeekbarPreviewListener(): SeekbarPreviewListener {
|
||||
return SeekbarPreviewListener(
|
||||
@ -1606,7 +1511,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
|
||||
private fun shouldStartPiP(): Boolean {
|
||||
return shouldUsePip() && viewModel.player.isPlaying &&
|
||||
return shouldUsePip() && playerController.isPlaying &&
|
||||
!BackgroundHelper.isBackgroundServiceRunning(requireContext())
|
||||
}
|
||||
|
||||
@ -1620,7 +1525,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
val orientation = resources.configuration.orientation
|
||||
if (commonPlayerViewModel.isFullscreen.value != true && orientation != playerLayoutOrientation) {
|
||||
// remember the current position before recreating the activity
|
||||
arguments?.putLong(IntentData.timeStamp, viewModel.player.currentPosition / 1000)
|
||||
arguments?.putLong(
|
||||
IntentData.timeStamp,
|
||||
playerController.currentPosition / 1000
|
||||
)
|
||||
playerLayoutOrientation = orientation
|
||||
|
||||
viewModel.isOrientationChangeInProgress = true
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -2,17 +2,10 @@ package com.github.libretube.ui.models
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.api.JsonHelper
|
||||
import com.github.libretube.api.RetrofitInstance
|
||||
import com.github.libretube.api.StreamsExtractor
|
||||
import com.github.libretube.api.obj.Message
|
||||
import com.github.libretube.api.obj.Segment
|
||||
import com.github.libretube.api.obj.Streams
|
||||
import com.github.libretube.api.obj.Subtitle
|
||||
@ -24,10 +17,7 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@UnstableApi
|
||||
class PlayerViewModel(
|
||||
val player: ExoPlayer,
|
||||
val trackSelector: DefaultTrackSelector,
|
||||
) : ViewModel() {
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
// data to remember for recovery on orientation change
|
||||
private var streamsInfo: Streams? = null
|
||||
@ -69,22 +59,4 @@ class PlayerViewModel(
|
||||
).segments
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory = viewModelFactory {
|
||||
initializer {
|
||||
val context = this[APPLICATION_KEY]!!
|
||||
val trackSelector = DefaultTrackSelector(context)
|
||||
PlayerViewModel(
|
||||
player = PlayerHelper.createPlayer(context, trackSelector, false),
|
||||
trackSelector = trackSelector,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
player.release()
|
||||
}
|
||||
}
|
@ -4,17 +4,22 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.libretube.constants.PreferenceKeys
|
||||
import com.github.libretube.databinding.PlaybackBottomSheetBinding
|
||||
import com.github.libretube.enums.PlayerCommand
|
||||
import com.github.libretube.extensions.round
|
||||
import com.github.libretube.helpers.PreferenceHelper
|
||||
import com.github.libretube.services.AbstractPlayerService
|
||||
import com.github.libretube.ui.adapters.SliderLabelsAdapter
|
||||
|
||||
class PlaybackOptionsSheet(
|
||||
private val player: ExoPlayer
|
||||
private val player: Player
|
||||
) : ExpandedBottomSheet() {
|
||||
private var _binding: PlaybackBottomSheetBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
@ -32,8 +37,10 @@ class PlaybackOptionsSheet(
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val binding = binding
|
||||
|
||||
binding.speedShortcuts.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.pitchShortcuts.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.speedShortcuts.layoutManager =
|
||||
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.pitchShortcuts.layoutManager =
|
||||
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
|
||||
binding.speedShortcuts.adapter = SliderLabelsAdapter(SUGGESTED_SPEEDS) {
|
||||
binding.speed.value = it
|
||||
@ -57,7 +64,15 @@ class PlaybackOptionsSheet(
|
||||
}
|
||||
|
||||
binding.skipSilence.setOnCheckedChangeListener { _, isChecked ->
|
||||
player.skipSilenceEnabled = isChecked
|
||||
// TODO: unify the skip silence handling
|
||||
if (player is ExoPlayer) {
|
||||
player.skipSilenceEnabled = isChecked
|
||||
} else if (player is MediaController) {
|
||||
player.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand,
|
||||
bundleOf(PlayerCommand.SKIP_SILENCE.name to isChecked)
|
||||
)
|
||||
}
|
||||
PreferenceHelper.putBoolean(PreferenceKeys.SKIP_SILENCE, isChecked)
|
||||
}
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.PlayerView
|
||||
@ -601,8 +601,8 @@ abstract class CustomExoPlayerView(
|
||||
}
|
||||
|
||||
override fun onPlaybackSpeedClicked() {
|
||||
player?.let {
|
||||
PlaybackOptionsSheet(it as ExoPlayer).show(supportFragmentManager)
|
||||
(player as? MediaController)?.let {
|
||||
PlaybackOptionsSheet(it).show(supportFragmentManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,6 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.trackselection.TrackSelector
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.constants.PreferenceKeys
|
||||
@ -29,7 +28,6 @@ import com.github.libretube.ui.dialogs.SubmitSegmentDialog
|
||||
import com.github.libretube.ui.interfaces.OnlinePlayerOptions
|
||||
import com.github.libretube.ui.models.CommonPlayerViewModel
|
||||
import com.github.libretube.ui.models.PlayerViewModel
|
||||
import com.github.libretube.ui.sheets.PlayingQueueSheet
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
|
||||
@UnstableApi
|
||||
@ -40,7 +38,6 @@ class OnlinePlayerView(
|
||||
private var playerOptions: OnlinePlayerOptions? = null
|
||||
private var playerViewModel: PlayerViewModel? = null
|
||||
private var commonPlayerViewModel: CommonPlayerViewModel? = null
|
||||
private var trackSelector: TrackSelector? = null
|
||||
private var viewLifecycleOwner: LifecycleOwner? = null
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@ -51,6 +48,8 @@ class OnlinePlayerView(
|
||||
*/
|
||||
var currentWindow: Window? = null
|
||||
|
||||
var selectedResolution: Int? = null
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
override fun getOptionsMenuItems(): List<BottomSheetItem> {
|
||||
return super.getOptionsMenuItems() +
|
||||
@ -72,7 +71,7 @@ class OnlinePlayerView(
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.captions),
|
||||
R.drawable.ic_caption,
|
||||
this::getCurrentCaptionLanguage
|
||||
{ playerViewModel?.currentSubtitle?.code ?: context.getString(R.string.none) }
|
||||
) {
|
||||
playerOptions?.onCaptionsClicked()
|
||||
},
|
||||
@ -89,25 +88,14 @@ class OnlinePlayerView(
|
||||
private fun getCurrentResolutionSummary(): String {
|
||||
val currentQuality = player?.videoSize?.height ?: 0
|
||||
var summary = "${currentQuality}p"
|
||||
val trackSelector = trackSelector ?: return summary
|
||||
val selectedQuality = trackSelector.parameters.maxVideoHeight
|
||||
if (selectedQuality == Int.MAX_VALUE) {
|
||||
if (selectedResolution == null) {
|
||||
summary += " - ${context.getString(R.string.auto)}"
|
||||
} else if (selectedQuality > currentQuality) {
|
||||
} else if ((selectedResolution ?: 0) > currentQuality) {
|
||||
summary += " - ${context.getString(R.string.resolution_limited)}"
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private fun getCurrentCaptionLanguage(): String {
|
||||
return if (trackSelector != null && trackSelector!!.parameters.preferredTextLanguages.isNotEmpty()) {
|
||||
trackSelector!!.parameters.preferredTextLanguages[0]
|
||||
} else {
|
||||
context.getString(R.string.none)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentAudioTrackTitle(): String {
|
||||
if (player == null) {
|
||||
return context.getString(R.string.unknown_or_no_audio)
|
||||
@ -153,13 +141,11 @@ class OnlinePlayerView(
|
||||
playerViewModel: PlayerViewModel,
|
||||
commonPlayerViewModel: CommonPlayerViewModel,
|
||||
viewLifecycleOwner: LifecycleOwner,
|
||||
trackSelector: TrackSelector,
|
||||
playerOptions: OnlinePlayerOptions
|
||||
) {
|
||||
this.playerViewModel = playerViewModel
|
||||
this.commonPlayerViewModel = commonPlayerViewModel
|
||||
this.viewLifecycleOwner = viewLifecycleOwner
|
||||
this.trackSelector = trackSelector
|
||||
this.playerOptions = playerOptions
|
||||
|
||||
commonPlayerViewModel.isFullscreen.observe(viewLifecycleOwner) { isFullscreen ->
|
||||
|
@ -1,75 +1,35 @@
|
||||
package com.github.libretube.util
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.media.app.NotificationCompat.MediaStyle
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.enums.NotificationId
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.extensions.toMediaMetadataCompat
|
||||
import com.github.libretube.helpers.ImageHelper
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.obj.PlayerNotificationData
|
||||
import com.github.libretube.services.OnClearFromRecentService
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import java.util.UUID
|
||||
import com.google.common.collect.ImmutableList
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class NowPlayingNotification(
|
||||
private val context: Context,
|
||||
private val player: ExoPlayer,
|
||||
private val backgroundOnly: Boolean = false,
|
||||
private val offlinePlayer: Boolean = false,
|
||||
private val intentActivity: Class<*> = MainActivity::class.java
|
||||
) {
|
||||
private var videoId: String? = null
|
||||
private val nManager = context.getSystemService<NotificationManager>()!!
|
||||
|
||||
/**
|
||||
* The metadata of the current playing song (thumbnail, title, uploader)
|
||||
*/
|
||||
private var notificationData: PlayerNotificationData? = null
|
||||
|
||||
/**
|
||||
* The [MediaSessionCompat] for the [notificationData].
|
||||
*/
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
|
||||
/**
|
||||
* The [NotificationCompat.Builder] to load the [mediaSession] content on it.
|
||||
*/
|
||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||
|
||||
/**
|
||||
* The [Bitmap] which represents the background / thumbnail of the notification
|
||||
*/
|
||||
private var notificationBitmap: Bitmap? = null
|
||||
|
||||
private fun loadCurrentLargeIcon() {
|
||||
if (DataSaverMode.isEnabled(context)) return
|
||||
|
||||
if (notificationBitmap == null) {
|
||||
enqueueThumbnailRequest {
|
||||
createOrUpdateNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
): MediaNotification.Provider {
|
||||
private val nProvider = DefaultMediaNotificationProvider.Builder(context)
|
||||
.setNotificationId(NotificationId.PLAYER_PLAYBACK.id)
|
||||
.setChannelId(PLAYER_CHANNEL_NAME)
|
||||
.setChannelName(R.string.player_channel_name)
|
||||
.build()
|
||||
|
||||
private fun createCurrentContentIntent(): PendingIntent? {
|
||||
// starts a new MainActivity Intent when the player notification is clicked
|
||||
@ -84,194 +44,10 @@ class NowPlayingNotification(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return PendingIntentCompat
|
||||
.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, false)
|
||||
}
|
||||
|
||||
private fun createIntent(action: String): PendingIntent? {
|
||||
val intent = Intent(action)
|
||||
.setPackage(context.packageName)
|
||||
|
||||
return PendingIntentCompat
|
||||
.getBroadcast(context, 1, intent, PendingIntent.FLAG_CANCEL_CURRENT, false)
|
||||
}
|
||||
|
||||
private fun enqueueThumbnailRequest(callback: (Bitmap) -> Unit) {
|
||||
ImageHelper.getImageWithCallback(
|
||||
context,
|
||||
notificationData?.thumbnailPath?.toString() ?: notificationData?.thumbnailUrl
|
||||
) {
|
||||
notificationBitmap = processBitmap(it)
|
||||
callback.invoke(notificationBitmap!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processBitmap(bitmap: Bitmap): Bitmap {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
bitmap
|
||||
} else {
|
||||
ImageHelper.getSquareBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
private val legacyNotificationButtons
|
||||
get() = listOf(
|
||||
createNotificationAction(R.drawable.ic_prev_outlined, PlayerEvent.Prev.name),
|
||||
createNotificationAction(
|
||||
if (player.isPlaying) R.drawable.ic_pause else R.drawable.ic_play,
|
||||
PlayerEvent.PlayPause.name
|
||||
),
|
||||
createNotificationAction(R.drawable.ic_next_outlined, PlayerEvent.Next.name),
|
||||
createNotificationAction(R.drawable.ic_rewind_md, PlayerEvent.Rewind.name),
|
||||
createNotificationAction(R.drawable.ic_forward_md, PlayerEvent.Forward.name)
|
||||
)
|
||||
|
||||
private fun createNotificationAction(
|
||||
drawableRes: Int,
|
||||
actionName: String
|
||||
): NotificationCompat.Action {
|
||||
return NotificationCompat.Action.Builder(drawableRes, actionName, createIntent(actionName))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createMediaSessionAction(
|
||||
@DrawableRes drawableRes: Int,
|
||||
actionName: String
|
||||
): PlaybackStateCompat.CustomAction {
|
||||
return PlaybackStateCompat.CustomAction.Builder(actionName, actionName, drawableRes).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [MediaSessionCompat] for the player
|
||||
*/
|
||||
private fun createMediaSession() {
|
||||
if (this::mediaSession.isInitialized) return
|
||||
|
||||
val sessionCallback = object : MediaSessionCompat.Callback() {
|
||||
override fun onRewind() {
|
||||
handlePlayerAction(PlayerEvent.Rewind)
|
||||
super.onRewind()
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
handlePlayerAction(PlayerEvent.Forward)
|
||||
super.onFastForward()
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
handlePlayerAction(PlayerEvent.PlayPause)
|
||||
super.onPlay()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
handlePlayerAction(PlayerEvent.PlayPause)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
handlePlayerAction(PlayerEvent.Next)
|
||||
super.onSkipToNext()
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
handlePlayerAction(PlayerEvent.Prev)
|
||||
super.onSkipToPrevious()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
handlePlayerAction(PlayerEvent.Stop)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
player.seekTo(pos)
|
||||
super.onSeekTo(pos)
|
||||
}
|
||||
|
||||
override fun onCustomAction(action: String, extras: Bundle?) {
|
||||
runCatching { handlePlayerAction(PlayerEvent.valueOf(action)) }
|
||||
super.onCustomAction(action, extras)
|
||||
}
|
||||
}
|
||||
|
||||
mediaSession = MediaSessionCompat(context, UUID.randomUUID().toString())
|
||||
mediaSession.setCallback(sessionCallback)
|
||||
|
||||
updateSessionMetadata()
|
||||
updateSessionPlaybackState()
|
||||
|
||||
val playerStateListener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
updateSessionPlaybackState(isPlaying = isPlaying)
|
||||
}
|
||||
|
||||
override fun onIsLoadingChanged(isLoading: Boolean) {
|
||||
super.onIsLoadingChanged(isLoading)
|
||||
|
||||
if (!isLoading) {
|
||||
updateSessionMetadata()
|
||||
}
|
||||
|
||||
updateSessionPlaybackState(isLoading = isLoading)
|
||||
}
|
||||
|
||||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||
super.onMediaMetadataChanged(mediaMetadata)
|
||||
updateSessionMetadata(mediaMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
player.addListener(playerStateListener)
|
||||
}
|
||||
|
||||
private fun updateSessionMetadata(metadata: MediaMetadata? = null) {
|
||||
val data = metadata ?: player.mediaMetadata
|
||||
val newMetadata = data.toMediaMetadataCompat(player.duration, notificationBitmap)
|
||||
mediaSession.setMetadata(newMetadata)
|
||||
}
|
||||
|
||||
private fun updateSessionPlaybackState(isPlaying: Boolean? = null, isLoading: Boolean? = null) {
|
||||
val loading = isLoading == true || (isPlaying == false && player.isLoading)
|
||||
|
||||
val newPlaybackState = when {
|
||||
loading -> PlaybackStateCompat.STATE_BUFFERING
|
||||
isPlaying ?: player.isPlaying -> PlaybackStateCompat.STATE_PLAYING
|
||||
else -> PlaybackStateCompat.STATE_PAUSED
|
||||
}
|
||||
|
||||
mediaSession.setPlaybackState(createPlaybackState(newPlaybackState))
|
||||
}
|
||||
|
||||
private fun createPlaybackState(@PlaybackStateCompat.State state: Int): PlaybackStateCompat {
|
||||
val stateActions = PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_REWIND or
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_SEEK_TO
|
||||
|
||||
return PlaybackStateCompat.Builder()
|
||||
.setActions(stateActions)
|
||||
.addCustomAction(
|
||||
createMediaSessionAction(
|
||||
R.drawable.ic_rewind_md,
|
||||
PlayerEvent.Rewind.name
|
||||
)
|
||||
)
|
||||
.addCustomAction(
|
||||
createMediaSessionAction(
|
||||
R.drawable.ic_forward_md,
|
||||
PlayerEvent.Forward.name
|
||||
)
|
||||
)
|
||||
.setState(state, player.currentPosition, player.playbackParameters.speed)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the action to the responsible notification owner (e.g. PlayerFragment)
|
||||
*/
|
||||
@ -282,79 +58,23 @@ class NowPlayingNotification(
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or creates the [notificationBuilder]
|
||||
*/
|
||||
fun updatePlayerNotification(videoId: String, data: PlayerNotificationData) {
|
||||
this.videoId = videoId
|
||||
this.notificationData = data
|
||||
// reset the thumbnail bitmap in order to become reloaded for the new video
|
||||
this.notificationBitmap = null
|
||||
|
||||
loadCurrentLargeIcon()
|
||||
|
||||
if (notificationBuilder == null) {
|
||||
createMediaSession()
|
||||
createNotificationBuilder()
|
||||
// update the notification each time the player continues playing or pauses
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
createOrUpdateNotification()
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
}
|
||||
})
|
||||
context.startService(Intent(context, OnClearFromRecentService::class.java))
|
||||
}
|
||||
|
||||
createOrUpdateNotification()
|
||||
override fun createNotification(
|
||||
mediaSession: MediaSession,
|
||||
customLayout: ImmutableList<CommandButton>,
|
||||
actionFactory: MediaNotification.ActionFactory,
|
||||
onNotificationChangedCallback: MediaNotification.Provider.Callback
|
||||
): MediaNotification {
|
||||
createCurrentContentIntent()?.let { mediaSession.setSessionActivity(it) }
|
||||
nProvider.setSmallIcon(R.drawable.ic_launcher_lockscreen)
|
||||
return nProvider.createNotification(mediaSession, customLayout, actionFactory, onNotificationChangedCallback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the [notificationBuilder] attached to the [player] and shows it.
|
||||
*/
|
||||
private fun createNotificationBuilder() {
|
||||
notificationBuilder = NotificationCompat.Builder(context, PLAYER_CHANNEL_NAME)
|
||||
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
|
||||
.setContentIntent(createCurrentContentIntent())
|
||||
.setDeleteIntent(createIntent(PlayerEvent.Stop.name))
|
||||
.setStyle(
|
||||
MediaStyle()
|
||||
.setMediaSession(mediaSession.sessionToken)
|
||||
.setShowActionsInCompactView(1)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createOrUpdateNotification() {
|
||||
if (notificationBuilder == null) return
|
||||
val notification = notificationBuilder!!
|
||||
.setContentTitle(notificationData?.title)
|
||||
.setContentText(notificationData?.uploaderName)
|
||||
.setLargeIcon(notificationBitmap)
|
||||
.clearActions()
|
||||
.apply {
|
||||
legacyNotificationButtons.forEach {
|
||||
addAction(it)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
updateSessionMetadata()
|
||||
nManager.notify(NotificationId.PLAYER_PLAYBACK.id, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the [NowPlayingNotification]
|
||||
*/
|
||||
fun destroySelf() {
|
||||
if (this::mediaSession.isInitialized) mediaSession.release()
|
||||
|
||||
nManager.cancel(NotificationId.PLAYER_PLAYBACK.id)
|
||||
}
|
||||
|
||||
fun cancelNotification() {
|
||||
nManager.cancel(NotificationId.PLAYER_PLAYBACK.id)
|
||||
}
|
||||
|
||||
fun refreshNotification() {
|
||||
createOrUpdateNotification()
|
||||
override fun handleCustomCommand(
|
||||
session: MediaSession,
|
||||
action: String,
|
||||
extras: Bundle
|
||||
): Boolean {
|
||||
runCatching { handlePlayerAction(PlayerEvent.valueOf(action)) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user