1400 lines
53 KiB
Kotlin
1400 lines
53 KiB
Kotlin
package com.lagradost.cloudstream3.ui.player
|
||
|
||
import android.annotation.SuppressLint
|
||
import android.content.Context
|
||
import android.graphics.Bitmap
|
||
import android.net.Uri
|
||
import android.os.Handler
|
||
import android.os.Looper
|
||
import android.util.Log
|
||
import android.util.Rational
|
||
import android.widget.FrameLayout
|
||
import androidx.media3.common.C.*
|
||
import androidx.media3.common.Format
|
||
import androidx.media3.common.MediaItem
|
||
import androidx.media3.common.MimeTypes
|
||
import androidx.media3.common.PlaybackException
|
||
import androidx.media3.common.Player
|
||
import androidx.media3.common.TrackGroup
|
||
import androidx.media3.common.TrackSelectionOverride
|
||
import androidx.media3.common.Tracks
|
||
import androidx.media3.common.VideoSize
|
||
import androidx.media3.database.StandaloneDatabaseProvider
|
||
import androidx.media3.datasource.DataSource
|
||
import androidx.media3.datasource.DefaultDataSourceFactory
|
||
import androidx.media3.datasource.DefaultHttpDataSource
|
||
import androidx.media3.datasource.HttpDataSource
|
||
import androidx.media3.datasource.cache.CacheDataSource
|
||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||
import androidx.media3.datasource.cache.SimpleCache
|
||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||
import androidx.media3.exoplayer.DefaultLoadControl
|
||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||
import androidx.media3.exoplayer.ExoPlayer
|
||
import androidx.media3.exoplayer.SeekParameters
|
||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
|
||
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
|
||
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
|
||
import androidx.media3.exoplayer.source.ClippingMediaSource
|
||
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
|
||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
||
import androidx.media3.exoplayer.text.TextRenderer
|
||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||
import androidx.media3.exoplayer.trackselection.TrackSelector
|
||
import androidx.media3.ui.SubtitleView
|
||
import androidx.preference.PreferenceManager
|
||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
|
||
import com.lagradost.cloudstream3.USER_AGENT
|
||
import com.lagradost.cloudstream3.app
|
||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||
import com.lagradost.cloudstream3.mvvm.logError
|
||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
|
||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||
import com.lagradost.cloudstream3.utils.DrmExtractorLink
|
||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
|
||
import java.io.File
|
||
import java.lang.IllegalArgumentException
|
||
import java.util.UUID
|
||
import javax.net.ssl.HttpsURLConnection
|
||
import javax.net.ssl.SSLContext
|
||
import javax.net.ssl.SSLSession
|
||
|
||
const val TAG = "CS3ExoPlayer"
|
||
const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language"
|
||
|
||
/** toleranceBeforeUs – The maximum time that the actual position seeked to may precede the
|
||
* requested seek position, in microseconds. Must be non-negative. */
|
||
const val toleranceBeforeUs = 300_000L
|
||
|
||
/**
|
||
* toleranceAfterUs – The maximum time that the actual position seeked to may exceed the requested
|
||
* seek position, in microseconds. Must be non-negative.
|
||
*/
|
||
const val toleranceAfterUs = 300_000L
|
||
|
||
class CS3IPlayer : IPlayer {
|
||
private var isPlaying = false
|
||
private var exoPlayer: ExoPlayer? = null
|
||
set(value) {
|
||
// If the old value is not null then the player has not been properly released.
|
||
debugAssert(
|
||
{ field != null && value != null },
|
||
{ "Previous player instance should be released!" })
|
||
field = value
|
||
}
|
||
|
||
var cacheSize = 0L
|
||
var simpleCacheSize = 0L
|
||
var videoBufferMs = 0L
|
||
|
||
val imageGenerator = IPreviewGenerator.new()
|
||
|
||
private val seekActionTime = 30000L
|
||
|
||
private var ignoreSSL: Boolean = true
|
||
private var playBackSpeed: Float = 1.0f
|
||
|
||
private var lastMuteVolume: Float = 1.0f
|
||
|
||
private var currentLink: ExtractorLink? = null
|
||
private var currentDownloadedFile: ExtractorUri? = null
|
||
private var hasUsedFirstRender = false
|
||
|
||
private var currentWindow: Int = 0
|
||
private var playbackPosition: Long = 0
|
||
|
||
private val subtitleHelper = PlayerSubtitleHelper()
|
||
|
||
/**
|
||
* This is a way to combine the MediaItem and its duration for the concatenating MediaSource.
|
||
* @param durationUs does not matter if only one slice is present, since it will not concatenate
|
||
* */
|
||
data class MediaItemSlice(
|
||
val mediaItem: MediaItem,
|
||
val durationUs: Long,
|
||
val drm: DrmMetadata? = null
|
||
)
|
||
|
||
data class DrmMetadata(
|
||
val kid: String,
|
||
val key: String,
|
||
val uuid: UUID,
|
||
val kty: String,
|
||
val keyRequestParameters: HashMap<String, String>,
|
||
)
|
||
|
||
override fun getDuration(): Long? = exoPlayer?.duration
|
||
override fun getPosition(): Long? = exoPlayer?.currentPosition
|
||
override fun getIsPlaying(): Boolean = isPlaying
|
||
override fun getPlaybackSpeed(): Float = playBackSpeed
|
||
|
||
/**
|
||
* Tracks reported to be used by exoplayer, since sometimes it has a mind of it's own when selecting subs.
|
||
* String = id
|
||
* Boolean = if it's active
|
||
* */
|
||
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>()
|
||
private var requestedListeningPercentages: List<Int>? = null
|
||
|
||
private var eventHandler: ((PlayerEvent) -> Unit)? = null
|
||
|
||
fun event(event: PlayerEvent) {
|
||
eventHandler?.invoke(event)
|
||
}
|
||
|
||
override fun releaseCallbacks() {
|
||
eventHandler = null
|
||
}
|
||
|
||
override fun initCallbacks(
|
||
eventHandler: ((PlayerEvent) -> Unit),
|
||
requestedListeningPercentages: List<Int>?,
|
||
) {
|
||
this.requestedListeningPercentages = requestedListeningPercentages
|
||
this.eventHandler = eventHandler
|
||
}
|
||
|
||
// I know, this is not a perfect solution, however it works for fixing subs
|
||
private fun reloadSubs() {
|
||
exoPlayer?.applicationLooper?.let {
|
||
try {
|
||
Handler(it).post {
|
||
try {
|
||
seekTime(1L, source = PlayerEventSource.Player)
|
||
} catch (e: Exception) {
|
||
logError(e)
|
||
}
|
||
}
|
||
} catch (e: Exception) {
|
||
logError(e)
|
||
}
|
||
}
|
||
}
|
||
|
||
fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) {
|
||
subtitleHelper.initSubtitles(subView, subHolder, style)
|
||
}
|
||
|
||
override fun getPreview(fraction: Float): Bitmap? {
|
||
return imageGenerator.getPreviewImage(fraction)
|
||
}
|
||
|
||
override fun hasPreview(): Boolean {
|
||
return imageGenerator.hasPreview()
|
||
}
|
||
|
||
override fun loadPlayer(
|
||
context: Context,
|
||
sameEpisode: Boolean,
|
||
link: ExtractorLink?,
|
||
data: ExtractorUri?,
|
||
startPosition: Long?,
|
||
subtitles: Set<SubtitleData>,
|
||
subtitle: SubtitleData?,
|
||
autoPlay: Boolean?,
|
||
preview: Boolean,
|
||
) {
|
||
Log.i(TAG, "loadPlayer")
|
||
if (sameEpisode) {
|
||
saveData()
|
||
} else {
|
||
currentSubtitles = subtitle
|
||
playbackPosition = 0
|
||
}
|
||
|
||
startPosition?.let {
|
||
playbackPosition = it
|
||
}
|
||
|
||
// we want autoplay because of TV and UX
|
||
isPlaying = autoPlay ?: isPlaying
|
||
|
||
// release the current exoplayer and cache
|
||
releasePlayer()
|
||
|
||
if (link != null) {
|
||
// only video support atm
|
||
(imageGenerator as? PreviewGenerator)?.let { gen ->
|
||
if (preview) {
|
||
gen.load(link, sameEpisode)
|
||
} else {
|
||
gen.clear(sameEpisode)
|
||
}
|
||
}
|
||
loadOnlinePlayer(context, link)
|
||
} else if (data != null) {
|
||
(imageGenerator as? PreviewGenerator)?.let { gen ->
|
||
if (preview) {
|
||
gen.load(context, data, sameEpisode)
|
||
} else {
|
||
gen.clear(sameEpisode)
|
||
}
|
||
}
|
||
loadOfflinePlayer(context, data)
|
||
} else {
|
||
throw IllegalArgumentException("Requires link or uri")
|
||
}
|
||
|
||
}
|
||
|
||
override fun setActiveSubtitles(subtitles: Set<SubtitleData>) {
|
||
Log.i(TAG, "setActiveSubtitles ${subtitles.size}")
|
||
subtitleHelper.setAllSubtitles(subtitles)
|
||
}
|
||
|
||
private var currentSubtitles: SubtitleData? = null
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun List<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? {
|
||
if (id == null) return null
|
||
// This beast of an expression does:
|
||
// 1. Filter all audio tracks
|
||
// 2. Get all formats in said audio tacks
|
||
// 3. Gets all ids of the formats
|
||
// 4. Filters to find the first audio track with the same id as the audio track we are looking for
|
||
// 5. Returns the media group and the index of the audio track in the group
|
||
return this.firstNotNullOfOrNull { group ->
|
||
(0 until group.mediaTrackGroup.length).map {
|
||
group.getTrackFormat(it) to it
|
||
}.firstOrNull { it.first.id == id }
|
||
?.let { group.mediaTrackGroup to it.second }
|
||
}
|
||
}
|
||
|
||
override fun setMaxVideoSize(width: Int, height: Int, id: String?) {
|
||
if (id != null) {
|
||
val videoTrack =
|
||
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_VIDEO }
|
||
?.getTrack(id)
|
||
|
||
if (videoTrack != null) {
|
||
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
|
||
?.buildUpon()
|
||
?.setOverrideForType(
|
||
TrackSelectionOverride(
|
||
videoTrack.first,
|
||
videoTrack.second
|
||
)
|
||
)
|
||
?.build()
|
||
?: return
|
||
return
|
||
}
|
||
}
|
||
|
||
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
|
||
?.buildUpon()
|
||
?.setMaxVideoSize(width, height)
|
||
?.build()
|
||
?: return
|
||
}
|
||
|
||
override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) {
|
||
preferredAudioTrackLanguage = trackLanguage
|
||
|
||
if (id != null) {
|
||
val audioTrack =
|
||
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO }
|
||
?.getTrack(id)
|
||
|
||
if (audioTrack != null) {
|
||
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
|
||
?.buildUpon()
|
||
?.setOverrideForType(
|
||
TrackSelectionOverride(
|
||
audioTrack.first,
|
||
audioTrack.second
|
||
)
|
||
)
|
||
?.build()
|
||
?: return
|
||
return
|
||
}
|
||
}
|
||
|
||
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
|
||
?.buildUpon()
|
||
?.setPreferredAudioLanguage(trackLanguage)
|
||
?.build()
|
||
?: return
|
||
}
|
||
|
||
|
||
/**
|
||
* Gets all supported formats in a list
|
||
* */
|
||
private fun List<Tracks.Group>.getFormats(): List<Pair<Format, Int>> {
|
||
return this.map {
|
||
it.getFormats()
|
||
}.flatten()
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
|
||
return (0 until this.mediaTrackGroup.length).mapNotNull { i ->
|
||
if (this.isSupported)
|
||
this.mediaTrackGroup.getFormat(i) to i
|
||
else null
|
||
}
|
||
}
|
||
|
||
private fun Format.toAudioTrack(): AudioTrack {
|
||
return AudioTrack(
|
||
this.id,
|
||
this.label,
|
||
// isPlaying,
|
||
this.language
|
||
)
|
||
}
|
||
|
||
private fun Format.toVideoTrack(): VideoTrack {
|
||
return VideoTrack(
|
||
this.id,
|
||
this.label,
|
||
// isPlaying,
|
||
this.language,
|
||
this.width,
|
||
this.height
|
||
)
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
override fun getVideoTracks(): CurrentTracks {
|
||
val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList()
|
||
val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO }
|
||
.getFormats()
|
||
.map { it.first.toVideoTrack() }
|
||
val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats()
|
||
.map { it.first.toAudioTrack() }
|
||
|
||
return CurrentTracks(
|
||
exoPlayer?.videoFormat?.toVideoTrack(),
|
||
exoPlayer?.audioFormat?.toAudioTrack(),
|
||
videoTracks,
|
||
audioTracks
|
||
)
|
||
}
|
||
|
||
/**
|
||
* @return True if the player should be reloaded
|
||
* */
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
|
||
Log.i(TAG, "setPreferredSubtitles init $subtitle")
|
||
currentSubtitles = subtitle
|
||
|
||
fun getTextTrack(id: String) =
|
||
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT }
|
||
?.getTrack(id)
|
||
|
||
return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector ->
|
||
if (subtitle == null) {
|
||
trackSelector.setParameters(
|
||
trackSelector.buildUponParameters()
|
||
.setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
|
||
.clearOverridesOfType(TRACK_TYPE_TEXT)
|
||
)
|
||
} else {
|
||
when (subtitleHelper.subtitleStatus(subtitle)) {
|
||
SubtitleStatus.REQUIRES_RELOAD -> {
|
||
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
|
||
return@let true
|
||
}
|
||
|
||
SubtitleStatus.IS_ACTIVE -> {
|
||
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
|
||
|
||
trackSelector.setParameters(
|
||
trackSelector.buildUponParameters()
|
||
.apply {
|
||
val track = getTextTrack(subtitle.getId())
|
||
if (track != null) {
|
||
setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
|
||
setOverrideForType(
|
||
TrackSelectionOverride(
|
||
track.first,
|
||
track.second
|
||
)
|
||
)
|
||
}
|
||
}
|
||
)
|
||
|
||
// ugliest code I have written, it seeks 1ms to *update* the subtitles
|
||
//exoPlayer?.applicationLooper?.let {
|
||
// Handler(it).postDelayed({
|
||
// seekTime(1L)
|
||
// }, 1)
|
||
//}
|
||
}
|
||
|
||
SubtitleStatus.NOT_FOUND -> {
|
||
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
|
||
return@let true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
} ?: false
|
||
}
|
||
|
||
var currentSubtitleOffset: Long = 0
|
||
|
||
override fun setSubtitleOffset(offset: Long) {
|
||
currentSubtitleOffset = offset
|
||
currentTextRenderer?.setRenderOffsetMs(offset)
|
||
}
|
||
|
||
override fun getSubtitleOffset(): Long {
|
||
return currentSubtitleOffset //currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset
|
||
}
|
||
|
||
override fun getCurrentPreferredSubtitle(): SubtitleData? {
|
||
return subtitleHelper.getAllSubtitles().firstOrNull { sub ->
|
||
playerSelectedSubtitleTracks.any { (id, isSelected) ->
|
||
isSelected && sub.getId() == id
|
||
}
|
||
}
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
override fun getAspectRatio(): Rational? {
|
||
return exoPlayer?.videoFormat?.let { format ->
|
||
Rational(format.width, format.height)
|
||
}
|
||
}
|
||
|
||
override fun updateSubtitleStyle(style: SaveCaptionStyle) {
|
||
subtitleHelper.setSubStyle(style)
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
override fun saveData() {
|
||
Log.i(TAG, "saveData")
|
||
updatedTime()
|
||
|
||
exoPlayer?.let { exo ->
|
||
playbackPosition = exo.currentPosition
|
||
currentWindow = exo.currentWindowIndex
|
||
isPlaying = exo.isPlaying
|
||
}
|
||
}
|
||
|
||
private fun releasePlayer(saveTime: Boolean = true) {
|
||
Log.i(TAG, "releasePlayer")
|
||
|
||
if (saveTime)
|
||
updatedTime()
|
||
|
||
exoPlayer?.apply {
|
||
setPlayWhenReady(false)
|
||
stop()
|
||
release()
|
||
}
|
||
//simpleCache?.release()
|
||
currentTextRenderer = null
|
||
|
||
exoPlayer = null
|
||
//simpleCache = null
|
||
}
|
||
|
||
override fun onStop() {
|
||
Log.i(TAG, "onStop")
|
||
|
||
saveData()
|
||
handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
|
||
//releasePlayer()
|
||
}
|
||
|
||
override fun onPause() {
|
||
Log.i(TAG, "onPause")
|
||
saveData()
|
||
handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
|
||
//releasePlayer()
|
||
}
|
||
|
||
override fun onResume(context: Context) {
|
||
if (exoPlayer == null)
|
||
reloadPlayer(context)
|
||
}
|
||
|
||
override fun release() {
|
||
imageGenerator.release()
|
||
releasePlayer()
|
||
}
|
||
|
||
override fun setPlaybackSpeed(speed: Float) {
|
||
exoPlayer?.setPlaybackSpeed(speed)
|
||
playBackSpeed = speed
|
||
}
|
||
|
||
companion object {
|
||
/**
|
||
* Setting this variable is permanent across app sessions.
|
||
**/
|
||
var preferredAudioTrackLanguage: String? = null
|
||
get() {
|
||
return field ?: getKey(
|
||
"$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY",
|
||
field
|
||
)?.also {
|
||
field = it
|
||
}
|
||
}
|
||
set(value) {
|
||
setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value)
|
||
field = value
|
||
}
|
||
|
||
private var simpleCache: SimpleCache? = null
|
||
|
||
var requestSubtitleUpdate: (() -> Unit)? = null
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun createOnlineSource(headers: Map<String, String>): HttpDataSource.Factory {
|
||
val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT)
|
||
return source.apply {
|
||
setDefaultRequestProperties(headers)
|
||
}
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory {
|
||
val provider = getApiFromNameNull(link.source)
|
||
val interceptor = provider?.getVideoInterceptor(link)
|
||
|
||
val source = if (interceptor == null) {
|
||
DefaultHttpDataSource.Factory() //TODO USE app.baseClient
|
||
.setUserAgent(USER_AGENT)
|
||
.setAllowCrossProtocolRedirects(true) //https://stackoverflow.com/questions/69040127/error-code-io-bad-http-status-exoplayer-android
|
||
} else {
|
||
val client = app.baseClient.newBuilder()
|
||
.addInterceptor(interceptor)
|
||
.build()
|
||
OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT)
|
||
}
|
||
|
||
// Do no include empty referer, if the provider wants those they can use the header map.
|
||
val refererMap =
|
||
if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer)
|
||
val headers = mapOf(
|
||
"accept" to "*/*",
|
||
"sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"",
|
||
"sec-ch-ua-mobile" to "?0",
|
||
"sec-fetch-user" to "?1",
|
||
"sec-fetch-mode" to "navigate",
|
||
"sec-fetch-dest" to "video"
|
||
) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization
|
||
|
||
return source.apply {
|
||
setDefaultRequestProperties(headers)
|
||
}
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun Context.createOfflineSource(): DataSource.Factory {
|
||
return DefaultDataSourceFactory(this, USER_AGENT)
|
||
}
|
||
|
||
/*private fun getSubSources(
|
||
onlineSourceFactory: DataSource.Factory?,
|
||
offlineSourceFactory: DataSource.Factory?,
|
||
subHelper: PlayerSubtitleHelper,
|
||
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
|
||
val activeSubtitles = ArrayList<SubtitleData>()
|
||
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
|
||
val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url))
|
||
.setMimeType(sub.mimeType)
|
||
.setLanguage("_${sub.name}")
|
||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||
.build()
|
||
when (sub.origin) {
|
||
SubtitleOrigin.DOWNLOADED_FILE -> {
|
||
if (offlineSourceFactory != null) {
|
||
activeSubtitles.add(sub)
|
||
SingleSampleMediaSource.Factory(offlineSourceFactory)
|
||
.createMediaSource(subConfig, C.TIME_UNSET)
|
||
} else {
|
||
null
|
||
}
|
||
}
|
||
SubtitleOrigin.URL -> {
|
||
if (onlineSourceFactory != null) {
|
||
activeSubtitles.add(sub)
|
||
SingleSampleMediaSource.Factory(onlineSourceFactory)
|
||
.createMediaSource(subConfig, C.TIME_UNSET)
|
||
} else {
|
||
null
|
||
}
|
||
}
|
||
SubtitleOrigin.OPEN_SUBTITLES -> {
|
||
// TODO
|
||
throw NotImplementedError()
|
||
}
|
||
}
|
||
}
|
||
println("SUBSRC: ${subSources.size} activeSubtitles : ${activeSubtitles.size} of ${subHelper.getAllSubtitles().size} ")
|
||
return Pair(subSources, activeSubtitles)
|
||
}*/
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun getCache(context: Context, cacheSize: Long): SimpleCache? {
|
||
return try {
|
||
val databaseProvider = StandaloneDatabaseProvider(context)
|
||
SimpleCache(
|
||
File(
|
||
context.cacheDir, "exoplayer"
|
||
).also { deleteFileOnExit(it) }, // Ensures always fresh file
|
||
LeastRecentlyUsedCacheEvictor(cacheSize),
|
||
databaseProvider
|
||
)
|
||
} catch (e: Exception) {
|
||
logError(e)
|
||
null
|
||
}
|
||
}
|
||
|
||
private fun getMediaItemBuilder(mimeType: String):
|
||
MediaItem.Builder {
|
||
return MediaItem.Builder()
|
||
//Replace needed for android 6.0.0 https://github.com/google/ExoPlayer/issues/5983
|
||
.setMimeType(mimeType)
|
||
}
|
||
|
||
private fun getMediaItem(mimeType: String, uri: Uri): MediaItem {
|
||
return getMediaItemBuilder(mimeType).setUri(uri).build()
|
||
}
|
||
|
||
private fun getMediaItem(mimeType: String, url: String): MediaItem {
|
||
return getMediaItemBuilder(mimeType).setUri(url).build()
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector {
|
||
val trackSelector = DefaultTrackSelector(context)
|
||
trackSelector.parameters = trackSelector.buildUponParameters()
|
||
// This will not force higher quality videos to fail
|
||
// but will make the m3u8 pick the correct preferred
|
||
.setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE)
|
||
.setPreferredAudioLanguage(null)
|
||
.build()
|
||
return trackSelector
|
||
}
|
||
|
||
var currentTextRenderer: CustomTextRenderer? = null
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun buildExoPlayer(
|
||
context: Context,
|
||
mediaItemSlices: List<MediaItemSlice>,
|
||
subSources: List<SingleSampleMediaSource>,
|
||
currentWindow: Int,
|
||
playbackPosition: Long,
|
||
playBackSpeed: Float,
|
||
subtitleOffset: Long,
|
||
cacheSize: Long,
|
||
videoBufferMs: Long,
|
||
playWhenReady: Boolean = true,
|
||
cacheFactory: CacheDataSource.Factory? = null,
|
||
trackSelector: TrackSelector? = null,
|
||
/**
|
||
* Sets the m3u8 preferred video quality, will not force stop anything with higher quality.
|
||
* Does not work if trackSelector is defined.
|
||
**/
|
||
maxVideoHeight: Int? = null
|
||
): ExoPlayer {
|
||
val exoPlayerBuilder =
|
||
ExoPlayer.Builder(context)
|
||
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
|
||
DefaultRenderersFactory(context).apply {
|
||
setEnableDecoderFallback(true)
|
||
// Enable Ffmpeg extension
|
||
setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
|
||
}.createRenderers(
|
||
eventHandler,
|
||
videoRendererEventListener,
|
||
audioRendererEventListener,
|
||
textRendererOutput,
|
||
metadataRendererOutput
|
||
).map {
|
||
if (it is TextRenderer) {
|
||
val currentTextRenderer = CustomTextRenderer(
|
||
subtitleOffset,
|
||
textRendererOutput,
|
||
eventHandler.looper,
|
||
CustomSubtitleDecoderFactory()
|
||
).also { this.currentTextRenderer = it }
|
||
currentTextRenderer
|
||
} else it
|
||
}.toTypedArray()
|
||
}
|
||
.setTrackSelector(
|
||
trackSelector ?: getTrackSelector(
|
||
context,
|
||
maxVideoHeight
|
||
)
|
||
)
|
||
// Allows any seeking to be +- 0.3s to allow for faster seeking
|
||
.setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs))
|
||
.setLoadControl(
|
||
DefaultLoadControl.Builder()
|
||
.setTargetBufferBytes(
|
||
if (cacheSize <= 0) {
|
||
DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES
|
||
} else {
|
||
if (cacheSize > Int.MAX_VALUE) Int.MAX_VALUE else cacheSize.toInt()
|
||
}
|
||
)
|
||
.setBackBuffer(
|
||
30000,
|
||
true
|
||
)
|
||
.setBufferDurationsMs(
|
||
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
|
||
if (videoBufferMs <= 0) {
|
||
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS
|
||
} else {
|
||
videoBufferMs.toInt()
|
||
},
|
||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||
).build()
|
||
)
|
||
|
||
|
||
val factory =
|
||
if (cacheFactory == null) DefaultMediaSourceFactory(context)
|
||
else DefaultMediaSourceFactory(cacheFactory)
|
||
|
||
// If there is only one item then treat it as normal, if multiple: concatenate the items.
|
||
val videoMediaSource = if (mediaItemSlices.size == 1) {
|
||
val item = mediaItemSlices.first()
|
||
|
||
item.drm?.let { drm ->
|
||
val drmCallback =
|
||
LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray())
|
||
val manager = DefaultDrmSessionManager.Builder()
|
||
.setPlayClearSamplesWithoutKeys(true)
|
||
.setMultiSession(false)
|
||
.setKeyRequestParameters(drm.keyRequestParameters)
|
||
.setUuidAndExoMediaDrmProvider(drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
|
||
.build(drmCallback)
|
||
val manifestDataSourceFactory = DefaultHttpDataSource.Factory()
|
||
|
||
DashMediaSource.Factory(manifestDataSourceFactory)
|
||
.setDrmSessionManagerProvider { manager }
|
||
.createMediaSource(item.mediaItem)
|
||
} ?: run {
|
||
factory.createMediaSource(item.mediaItem)
|
||
}
|
||
} else {
|
||
val source = ConcatenatingMediaSource()
|
||
mediaItemSlices.map { item ->
|
||
source.addMediaSource(
|
||
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
||
ClippingMediaSource(
|
||
factory.createMediaSource(item.mediaItem),
|
||
item.durationUs
|
||
)
|
||
)
|
||
}
|
||
source
|
||
}
|
||
|
||
//println("PLAYBACK POS $playbackPosition")
|
||
return exoPlayerBuilder.build().apply {
|
||
setPlayWhenReady(playWhenReady)
|
||
seekTo(currentWindow, playbackPosition)
|
||
setMediaSource(
|
||
MergingMediaSource(
|
||
videoMediaSource, *subSources.toTypedArray()
|
||
),
|
||
playbackPosition
|
||
)
|
||
setHandleAudioBecomingNoisy(true)
|
||
setPlaybackSpeed(playBackSpeed)
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? {
|
||
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
|
||
for (lastTimeStamp in lastTimeStamps) {
|
||
if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) {
|
||
return lastTimeStamp
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
fun updatedTime(
|
||
writePosition: Long? = null,
|
||
source: PlayerEventSource = PlayerEventSource.Player
|
||
) {
|
||
val position = writePosition ?: exoPlayer?.currentPosition
|
||
|
||
getCurrentTimestamp(position)?.let { timestamp ->
|
||
event(TimestampInvokedEvent(timestamp, source))
|
||
}
|
||
|
||
val duration = exoPlayer?.contentDuration
|
||
if (duration != null && position != null) {
|
||
event(
|
||
PositionEvent(
|
||
source,
|
||
fromMs = exoPlayer?.currentPosition ?: 0,
|
||
position,
|
||
duration
|
||
)
|
||
)
|
||
}
|
||
}
|
||
|
||
override fun seekTime(time: Long, source: PlayerEventSource) {
|
||
exoPlayer?.seekTime(time, source)
|
||
}
|
||
|
||
override fun seekTo(time: Long, source: PlayerEventSource) {
|
||
updatedTime(time, source)
|
||
exoPlayer?.seekTo(time)
|
||
}
|
||
|
||
private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) {
|
||
updatedTime(currentPosition + time, source)
|
||
seekTo(currentPosition + time)
|
||
}
|
||
|
||
override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) {
|
||
Log.i(TAG, "handleEvent ${event.name}")
|
||
try {
|
||
exoPlayer?.apply {
|
||
when (event) {
|
||
CSPlayerEvent.Play -> {
|
||
event(PlayEvent(source))
|
||
play()
|
||
}
|
||
|
||
CSPlayerEvent.Pause -> {
|
||
event(PauseEvent(source))
|
||
pause()
|
||
}
|
||
|
||
CSPlayerEvent.ToggleMute -> {
|
||
if (volume <= 0) {
|
||
//is muted
|
||
volume = lastMuteVolume
|
||
} else {
|
||
// is not muted
|
||
lastMuteVolume = volume
|
||
volume = 0f
|
||
}
|
||
}
|
||
|
||
CSPlayerEvent.PlayPauseToggle -> {
|
||
if (isPlaying) {
|
||
handleEvent(CSPlayerEvent.Pause, source)
|
||
} else {
|
||
handleEvent(CSPlayerEvent.Play, source)
|
||
}
|
||
}
|
||
|
||
CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source)
|
||
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source)
|
||
CSPlayerEvent.NextEpisode -> event(
|
||
EpisodeSeekEvent(
|
||
offset = 1,
|
||
source = source
|
||
)
|
||
)
|
||
|
||
CSPlayerEvent.PrevEpisode -> event(
|
||
EpisodeSeekEvent(
|
||
offset = -1,
|
||
source = source
|
||
)
|
||
)
|
||
|
||
CSPlayerEvent.SkipCurrentChapter -> {
|
||
//val dur = this@CS3IPlayer.getDuration() ?: return@apply
|
||
getCurrentTimestamp()?.let { lastTimeStamp ->
|
||
if (lastTimeStamp.skipToNextEpisode) {
|
||
handleEvent(CSPlayerEvent.NextEpisode, source)
|
||
} else {
|
||
seekTo(lastTimeStamp.endMs + 1L)
|
||
}
|
||
event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (t: Throwable) {
|
||
Log.e(TAG, "handleEvent error", t)
|
||
event(ErrorEvent(t))
|
||
}
|
||
}
|
||
|
||
private fun loadExo(
|
||
context: Context,
|
||
mediaSlices: List<MediaItemSlice>,
|
||
subSources: List<SingleSampleMediaSource>,
|
||
cacheFactory: CacheDataSource.Factory? = null
|
||
) {
|
||
Log.i(TAG, "loadExo")
|
||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||
val maxVideoHeight = settingsManager.getInt(
|
||
context.getString(if (context.isUsingMobileData()) com.lagradost.cloudstream3.R.string.quality_pref_mobile_data_key else com.lagradost.cloudstream3.R.string.quality_pref_key),
|
||
Int.MAX_VALUE
|
||
)
|
||
|
||
try {
|
||
hasUsedFirstRender = false
|
||
|
||
// ye this has to be a val for whatever reason
|
||
// this makes no sense
|
||
exoPlayer = buildExoPlayer(
|
||
context,
|
||
mediaSlices,
|
||
subSources,
|
||
currentWindow,
|
||
playbackPosition,
|
||
playBackSpeed,
|
||
cacheSize = cacheSize,
|
||
videoBufferMs = videoBufferMs,
|
||
playWhenReady = isPlaying, // this keep the current state of the player
|
||
cacheFactory = cacheFactory,
|
||
subtitleOffset = currentSubtitleOffset,
|
||
maxVideoHeight = maxVideoHeight
|
||
)
|
||
|
||
requestSubtitleUpdate = ::reloadSubs
|
||
|
||
event(PlayerAttachedEvent(exoPlayer))
|
||
exoPlayer?.prepare()
|
||
|
||
exoPlayer?.let { exo ->
|
||
event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
|
||
isPlaying = exo.isPlaying
|
||
}
|
||
|
||
exoPlayer?.addListener(object : Player.Listener {
|
||
override fun onTracksChanged(tracks: Tracks) {
|
||
normalSafeApiCall {
|
||
val textTracks = tracks.groups.filter { it.type == TRACK_TYPE_TEXT }
|
||
|
||
playerSelectedSubtitleTracks =
|
||
textTracks.map { group ->
|
||
group.getFormats().mapNotNull { (format, _) ->
|
||
(format.id ?: return@mapNotNull null) to group.isSelected
|
||
}
|
||
}.flatten()
|
||
|
||
val exoPlayerReportedTracks =
|
||
tracks.groups.filter { it.type == TRACK_TYPE_TEXT }.getFormats()
|
||
.mapNotNull { (format, _) ->
|
||
// Filter out non subs, already used subs and subs without languages
|
||
if (format.id == null ||
|
||
format.language == null ||
|
||
format.language?.startsWith("-") == true
|
||
) return@mapNotNull null
|
||
|
||
return@mapNotNull SubtitleData(
|
||
// Nicer looking displayed names
|
||
fromTwoLettersToLanguage(format.language!!)
|
||
?: format.language!!,
|
||
// See setPreferredTextLanguage
|
||
format.id!!,
|
||
SubtitleOrigin.EMBEDDED_IN_VIDEO,
|
||
format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP,
|
||
emptyMap(),
|
||
format.language
|
||
)
|
||
}
|
||
|
||
event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks))
|
||
event(TracksChangedEvent())
|
||
event(SubtitlesUpdatedEvent())
|
||
}
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||
exoPlayer?.let { exo ->
|
||
event(
|
||
StatusEvent(
|
||
wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused,
|
||
isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
|
||
)
|
||
)
|
||
isPlaying = exo.isPlaying
|
||
}
|
||
|
||
when (playbackState) {
|
||
Player.STATE_READY -> {
|
||
onRenderFirst()
|
||
}
|
||
|
||
else -> {}
|
||
}
|
||
|
||
|
||
if (playWhenReady) {
|
||
when (playbackState) {
|
||
Player.STATE_READY -> {
|
||
|
||
}
|
||
|
||
Player.STATE_ENDED -> {
|
||
event(VideoEndedEvent())
|
||
}
|
||
|
||
Player.STATE_BUFFERING -> {
|
||
updatedTime(source = PlayerEventSource.Player)
|
||
}
|
||
|
||
Player.STATE_IDLE -> {
|
||
|
||
}
|
||
|
||
else -> Unit
|
||
}
|
||
}
|
||
}
|
||
|
||
override fun onPlayerError(error: PlaybackException) {
|
||
// If the Network fails then ignore the exception if the duration is set.
|
||
// This is to switch mirrors automatically if the stream has not been fetched, but
|
||
// allow playing the buffer without internet as then the duration is fetched.
|
||
when {
|
||
error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED
|
||
&& exoPlayer?.duration != TIME_UNSET -> {
|
||
exoPlayer?.prepare()
|
||
}
|
||
|
||
error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> {
|
||
// Re-initialize player at the current live window default position.
|
||
exoPlayer?.seekToDefaultPosition()
|
||
exoPlayer?.prepare()
|
||
}
|
||
|
||
else -> {
|
||
event(ErrorEvent(error))
|
||
}
|
||
}
|
||
|
||
super.onPlayerError(error)
|
||
}
|
||
|
||
//override fun onCues(cues: MutableList<Cue>) {
|
||
// super.onCues(cues.map { cue -> cue.buildUpon().setText("Hello world").setSize(Cue.DIMEN_UNSET).build() })
|
||
//}
|
||
|
||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||
super.onIsPlayingChanged(isPlaying)
|
||
if (isPlaying) {
|
||
event(RequestAudioFocusEvent())
|
||
onRenderFirst()
|
||
}
|
||
}
|
||
|
||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||
super.onPlaybackStateChanged(playbackState)
|
||
when (playbackState) {
|
||
Player.STATE_READY -> {
|
||
|
||
}
|
||
|
||
Player.STATE_ENDED -> {
|
||
// Resets subtitle delay on ended video
|
||
setSubtitleOffset(0)
|
||
|
||
// Only play next episode if autoplay is on (default)
|
||
if (PreferenceManager.getDefaultSharedPreferences(context)
|
||
?.getBoolean(
|
||
context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key),
|
||
true
|
||
) == true
|
||
) {
|
||
handleEvent(
|
||
CSPlayerEvent.NextEpisode,
|
||
source = PlayerEventSource.Player
|
||
)
|
||
}
|
||
}
|
||
|
||
Player.STATE_BUFFERING -> {
|
||
updatedTime(source = PlayerEventSource.Player)
|
||
}
|
||
|
||
Player.STATE_IDLE -> {
|
||
// IDLE
|
||
}
|
||
|
||
else -> Unit
|
||
}
|
||
}
|
||
|
||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||
super.onVideoSizeChanged(videoSize)
|
||
event(ResizedEvent(height = videoSize.height, width = videoSize.width))
|
||
}
|
||
|
||
override fun onRenderedFirstFrame() {
|
||
super.onRenderedFirstFrame()
|
||
onRenderFirst()
|
||
updatedTime(source = PlayerEventSource.Player)
|
||
}
|
||
})
|
||
} catch (t: Throwable) {
|
||
Log.e(TAG, "loadExo error", t)
|
||
event(ErrorEvent(t))
|
||
}
|
||
}
|
||
|
||
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
|
||
lastTimeStamps = timeStamps
|
||
timeStamps.forEach { timestamp ->
|
||
exoPlayer?.createMessage { _, _ ->
|
||
updatedTime(source = PlayerEventSource.Player)
|
||
//if (payload is EpisodeSkip.SkipStamp) // this should always be true
|
||
// onTimestampInvoked?.invoke(payload)
|
||
}
|
||
?.setLooper(Looper.getMainLooper())
|
||
?.setPosition(timestamp.startMs)
|
||
//?.setPayload(timestamp)
|
||
?.setDeleteAfterDelivery(false)
|
||
?.send()
|
||
}
|
||
updatedTime(source = PlayerEventSource.Player)
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
fun onRenderFirst() {
|
||
if (hasUsedFirstRender) { // this insures that we only call this once per player load
|
||
return
|
||
}
|
||
Log.i(TAG, "Rendered first frame")
|
||
hasUsedFirstRender = true
|
||
val invalid = exoPlayer?.duration?.let { duration ->
|
||
// Only errors short playback when not playing downloaded files
|
||
duration < 20_000L && currentDownloadedFile == null
|
||
// Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period
|
||
// If you can get the total time that'd be better, but this is already niche.
|
||
&& exoPlayer?.currentTimeline?.periodCount == 1
|
||
&& exoPlayer?.isCurrentMediaItemLive != true
|
||
} ?: false
|
||
|
||
if (invalid) {
|
||
releasePlayer(saveTime = false)
|
||
event(ErrorEvent(InvalidFileException("Too short playback")))
|
||
return
|
||
}
|
||
|
||
setPreferredSubtitles(currentSubtitles)
|
||
val format = exoPlayer?.videoFormat
|
||
val width = format?.width
|
||
val height = format?.height
|
||
if (height != null && width != null) {
|
||
event(ResizedEvent(width = width, height = height))
|
||
updatedTime()
|
||
exoPlayer?.apply {
|
||
requestedListeningPercentages?.forEach { percentage ->
|
||
createMessage { _, _ ->
|
||
updatedTime()
|
||
}
|
||
.setLooper(Looper.getMainLooper())
|
||
.setPosition(contentDuration * percentage / 100)
|
||
// .setPayload(customPayloadData)
|
||
.setDeleteAfterDelivery(false)
|
||
.send()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun loadOfflinePlayer(context: Context, data: ExtractorUri) {
|
||
Log.i(TAG, "loadOfflinePlayer")
|
||
try {
|
||
currentDownloadedFile = data
|
||
|
||
val mediaItem = getMediaItem(MimeTypes.VIDEO_MP4, data.uri)
|
||
val offlineSourceFactory = context.createOfflineSource()
|
||
val onlineSourceFactory = createOnlineSource(emptyMap())
|
||
|
||
val (subSources, activeSubtitles) = getSubSources(
|
||
onlineSourceFactory = onlineSourceFactory,
|
||
offlineSourceFactory = offlineSourceFactory,
|
||
subtitleHelper,
|
||
)
|
||
|
||
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
|
||
loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources)
|
||
} catch (t: Throwable) {
|
||
Log.e(TAG, "loadOfflinePlayer error", t)
|
||
event(ErrorEvent(t))
|
||
}
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun getSubSources(
|
||
onlineSourceFactory: HttpDataSource.Factory?,
|
||
offlineSourceFactory: DataSource.Factory?,
|
||
subHelper: PlayerSubtitleHelper,
|
||
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
|
||
val activeSubtitles = ArrayList<SubtitleData>()
|
||
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
|
||
val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url))
|
||
.setMimeType(sub.mimeType)
|
||
.setLanguage("_${sub.name}")
|
||
.setId(sub.getId())
|
||
.setSelectionFlags(0)
|
||
.build()
|
||
when (sub.origin) {
|
||
SubtitleOrigin.DOWNLOADED_FILE -> {
|
||
if (offlineSourceFactory != null) {
|
||
activeSubtitles.add(sub)
|
||
SingleSampleMediaSource.Factory(offlineSourceFactory)
|
||
.createMediaSource(subConfig, TIME_UNSET)
|
||
} else {
|
||
null
|
||
}
|
||
}
|
||
|
||
SubtitleOrigin.URL -> {
|
||
if (onlineSourceFactory != null) {
|
||
activeSubtitles.add(sub)
|
||
SingleSampleMediaSource.Factory(onlineSourceFactory.apply {
|
||
if (sub.headers.isNotEmpty())
|
||
this.setDefaultRequestProperties(sub.headers)
|
||
})
|
||
.createMediaSource(subConfig, TIME_UNSET)
|
||
} else {
|
||
null
|
||
}
|
||
}
|
||
|
||
SubtitleOrigin.EMBEDDED_IN_VIDEO -> {
|
||
if (offlineSourceFactory != null) {
|
||
activeSubtitles.add(sub)
|
||
SingleSampleMediaSource.Factory(offlineSourceFactory)
|
||
.createMediaSource(subConfig, TIME_UNSET)
|
||
} else {
|
||
null
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return Pair(subSources, activeSubtitles)
|
||
}
|
||
|
||
override fun isActive(): Boolean {
|
||
return exoPlayer != null
|
||
}
|
||
|
||
@SuppressLint("UnsafeOptInUsageError")
|
||
private fun loadOnlinePlayer(context: Context, link: ExtractorLink) {
|
||
Log.i(TAG, "loadOnlinePlayer $link")
|
||
try {
|
||
currentLink = link
|
||
|
||
if (ignoreSSL) {
|
||
// Disables ssl check
|
||
val sslContext: SSLContext = SSLContext.getInstance("TLS")
|
||
sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom())
|
||
sslContext.createSSLEngine()
|
||
HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession ->
|
||
true
|
||
}
|
||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||
}
|
||
|
||
val mime = when (link.type) {
|
||
ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8
|
||
ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD
|
||
ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4
|
||
ExtractorLinkType.TORRENT -> throw IllegalArgumentException("No torrent support")
|
||
ExtractorLinkType.MAGNET -> throw IllegalArgumentException("No magnet support")
|
||
}
|
||
|
||
|
||
val mediaItems = when (link) {
|
||
is ExtractorLinkPlayList -> link.playlist.map {
|
||
MediaItemSlice(getMediaItem(mime, it.url), it.durationUs)
|
||
}
|
||
|
||
is DrmExtractorLink -> {
|
||
listOf(
|
||
// Single sliced list with unset length
|
||
MediaItemSlice(
|
||
getMediaItem(mime, link.url), Long.MIN_VALUE,
|
||
drm = DrmMetadata(
|
||
kid = link.kid,
|
||
key = link.key,
|
||
uuid = link.uuid,
|
||
kty = link.kty,
|
||
keyRequestParameters = link.keyRequestParameters
|
||
)
|
||
)
|
||
)
|
||
}
|
||
|
||
else -> listOf(
|
||
// Single sliced list with unset length
|
||
MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE)
|
||
)
|
||
}
|
||
|
||
val onlineSourceFactory = createOnlineSource(link)
|
||
val offlineSourceFactory = context.createOfflineSource()
|
||
|
||
val (subSources, activeSubtitles) = getSubSources(
|
||
onlineSourceFactory = onlineSourceFactory,
|
||
offlineSourceFactory = offlineSourceFactory,
|
||
subtitleHelper
|
||
)
|
||
|
||
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
|
||
|
||
if (simpleCache == null)
|
||
simpleCache = getCache(context, simpleCacheSize)
|
||
|
||
val cacheFactory = CacheDataSource.Factory().apply {
|
||
simpleCache?.let { setCache(it) }
|
||
setUpstreamDataSourceFactory(onlineSourceFactory)
|
||
}
|
||
|
||
loadExo(context, mediaItems, subSources, cacheFactory)
|
||
} catch (t: Throwable) {
|
||
Log.e(TAG, "loadOnlinePlayer error", t)
|
||
event(ErrorEvent(t))
|
||
}
|
||
}
|
||
|
||
override fun reloadPlayer(context: Context) {
|
||
Log.i(TAG, "reloadPlayer")
|
||
|
||
releasePlayer(false)
|
||
currentLink?.let {
|
||
loadOnlinePlayer(context, it)
|
||
} ?: currentDownloadedFile?.let {
|
||
loadOfflinePlayer(context, it)
|
||
}
|
||
}
|
||
}
|