mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-28 07:50:31 +05:30
refactor: merge VideoOnlinePlayerService with OnlinePlayerService
This commit is contained in:
parent
889c253393
commit
e244ded084
@ -1,11 +1,15 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.collection.FloatFloatPair
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Segment(
|
||||
@SerialName("UUID") val uuid: String? = null,
|
||||
val actionType: String? = null,
|
||||
@ -17,7 +21,8 @@ data class Segment(
|
||||
val videoDuration: Double? = null,
|
||||
val votes: Int? = null,
|
||||
var skipped: Boolean = false
|
||||
) {
|
||||
): Parcelable {
|
||||
@Transient
|
||||
@IgnoredOnParcel
|
||||
val segmentStartAndEnd = FloatFloatPair(segment[0], segment[1])
|
||||
}
|
||||
|
@ -44,7 +44,6 @@ object IntentData {
|
||||
const val maxAudioQuality = "maxAudioQuality"
|
||||
const val audioLanguage = "audioLanguage"
|
||||
const val captionLanguage = "captionLanguage"
|
||||
const val wasIntentStopped = "wasIntentStopped"
|
||||
const val tabData = "tabData"
|
||||
const val videoList = "videoList"
|
||||
const val nextPage = "nextPage"
|
||||
@ -57,4 +56,5 @@ object IntentData {
|
||||
const val downloadInfo = "downloadInfo"
|
||||
const val streams = "streams"
|
||||
const val chapters = "chapters"
|
||||
const val segments = "segments"
|
||||
}
|
||||
|
@ -1,11 +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
|
||||
SET_SUBTITLE,
|
||||
SET_SB_AUTO_SKIP_ENABLED,
|
||||
}
|
@ -13,10 +13,12 @@ import com.github.libretube.db.obj.DownloadChapter
|
||||
import com.github.libretube.db.obj.DownloadWithItems
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
|
||||
fun MediaItem.Builder.setMetadata(streams: Streams, videoId: String) = apply {
|
||||
val extras = bundleOf(
|
||||
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
|
||||
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader,
|
||||
IntentData.videoId to videoId,
|
||||
IntentData.streams to streams,
|
||||
IntentData.chapters to streams.chapters
|
||||
)
|
||||
setMediaMetadata(
|
||||
@ -27,6 +29,8 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
|
||||
.setArtworkUri(streams.thumbnailUrl.toUri())
|
||||
.setComposer(streams.uploaderUrl.toID())
|
||||
.setExtras(extras)
|
||||
// send a unique timestamp to notify that the metadata changed, even if playing the same video twice
|
||||
.setTrackNumber(System.currentTimeMillis().mod(Int.MAX_VALUE))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
@ -38,6 +42,7 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply
|
||||
val extras = bundleOf(
|
||||
MediaMetadataCompat.METADATA_KEY_TITLE to download.title,
|
||||
MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader,
|
||||
IntentData.videoId to download.videoId,
|
||||
IntentData.chapters to chapters.map(DownloadChapter::toChapterSegment)
|
||||
)
|
||||
setMediaMetadata(
|
||||
@ -47,6 +52,8 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply
|
||||
.setDurationMs(download.duration?.times(1000))
|
||||
.setArtworkUri(download.thumbnailPath?.toAndroidUri())
|
||||
.setExtras(extras)
|
||||
// send a unique timestamp to notify that the metadata changed, even if playing the same video twice
|
||||
.setTrackNumber(System.currentTimeMillis().mod(Int.MAX_VALUE))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.view.accessibility.CaptioningManager
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
@ -42,6 +41,7 @@ import com.github.libretube.db.obj.WatchPosition
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.enums.SbSkipOptions
|
||||
import com.github.libretube.extensions.seekBy
|
||||
import com.github.libretube.extensions.toastFromMainThread
|
||||
import com.github.libretube.extensions.togglePlayPauseState
|
||||
import com.github.libretube.extensions.updateParameters
|
||||
import com.github.libretube.obj.VideoStats
|
||||
@ -333,13 +333,13 @@ object PlayerHelper {
|
||||
false
|
||||
)
|
||||
|
||||
val enabledVideoCodecs: String
|
||||
private val enabledVideoCodecs: String
|
||||
get() = PreferenceHelper.getString(
|
||||
PreferenceKeys.ENABLED_VIDEO_CODECS,
|
||||
"all"
|
||||
)
|
||||
|
||||
val enabledAudioCodecs: String
|
||||
private val enabledAudioCodecs: String
|
||||
get() = PreferenceHelper.getString(
|
||||
PreferenceKeys.ENABLED_AUDIO_CODECS,
|
||||
"all"
|
||||
@ -601,7 +601,8 @@ object PlayerHelper {
|
||||
fun Player.checkForSegments(
|
||||
context: Context,
|
||||
segments: List<Segment>,
|
||||
sponsorBlockConfig: MutableMap<String, SbSkipOptions>
|
||||
sponsorBlockConfig: MutableMap<String, SbSkipOptions>,
|
||||
skipAutomaticallyIfEnabled: Boolean
|
||||
): Segment? {
|
||||
for (segment in segments.filter { it.category != SPONSOR_HIGHLIGHT_CATEGORY }) {
|
||||
val (start, end) = segment.segmentStartAndEnd
|
||||
@ -609,25 +610,26 @@ object PlayerHelper {
|
||||
|
||||
// avoid seeking to the same segment multiple times, e.g. when the SB segment is at the end of the video
|
||||
if ((duration - currentPosition).absoluteValue < 500) continue
|
||||
if (currentPosition !in segmentStart until segmentEnd) continue
|
||||
|
||||
if (currentPosition in segmentStart until segmentEnd) {
|
||||
val key = sponsorBlockConfig[segment.category]
|
||||
if (key == SbSkipOptions.AUTOMATIC ||
|
||||
(key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped)
|
||||
) {
|
||||
if (sponsorBlockNotifications) {
|
||||
runCatching {
|
||||
Toast.makeText(context, R.string.segment_skipped, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
val key = sponsorBlockConfig[segment.category]
|
||||
|
||||
if (!skipAutomaticallyIfEnabled || key == SbSkipOptions.MANUAL ||
|
||||
(key == SbSkipOptions.AUTOMATIC_ONCE && segment.skipped)
|
||||
) {
|
||||
return segment
|
||||
} else if (key == SbSkipOptions.AUTOMATIC ||
|
||||
(key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped)
|
||||
) {
|
||||
if (sponsorBlockNotifications) {
|
||||
runCatching {
|
||||
context.toastFromMainThread(R.string.segment_skipped)
|
||||
}
|
||||
seekTo(segmentEnd)
|
||||
segment.skipped = true
|
||||
} else if (key == SbSkipOptions.MANUAL ||
|
||||
(key == SbSkipOptions.AUTOMATIC_ONCE && segment.skipped)
|
||||
) {
|
||||
return segment
|
||||
}
|
||||
seekTo(segmentEnd)
|
||||
segment.skipped = true
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
@ -19,8 +19,10 @@ import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.api.obj.Subtitle
|
||||
import com.github.libretube.enums.PlayerCommand
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.extensions.parcelable
|
||||
import com.github.libretube.extensions.toastFromMainThread
|
||||
import com.github.libretube.extensions.updateParameters
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
@ -110,8 +112,53 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
|
||||
|
||||
open fun runPlayerCommand(args: Bundle) {
|
||||
when {
|
||||
args.containsKey(PlayerCommand.SKIP_SILENCE.name) ->
|
||||
exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name)
|
||||
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) -> {
|
||||
val subtitle: Subtitle? = args.parcelable(PlayerCommand.SET_SUBTITLE.name)
|
||||
|
||||
trackSelector?.updateParameters {
|
||||
val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0
|
||||
setPreferredTextRoleFlags(roleFlags)
|
||||
setPreferredTextLanguage(subtitle?.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubtitleRoleFlags(subtitle: Subtitle?): Int {
|
||||
return if (subtitle?.autoGenerated != true) {
|
||||
C.ROLE_FLAG_CAPTION
|
||||
} else {
|
||||
PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,38 +1,52 @@
|
||||
package com.github.libretube.services
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.SubtitleConfiguration
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
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.JsonHelper
|
||||
import com.github.libretube.api.RetrofitInstance
|
||||
import com.github.libretube.api.StreamsExtractor
|
||||
import com.github.libretube.api.obj.Segment
|
||||
import com.github.libretube.api.obj.Streams
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.constants.PreferenceKeys
|
||||
import com.github.libretube.db.DatabaseHelper
|
||||
import com.github.libretube.enums.PlayerCommand
|
||||
import com.github.libretube.extensions.parcelable
|
||||
import com.github.libretube.extensions.setMetadata
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
||||
import com.github.libretube.extensions.toastFromMainThread
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.helpers.PlayerHelper.checkForSegments
|
||||
import com.github.libretube.helpers.PreferenceHelper
|
||||
import com.github.libretube.helpers.ProxyHelper
|
||||
import com.github.libretube.parcelable.PlayerData
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import com.github.libretube.util.YoutubeHlsPlaylistParser
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Loads the selected videos audio in background mode with a notification area.
|
||||
*/
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class OnlinePlayerService : AbstractPlayerService() {
|
||||
open class OnlinePlayerService : AbstractPlayerService() {
|
||||
override val isOfflinePlayer: Boolean = false
|
||||
override val isAudioOnlyPlayer: Boolean = true
|
||||
override val intentActivity: Class<*> = MainActivity::class.java
|
||||
@ -42,6 +56,11 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
private var channelId: String? = null
|
||||
private var startTimestamp: Long? = null
|
||||
|
||||
private val cronetDataSourceFactory = CronetDataSource.Factory(
|
||||
CronetHelper.cronetEngine,
|
||||
Executors.newCachedThreadPool()
|
||||
)
|
||||
|
||||
/**
|
||||
* The response that gets when called the Api.
|
||||
*/
|
||||
@ -49,6 +68,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
private set
|
||||
|
||||
// SponsorBlock Segment data
|
||||
private var sponsorBlockAutoSkip = true
|
||||
private var sponsorBlockSegments = listOf<Segment>()
|
||||
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
||||
|
||||
@ -90,6 +110,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
// get the intent arguments
|
||||
videoId = playerData.videoId
|
||||
playlistId = playerData.playlistId
|
||||
channelId = playerData.channelId
|
||||
startTimestamp = playerData.timestamp
|
||||
|
||||
if (!playerData.keepQueue) PlayingQueue.clear()
|
||||
@ -111,7 +132,8 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
try {
|
||||
StreamsExtractor.extractStreams(videoId)
|
||||
} catch (e: Exception) {
|
||||
val errorMessage = StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e)
|
||||
val errorMessage =
|
||||
StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e)
|
||||
this@OnlinePlayerService.toastFromMainDispatcher(errorMessage)
|
||||
return@withContext null
|
||||
}
|
||||
@ -139,18 +161,14 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
}
|
||||
|
||||
private fun playAudio(seekToPosition: Long) {
|
||||
scope.launch {
|
||||
setMediaItem()
|
||||
setStreamSource()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// seek to the previous position if available
|
||||
if (seekToPosition != 0L) {
|
||||
exoPlayer?.seekTo(seekToPosition)
|
||||
} else if (PlayerHelper.watchPositionsAudio) {
|
||||
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
|
||||
exoPlayer?.seekTo(it)
|
||||
}
|
||||
}
|
||||
// seek to the previous position if available
|
||||
if (seekToPosition != 0L) {
|
||||
exoPlayer?.seekTo(seekToPosition)
|
||||
} else if (PlayerHelper.watchPositionsAudio) {
|
||||
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
|
||||
exoPlayer?.seekTo(it)
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,6 +192,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
saveWatchPosition()
|
||||
|
||||
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return
|
||||
if (!isAudioOnlyPlayer && PlayerHelper.autoPlayCountdown) return
|
||||
|
||||
val nextVideo = nextId ?: PlayingQueue.getNext() ?: return
|
||||
|
||||
@ -187,50 +206,124 @@ class OnlinePlayerService : AbstractPlayerService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the [MediaItem] with the [streams] into the [exoPlayer]
|
||||
*/
|
||||
private suspend fun setMediaItem() {
|
||||
val streams = streams ?: return
|
||||
|
||||
val (uri, mimeType) =
|
||||
if (!PlayerHelper.useHlsOverDash && streams.audioStreams.isNotEmpty()) {
|
||||
PlayerHelper.createDashSource(streams, this) to MimeTypes.APPLICATION_MPD
|
||||
} else {
|
||||
ProxyHelper.unwrapStreamUrl(streams.hls.orEmpty())
|
||||
.toUri() to MimeTypes.APPLICATION_M3U8
|
||||
}
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMimeType(mimeType)
|
||||
.setMetadata(streams)
|
||||
.build()
|
||||
withContext(Dispatchers.Main) { exoPlayer?.setMediaItem(mediaItem) }
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch the segments for SponsorBlock
|
||||
*/
|
||||
private fun fetchSponsorBlockSegments() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (sponsorBlockConfig.isEmpty()) return@runCatching
|
||||
sponsorBlockSegments = RetrofitInstance.api.getSegments(
|
||||
videoId,
|
||||
JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
|
||||
).segments
|
||||
private fun fetchSponsorBlockSegments() = scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (sponsorBlockConfig.isEmpty()) return@runCatching
|
||||
sponsorBlockSegments = RetrofitInstance.api.getSegments(
|
||||
videoId,
|
||||
JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
|
||||
).segments
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
exoPlayer?.playlistMetadata = MediaMetadata.Builder()
|
||||
.setExtras(bundleOf(IntentData.segments to ArrayList(sponsorBlockSegments)))
|
||||
.build()
|
||||
|
||||
checkForSegments()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* check for SponsorBlock segments
|
||||
*/
|
||||
private fun checkForSegments() {
|
||||
handler.postDelayed(this::checkForSegments, 100)
|
||||
|
||||
exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig)
|
||||
exoPlayer?.checkForSegments(
|
||||
this,
|
||||
sponsorBlockSegments,
|
||||
sponsorBlockConfig,
|
||||
skipAutomaticallyIfEnabled = sponsorBlockAutoSkip
|
||||
)
|
||||
}
|
||||
|
||||
override fun runPlayerCommand(args: Bundle) {
|
||||
super.runPlayerCommand(args)
|
||||
|
||||
if (args.containsKey(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name)) {
|
||||
sponsorBlockAutoSkip = args.getBoolean(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the [MediaItem] with the [streams] into the [exoPlayer]
|
||||
*/
|
||||
private fun setStreamSource() {
|
||||
val streams = streams ?: return
|
||||
|
||||
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!!
|
||||
|
||||
val mediaItem =
|
||||
createMediaItem(lbryHlsUrl.toUri(), MimeTypes.APPLICATION_M3U8, streams)
|
||||
exoPlayer?.setMediaItem(mediaItem)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
val mediaItem = createMediaItem(dashUri, MimeTypes.APPLICATION_MPD, streams)
|
||||
exoPlayer?.setMediaItem(mediaItem)
|
||||
}
|
||||
// HLS
|
||||
streams.hls != null -> {
|
||||
val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory)
|
||||
.setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
ProxyHelper.unwrapStreamUrl(streams.hls).toUri(),
|
||||
MimeTypes.APPLICATION_M3U8,
|
||||
streams
|
||||
)
|
||||
val mediaSource = hlsMediaSourceFactory.createMediaSource(mediaItem)
|
||||
|
||||
exoPlayer?.setMediaSource(mediaSource)
|
||||
return
|
||||
}
|
||||
// NO STREAM FOUND
|
||||
else -> {
|
||||
toastFromMainThread(R.string.unknown_error)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}.orEmpty()
|
||||
|
||||
private fun createMediaItem(uri: Uri, mimeType: String, streams: Streams) =
|
||||
MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMimeType(mimeType)
|
||||
.setSubtitleConfigurations(getSubtitleConfigs())
|
||||
.setMetadata(streams, videoId)
|
||||
.build()
|
||||
}
|
||||
|
@ -1,174 +1,9 @@
|
||||
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.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
|
||||
class VideoOnlinePlayerService : OnlinePlayerService() {
|
||||
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)
|
||||
}
|
||||
}
|
@ -24,7 +24,6 @@ import com.github.libretube.databinding.ActivityOfflinePlayerBinding
|
||||
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
|
||||
import com.github.libretube.db.DatabaseHolder.Database
|
||||
import com.github.libretube.db.obj.DownloadChapter
|
||||
import com.github.libretube.db.obj.filterByTab
|
||||
import com.github.libretube.enums.FileType
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.extensions.serializableExtra
|
||||
@ -39,7 +38,6 @@ import com.github.libretube.ui.listeners.SeekbarPreviewListener
|
||||
import com.github.libretube.ui.models.ChaptersViewModel
|
||||
import com.github.libretube.ui.models.CommonPlayerViewModel
|
||||
import com.github.libretube.util.OfflineTimeFrameReceiver
|
||||
import com.github.libretube.util.PauseableTimer
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -59,11 +57,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
|
||||
private val chaptersViewModel: ChaptersViewModel by viewModels()
|
||||
|
||||
private val watchPositionTimer = PauseableTimer(
|
||||
onTick = this::saveWatchPosition,
|
||||
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
|
||||
)
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
@ -87,13 +80,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
pipParams
|
||||
)
|
||||
}
|
||||
|
||||
// Start or pause watch position timer
|
||||
if (isPlaying) {
|
||||
watchPositionTimer.resume()
|
||||
} else {
|
||||
watchPositionTimer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
@ -108,10 +94,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) {
|
||||
playNextVideo(PlayingQueue.getNext() ?: return)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,9 +135,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
PlayingQueue.resetToDefaults()
|
||||
PlayingQueue.clear()
|
||||
|
||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
||||
playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
|
||||
}
|
||||
@ -180,12 +159,9 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
if (PlayerHelper.pipEnabled) {
|
||||
PictureInPictureCompat.setPictureInPictureParams(this, pipParams)
|
||||
}
|
||||
|
||||
lifecycleScope.launch { fillQueue() }
|
||||
}
|
||||
|
||||
private fun playNextVideo(videoId: String) {
|
||||
saveWatchPosition()
|
||||
this.videoId = videoId
|
||||
playVideo()
|
||||
}
|
||||
@ -234,29 +210,9 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let {
|
||||
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it)
|
||||
}
|
||||
|
||||
if (PlayerHelper.watchPositionsVideo) {
|
||||
PlayerHelper.getStoredWatchPosition(videoId, downloadInfo.duration)?.let {
|
||||
playerController.seekTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fillQueue() {
|
||||
val downloads = withContext(Dispatchers.IO) {
|
||||
Database.downloadDao().getAll()
|
||||
}.filterByTab(DownloadTab.VIDEO)
|
||||
|
||||
PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() })
|
||||
}
|
||||
|
||||
private fun saveWatchPosition() {
|
||||
if (!PlayerHelper.watchPositionsVideo) return
|
||||
|
||||
PlayerHelper.saveWatchPosition(playerController, videoId)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
commonPlayerViewModel.isFullscreen.value = true
|
||||
super.onResume()
|
||||
@ -272,14 +228,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
saveWatchPosition()
|
||||
|
||||
watchPositionTimer.destroy()
|
||||
|
||||
runCatching {
|
||||
playerController.stop()
|
||||
}
|
||||
|
||||
runCatching {
|
||||
unregisterReceiver(playerActionReceiver)
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.PixelCopy
|
||||
@ -44,6 +45,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
@ -58,16 +60,15 @@ import com.github.libretube.compat.PictureInPictureParamsCompat
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.constants.PreferenceKeys
|
||||
import com.github.libretube.databinding.FragmentPlayerBinding
|
||||
import com.github.libretube.db.DatabaseHelper
|
||||
import com.github.libretube.db.DatabaseHolder
|
||||
import com.github.libretube.enums.PlayerCommand
|
||||
import com.github.libretube.enums.PlayerEvent
|
||||
import com.github.libretube.enums.ShareObjectType
|
||||
import com.github.libretube.extensions.formatShort
|
||||
import com.github.libretube.extensions.parcelable
|
||||
import com.github.libretube.extensions.parcelableList
|
||||
import com.github.libretube.extensions.serializableExtra
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
||||
import com.github.libretube.extensions.togglePlayPauseState
|
||||
import com.github.libretube.extensions.updateIfChanged
|
||||
import com.github.libretube.helpers.BackgroundHelper
|
||||
@ -106,7 +107,6 @@ import com.github.libretube.ui.sheets.BaseBottomSheet
|
||||
import com.github.libretube.ui.sheets.CommentsSheet
|
||||
import com.github.libretube.ui.sheets.StatsSheet
|
||||
import com.github.libretube.util.OnlineTimeFrameReceiver
|
||||
import com.github.libretube.util.PauseableTimer
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
import com.github.libretube.util.TextUtils
|
||||
import com.github.libretube.util.TextUtils.toTimeInSeconds
|
||||
@ -137,13 +137,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
private lateinit var videoId: String
|
||||
private var playlistId: String? = null
|
||||
private var channelId: String? = null
|
||||
private var keepQueue = false
|
||||
private var timeStamp = 0L
|
||||
private var isShort = false
|
||||
|
||||
// data and objects stored for the player
|
||||
private lateinit var streams: Streams
|
||||
private var isPlayerTransitioning = true
|
||||
|
||||
// if null, it's been set to automatic
|
||||
private var fullscreenResolution: Int? = null
|
||||
@ -224,12 +221,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// schedule task to save the watch position each second
|
||||
private val watchPositionTimer = PauseableTimer(
|
||||
onTick = this::saveWatchPosition,
|
||||
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
|
||||
)
|
||||
|
||||
private var bufferingTimeoutTask: Runnable? = null
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
@ -246,26 +237,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
BackgroundHelper.stopBackgroundPlay(requireContext())
|
||||
}
|
||||
|
||||
// add the video to the watch history when starting to play the video
|
||||
if (isPlaying && PlayerHelper.watchHistoryEnabled) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
DatabaseHelper.addToWatchHistory(videoId, streams)
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlaying && PlayerHelper.sponsorBlockEnabled) {
|
||||
handler.postDelayed(
|
||||
this@PlayerFragment::checkForSegments,
|
||||
100
|
||||
)
|
||||
}
|
||||
|
||||
// Start or pause watch position timer
|
||||
if (isPlaying) {
|
||||
watchPositionTimer.resume()
|
||||
} else {
|
||||
watchPositionTimer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
@ -282,8 +259,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
saveWatchPosition()
|
||||
|
||||
// set the playback speed to one if having reached the end of a livestream
|
||||
if (playbackState == Player.STATE_BUFFERING && binding.player.isLive &&
|
||||
playerController.duration - playerController.currentPosition < 700
|
||||
@ -293,23 +268,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
// check if video has ended, next video is available and autoplay is enabled/the video is part of a played playlist.
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
if (!isPlayerTransitioning && PlayerHelper.isAutoPlayEnabled(playlistId != null)) {
|
||||
isPlayerTransitioning = true
|
||||
if (PlayerHelper.autoPlayCountdown) {
|
||||
showAutoPlayCountdown()
|
||||
} else {
|
||||
playNextVideo()
|
||||
}
|
||||
if (PlayerHelper.isAutoPlayEnabled(playlistId != null) && PlayerHelper.autoPlayCountdown) {
|
||||
showAutoPlayCountdown()
|
||||
} else {
|
||||
binding.player.showControllerPermanently()
|
||||
}
|
||||
}
|
||||
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
// media actually playing
|
||||
isPlayerTransitioning = false
|
||||
}
|
||||
|
||||
// listen for the stop button in the notification
|
||||
if (playbackState == PlaybackState.STATE_STOPPED && PlayerHelper.pipEnabled &&
|
||||
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
|
||||
@ -334,6 +299,37 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
}
|
||||
|
||||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||
super.onMediaMetadataChanged(mediaMetadata)
|
||||
|
||||
mediaMetadata.extras?.getString(IntentData.videoId)?.let {
|
||||
videoId = it
|
||||
}
|
||||
|
||||
val maybeStreams: Streams? = mediaMetadata.extras?.parcelable(IntentData.streams)
|
||||
maybeStreams?.let {
|
||||
streams = it
|
||||
viewModel.segments = emptyList()
|
||||
playVideo()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaylistMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||
super.onPlaylistMetadataChanged(mediaMetadata)
|
||||
|
||||
val segments: List<Segment>? = mediaMetadata.extras?.parcelableList(IntentData.segments)
|
||||
viewModel.segments = segments.orEmpty()
|
||||
|
||||
playerBinding.exoProgress.setSegments(viewModel.segments)
|
||||
playerBinding.sbToggle.isVisible = true
|
||||
viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY }
|
||||
?.let {
|
||||
lifecycleScope.launch(Dispatchers.IO) { initializeHighlight(it) }
|
||||
}
|
||||
|
||||
Log.e("rec", "segments received")
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch player errors to prevent the app from stopping
|
||||
*/
|
||||
@ -369,12 +365,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val playerData = requireArguments().parcelable<PlayerData>(IntentData.playerData)!!
|
||||
videoId = playerData.videoId
|
||||
playlistId = playerData.playlistId
|
||||
channelId = playerData.channelId
|
||||
keepQueue = playerData.keepQueue
|
||||
timeStamp = playerData.timestamp
|
||||
|
||||
// broadcast receiver for PiP actions
|
||||
ContextCompat.registerReceiver(
|
||||
@ -386,11 +376,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true)
|
||||
noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false)
|
||||
|
||||
BackgroundHelper.startMediaService(requireContext(), VideoOnlinePlayerService::class.java, bundleOf()) {
|
||||
playerController = it
|
||||
playerController.addListener(playerListener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@ -406,11 +391,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
SoftwareKeyboardControllerCompat(view).hide()
|
||||
|
||||
// reset the callbacks of the playing queue
|
||||
PlayingQueue.resetToDefaults()
|
||||
|
||||
// clear the playing queue
|
||||
if (!keepQueue) PlayingQueue.clear()
|
||||
val playerData = requireArguments().parcelable<PlayerData>(IntentData.playerData)!!
|
||||
videoId = playerData.videoId
|
||||
playlistId = playerData.playlistId
|
||||
channelId = playerData.channelId
|
||||
|
||||
changeOrientationMode()
|
||||
|
||||
@ -439,7 +423,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
// offline video playback started and thus the player fragment is no longer needed
|
||||
killPlayerFragment()
|
||||
} else {
|
||||
playVideo()
|
||||
attachToPlayerService(playerData)
|
||||
}
|
||||
}
|
||||
|
||||
@ -456,20 +440,19 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
)
|
||||
}.show(childFragmentManager, null)
|
||||
} else {
|
||||
playVideo()
|
||||
attachToPlayerService(playerData)
|
||||
}
|
||||
|
||||
showBottomBar()
|
||||
}
|
||||
|
||||
/**
|
||||
* somehow the bottom bar is invisible on low screen resolutions, this fixes it
|
||||
*/
|
||||
private fun showBottomBar() {
|
||||
if (_binding?.player?.isPlayerLocked == false) {
|
||||
playerBinding.bottomBar.isVisible = true
|
||||
private fun attachToPlayerService(playerData: PlayerData) {
|
||||
BackgroundHelper.startMediaService(
|
||||
requireContext(),
|
||||
VideoOnlinePlayerService::class.java,
|
||||
bundleOf(IntentData.playerData to playerData)
|
||||
) {
|
||||
playerController = it
|
||||
playerController.addListener(playerListener)
|
||||
}
|
||||
handler.postDelayed(this::showBottomBar, 100)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@ -838,13 +821,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
playerController.pause()
|
||||
}
|
||||
|
||||
// the app was put somewhere in the background - remember to not automatically continue
|
||||
// playing on re-creation of the app
|
||||
// only run if the re-creation is not caused by an orientation change
|
||||
if (!viewModel.isOrientationChangeInProgress) {
|
||||
requireArguments().putBoolean(IntentData.wasIntentStopped, true)
|
||||
}
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@ -868,9 +844,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
saveWatchPosition()
|
||||
|
||||
watchPositionTimer.destroy()
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
|
||||
playerController.removeListener(playerListener)
|
||||
@ -927,26 +900,22 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// save the watch position if video isn't finished and option enabled
|
||||
private fun saveWatchPosition() {
|
||||
if (!isPlayerTransitioning && PlayerHelper.watchPositionsVideo) {
|
||||
PlayerHelper.saveWatchPosition(playerController, videoId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForSegments() {
|
||||
if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
|
||||
|
||||
handler.postDelayed(this::checkForSegments, 100)
|
||||
if (!viewModel.sponsorBlockEnabled || viewModel.segments.isEmpty()) return
|
||||
if (!PlayerHelper.sponsorBlockEnabled || viewModel.segments.isEmpty()) return
|
||||
|
||||
playerController.checkForSegments(
|
||||
requireContext(),
|
||||
viewModel.segments,
|
||||
viewModel.sponsorBlockConfig
|
||||
viewModel.sponsorBlockConfig,
|
||||
// skipping is done by player service
|
||||
skipAutomaticallyIfEnabled = false
|
||||
)
|
||||
?.let { segment ->
|
||||
if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let
|
||||
|
||||
binding.sbSkipBtn.isVisible = true
|
||||
binding.sbSkipBtn.setOnClickListener {
|
||||
playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
|
||||
@ -954,8 +923,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone =
|
||||
true
|
||||
|
||||
if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true
|
||||
}
|
||||
|
||||
private fun playVideo() {
|
||||
@ -966,103 +935,49 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
// reset the comments to become reloaded later
|
||||
commentsViewModel.reset()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
viewModel.fetchVideoInfo(requireContext(), videoId).let { (streams, errorMessage) ->
|
||||
if (errorMessage != null) {
|
||||
context?.toastFromMainDispatcher(errorMessage, Toast.LENGTH_LONG)
|
||||
return@launch
|
||||
}
|
||||
// hide the button to skip SponsorBlock segments manually
|
||||
binding.sbSkipBtn.isGone = true
|
||||
|
||||
this@PlayerFragment.streams = streams!!
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.startServiceCommand,
|
||||
bundleOf(IntentData.streams to streams)
|
||||
)
|
||||
}
|
||||
// use the video's default audio track when starting playback
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN
|
||||
)
|
||||
)
|
||||
|
||||
val isFirstVideo = PlayingQueue.isEmpty()
|
||||
if (isFirstVideo) {
|
||||
PlayingQueue.updateQueue(streams.toStreamItem(videoId), playlistId, channelId)
|
||||
} else {
|
||||
PlayingQueue.updateCurrent(streams.toStreamItem(videoId))
|
||||
}
|
||||
val isLastVideo = !isFirstVideo && PlayingQueue.isLast()
|
||||
val isAutoQueue = playlistId == null && channelId == null
|
||||
if ((isFirstVideo || isLastVideo) && isAutoQueue) {
|
||||
PlayingQueue.insertRelatedStreams(streams.relatedStreams)
|
||||
}
|
||||
// set the default subtitle if available
|
||||
updateCurrentSubtitle(viewModel.currentSubtitle)
|
||||
|
||||
val videoStream = streams.videoStreams.firstOrNull()
|
||||
isShort = PlayingQueue.getCurrent()?.isShort == true ||
|
||||
(videoStream?.height ?: 0) > (videoStream?.width ?: 0)
|
||||
// set media source and resolution in the beginning
|
||||
updateResolution(commonPlayerViewModel.isFullscreen.value == true)
|
||||
|
||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
||||
streamItem.url?.toID()?.let { playNextVideo(it) }
|
||||
}
|
||||
|
||||
// hide the button to skip SponsorBlock segments manually
|
||||
binding.sbSkipBtn.isGone = true
|
||||
|
||||
// set media sources for the player
|
||||
if (!viewModel.isOrientationChangeInProgress) initStreamSources()
|
||||
|
||||
if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) &&
|
||||
isShort && binding.playerMotionLayout.progress == 0f
|
||||
) {
|
||||
setFullscreen()
|
||||
}
|
||||
|
||||
binding.player.apply {
|
||||
useController = false
|
||||
player = playerController
|
||||
}
|
||||
|
||||
initializePlayerView()
|
||||
|
||||
// don't continue playback when the fragment is re-created after Android killed it
|
||||
val wasIntentStopped = requireArguments().getBoolean(IntentData.wasIntentStopped, false)
|
||||
playerController.playWhenReady =
|
||||
PlayerHelper.playAutomatically && !wasIntentStopped
|
||||
requireArguments().putBoolean(IntentData.wasIntentStopped, false)
|
||||
|
||||
playerController.prepare()
|
||||
|
||||
if (binding.playerMotionLayout.progress != 1.0f) {
|
||||
// show controllers when not in picture in picture mode
|
||||
val inPipMode = PlayerHelper.pipEnabled &&
|
||||
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
|
||||
if (!inPipMode) {
|
||||
binding.player.useController = true
|
||||
}
|
||||
}
|
||||
|
||||
fetchSponsorBlockSegments()
|
||||
|
||||
if (streams.category == Streams.categoryMusic) {
|
||||
playerController.setPlaybackSpeed(1f)
|
||||
}
|
||||
|
||||
viewModel.isOrientationChangeInProgress = false
|
||||
if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) &&
|
||||
isShort && binding.playerMotionLayout.progress == 0f
|
||||
) {
|
||||
setFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSponsorBlockSegments() {
|
||||
viewModel.sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
||||
|
||||
// Since the highlight is also a chapter, we need to fetch the other segments
|
||||
// first
|
||||
viewModel.fetchSponsorBlockSegments(videoId)
|
||||
|
||||
if (viewModel.segments.isEmpty()) return
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playerBinding.exoProgress.setSegments(viewModel.segments)
|
||||
playerBinding.sbToggle.isVisible = true
|
||||
binding.player.apply {
|
||||
useController = false
|
||||
player = playerController
|
||||
}
|
||||
viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY }
|
||||
?.let {
|
||||
initializeHighlight(it)
|
||||
|
||||
initializePlayerView()
|
||||
|
||||
if (binding.playerMotionLayout.progress != 1.0f) {
|
||||
// show controllers when not in picture in picture mode
|
||||
val inPipMode = PlayerHelper.pipEnabled &&
|
||||
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
|
||||
if (!inPipMode) {
|
||||
binding.player.useController = true
|
||||
}
|
||||
}
|
||||
|
||||
if (streams.category == Streams.categoryMusic) {
|
||||
playerController.setPlaybackSpeed(1f)
|
||||
}
|
||||
|
||||
viewModel.isOrientationChangeInProgress = false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1076,19 +991,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
|
||||
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return
|
||||
|
||||
// save the current watch position before starting the next video
|
||||
saveWatchPosition()
|
||||
|
||||
videoId = nextId ?: PlayingQueue.getNext() ?: return
|
||||
isPlayerTransitioning = true
|
||||
|
||||
// fix: if the fragment is recreated, play the current video, and not the initial one
|
||||
arguments?.run {
|
||||
val playerData = parcelable<PlayerData>(IntentData.playerData)!!.copy(videoId = videoId)
|
||||
putParcelable(IntentData.playerData, playerData)
|
||||
// make sure that autoplay continues without issues as the activity is obviously still alive
|
||||
// when starting to play the next video
|
||||
putBoolean(IntentData.wasIntentStopped, false)
|
||||
}
|
||||
|
||||
// start to play the next video
|
||||
@ -1245,37 +1153,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
||||
return resolutions.toList()
|
||||
}
|
||||
|
||||
private fun initStreamSources() {
|
||||
// use the video's default audio track when starting playback
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN
|
||||
)
|
||||
)
|
||||
|
||||
// set the default subtitle if available
|
||||
updateCurrentSubtitle(viewModel.currentSubtitle)
|
||||
|
||||
// set media source and resolution in the beginning
|
||||
updateResolution(commonPlayerViewModel.isFullscreen.value == true)
|
||||
playerController.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand,
|
||||
bundleOf(PlayerCommand.START_PLAYBACK.name to true)
|
||||
)
|
||||
|
||||
// support for time stamped links
|
||||
if (timeStamp != 0L) {
|
||||
playerController.seekTo(timeStamp * 1000)
|
||||
// delete the time stamp because it already got consumed
|
||||
timeStamp = 0L
|
||||
} else if (!streams.isLive) {
|
||||
// seek to the saved watch position
|
||||
PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let {
|
||||
playerController.seekTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlayerResolution(resolution: Int, isSelectedByUser: Boolean = false) {
|
||||
val transformedResolution = if (!isSelectedByUser && isShort) {
|
||||
ceil(resolution * 16.0 / 9.0).toInt()
|
||||
|
@ -1,27 +1,14 @@
|
||||
package com.github.libretube.ui.models
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.github.libretube.api.JsonHelper
|
||||
import com.github.libretube.api.RetrofitInstance
|
||||
import com.github.libretube.api.StreamsExtractor
|
||||
import com.github.libretube.api.obj.Segment
|
||||
import com.github.libretube.api.obj.Streams
|
||||
import com.github.libretube.api.obj.Subtitle
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.util.NowPlayingNotification
|
||||
import com.github.libretube.util.deArrow
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@UnstableApi
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
// data to remember for recovery on orientation change
|
||||
private var streamsInfo: Streams? = null
|
||||
var nowPlayingNotification: NowPlayingNotification? = null
|
||||
var segments = listOf<Segment>()
|
||||
var currentSubtitle = Subtitle(code = PlayerHelper.defaultSubtitleCode)
|
||||
var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
||||
@ -32,31 +19,4 @@ class PlayerViewModel : ViewModel() {
|
||||
* Set to true if the activity will be recreated due to an orientation change
|
||||
*/
|
||||
var isOrientationChangeInProgress = false
|
||||
var sponsorBlockEnabled = PlayerHelper.sponsorBlockEnabled
|
||||
|
||||
/**
|
||||
* @return pair of the stream info and the error message if the request was not successful
|
||||
*/
|
||||
suspend fun fetchVideoInfo(context: Context, videoId: String): Pair<Streams?, String?> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (isOrientationChangeInProgress && streamsInfo != null) return@withContext streamsInfo to null
|
||||
|
||||
return@withContext try {
|
||||
StreamsExtractor.extractStreams(videoId).deArrow(videoId) to null
|
||||
} catch (e: Exception) {
|
||||
return@withContext null to StreamsExtractor.getExtractorErrorMessageString(context, e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSponsorBlockSegments(videoId: String) = withContext(Dispatchers.IO) {
|
||||
if (sponsorBlockConfig.isEmpty() || isOrientationChangeInProgress) return@withContext
|
||||
|
||||
runCatching {
|
||||
segments =
|
||||
RetrofitInstance.api.getSegments(
|
||||
videoId,
|
||||
JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
|
||||
).segments
|
||||
}
|
||||
}
|
||||
}
|
@ -14,14 +14,17 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.constants.PreferenceKeys
|
||||
import com.github.libretube.enums.PlayerCommand
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.helpers.PreferenceHelper
|
||||
import com.github.libretube.helpers.WindowHelper
|
||||
import com.github.libretube.obj.BottomSheetItem
|
||||
import com.github.libretube.services.AbstractPlayerService
|
||||
import com.github.libretube.ui.base.BaseActivity
|
||||
import com.github.libretube.ui.dialogs.SubmitDeArrowDialog
|
||||
import com.github.libretube.ui.dialogs.SubmitSegmentDialog
|
||||
@ -49,39 +52,43 @@ class OnlinePlayerView(
|
||||
var currentWindow: Window? = null
|
||||
|
||||
var selectedResolution: Int? = null
|
||||
private var sponsorBlockAutoSkip = true
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
override fun getOptionsMenuItems(): List<BottomSheetItem> {
|
||||
return super.getOptionsMenuItems() +
|
||||
listOf(
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.quality),
|
||||
R.drawable.ic_hd,
|
||||
this::getCurrentResolutionSummary
|
||||
) {
|
||||
playerOptions?.onQualityClicked()
|
||||
},
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.audio_track),
|
||||
R.drawable.ic_audio,
|
||||
this::getCurrentAudioTrackTitle
|
||||
) {
|
||||
playerOptions?.onAudioStreamClicked()
|
||||
},
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.captions),
|
||||
R.drawable.ic_caption,
|
||||
{ playerViewModel?.currentSubtitle?.code ?: context.getString(R.string.none) }
|
||||
) {
|
||||
playerOptions?.onCaptionsClicked()
|
||||
},
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.stats_for_nerds),
|
||||
R.drawable.ic_info
|
||||
) {
|
||||
playerOptions?.onStatsClicked()
|
||||
}
|
||||
)
|
||||
listOf(
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.quality),
|
||||
R.drawable.ic_hd,
|
||||
this::getCurrentResolutionSummary
|
||||
) {
|
||||
playerOptions?.onQualityClicked()
|
||||
},
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.audio_track),
|
||||
R.drawable.ic_audio,
|
||||
this::getCurrentAudioTrackTitle
|
||||
) {
|
||||
playerOptions?.onAudioStreamClicked()
|
||||
},
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.captions),
|
||||
R.drawable.ic_caption,
|
||||
{
|
||||
playerViewModel?.currentSubtitle?.code
|
||||
?: context.getString(R.string.none)
|
||||
}
|
||||
) {
|
||||
playerOptions?.onCaptionsClicked()
|
||||
},
|
||||
BottomSheetItem(
|
||||
context.getString(R.string.stats_for_nerds),
|
||||
R.drawable.ic_info
|
||||
) {
|
||||
playerOptions?.onStatsClicked()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@ -153,7 +160,8 @@ class OnlinePlayerView(
|
||||
updateTopBarMargin()
|
||||
|
||||
binding.fullscreen.isInvisible = PlayerHelper.autoFullscreenEnabled
|
||||
val fullscreenDrawable = if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen
|
||||
val fullscreenDrawable =
|
||||
if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen
|
||||
binding.fullscreen.setImageResource(fullscreenDrawable)
|
||||
|
||||
binding.exoTitle.isInvisible = !isFullscreen
|
||||
@ -161,25 +169,32 @@ class OnlinePlayerView(
|
||||
|
||||
val updateSbImageResource = {
|
||||
binding.sbToggle.setImageResource(
|
||||
if (playerViewModel.sponsorBlockEnabled) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled
|
||||
if (sponsorBlockAutoSkip) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled
|
||||
)
|
||||
}
|
||||
updateSbImageResource()
|
||||
binding.sbToggle.setOnClickListener {
|
||||
playerViewModel.sponsorBlockEnabled = !playerViewModel.sponsorBlockEnabled
|
||||
sponsorBlockAutoSkip = !sponsorBlockAutoSkip
|
||||
(player as? MediaController)?.sendCustomCommand(
|
||||
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||
PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name to sponsorBlockAutoSkip
|
||||
)
|
||||
)
|
||||
updateSbImageResource()
|
||||
}
|
||||
|
||||
syncQueueButtons()
|
||||
|
||||
binding.sbSubmit.isVisible = PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false)
|
||||
binding.sbSubmit.isVisible =
|
||||
PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false)
|
||||
binding.sbSubmit.setOnClickListener {
|
||||
val submitSegmentDialog = SubmitSegmentDialog()
|
||||
submitSegmentDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener
|
||||
submitSegmentDialog.show((context as BaseActivity).supportFragmentManager, null)
|
||||
}
|
||||
|
||||
binding.dearrowSubmit.isVisible = PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_DEARROW, false)
|
||||
binding.dearrowSubmit.isVisible =
|
||||
PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_DEARROW, false)
|
||||
binding.dearrowSubmit.setOnClickListener {
|
||||
val submitDialog = SubmitDeArrowDialog()
|
||||
submitDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener
|
||||
|
Loading…
x
Reference in New Issue
Block a user