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