mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 00:10:32 +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
|
package com.github.libretube.api.obj
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
import androidx.collection.FloatFloatPair
|
import androidx.collection.FloatFloatPair
|
||||||
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@Parcelize
|
||||||
data class Segment(
|
data class Segment(
|
||||||
@SerialName("UUID") val uuid: String? = null,
|
@SerialName("UUID") val uuid: String? = null,
|
||||||
val actionType: String? = null,
|
val actionType: String? = null,
|
||||||
@ -17,7 +21,8 @@ data class Segment(
|
|||||||
val videoDuration: Double? = null,
|
val videoDuration: Double? = null,
|
||||||
val votes: Int? = null,
|
val votes: Int? = null,
|
||||||
var skipped: Boolean = false
|
var skipped: Boolean = false
|
||||||
) {
|
): Parcelable {
|
||||||
@Transient
|
@Transient
|
||||||
|
@IgnoredOnParcel
|
||||||
val segmentStartAndEnd = FloatFloatPair(segment[0], segment[1])
|
val segmentStartAndEnd = FloatFloatPair(segment[0], segment[1])
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,6 @@ object IntentData {
|
|||||||
const val maxAudioQuality = "maxAudioQuality"
|
const val maxAudioQuality = "maxAudioQuality"
|
||||||
const val audioLanguage = "audioLanguage"
|
const val audioLanguage = "audioLanguage"
|
||||||
const val captionLanguage = "captionLanguage"
|
const val captionLanguage = "captionLanguage"
|
||||||
const val wasIntentStopped = "wasIntentStopped"
|
|
||||||
const val tabData = "tabData"
|
const val tabData = "tabData"
|
||||||
const val videoList = "videoList"
|
const val videoList = "videoList"
|
||||||
const val nextPage = "nextPage"
|
const val nextPage = "nextPage"
|
||||||
@ -57,4 +56,5 @@ object IntentData {
|
|||||||
const val downloadInfo = "downloadInfo"
|
const val downloadInfo = "downloadInfo"
|
||||||
const val streams = "streams"
|
const val streams = "streams"
|
||||||
const val chapters = "chapters"
|
const val chapters = "chapters"
|
||||||
|
const val segments = "segments"
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
package com.github.libretube.enums
|
package com.github.libretube.enums
|
||||||
|
|
||||||
enum class PlayerCommand {
|
enum class PlayerCommand {
|
||||||
START_PLAYBACK,
|
|
||||||
SKIP_SILENCE,
|
SKIP_SILENCE,
|
||||||
SET_VIDEO_TRACK_TYPE_DISABLED,
|
SET_VIDEO_TRACK_TYPE_DISABLED,
|
||||||
SET_AUDIO_ROLE_FLAGS,
|
SET_AUDIO_ROLE_FLAGS,
|
||||||
SET_RESOLUTION,
|
SET_RESOLUTION,
|
||||||
SET_AUDIO_LANGUAGE,
|
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
|
import com.github.libretube.db.obj.DownloadWithItems
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
|
fun MediaItem.Builder.setMetadata(streams: Streams, videoId: String) = apply {
|
||||||
val extras = bundleOf(
|
val extras = bundleOf(
|
||||||
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
|
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
|
||||||
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader,
|
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader,
|
||||||
|
IntentData.videoId to videoId,
|
||||||
|
IntentData.streams to streams,
|
||||||
IntentData.chapters to streams.chapters
|
IntentData.chapters to streams.chapters
|
||||||
)
|
)
|
||||||
setMediaMetadata(
|
setMediaMetadata(
|
||||||
@ -27,6 +29,8 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
|
|||||||
.setArtworkUri(streams.thumbnailUrl.toUri())
|
.setArtworkUri(streams.thumbnailUrl.toUri())
|
||||||
.setComposer(streams.uploaderUrl.toID())
|
.setComposer(streams.uploaderUrl.toID())
|
||||||
.setExtras(extras)
|
.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()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -38,6 +42,7 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply
|
|||||||
val extras = bundleOf(
|
val extras = bundleOf(
|
||||||
MediaMetadataCompat.METADATA_KEY_TITLE to download.title,
|
MediaMetadataCompat.METADATA_KEY_TITLE to download.title,
|
||||||
MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader,
|
MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader,
|
||||||
|
IntentData.videoId to download.videoId,
|
||||||
IntentData.chapters to chapters.map(DownloadChapter::toChapterSegment)
|
IntentData.chapters to chapters.map(DownloadChapter::toChapterSegment)
|
||||||
)
|
)
|
||||||
setMediaMetadata(
|
setMediaMetadata(
|
||||||
@ -47,6 +52,8 @@ fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply
|
|||||||
.setDurationMs(download.duration?.times(1000))
|
.setDurationMs(download.duration?.times(1000))
|
||||||
.setArtworkUri(download.thumbnailPath?.toAndroidUri())
|
.setArtworkUri(download.thumbnailPath?.toAndroidUri())
|
||||||
.setExtras(extras)
|
.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()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.accessibility.CaptioningManager
|
import android.view.accessibility.CaptioningManager
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.app.PendingIntentCompat
|
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.PlayerEvent
|
||||||
import com.github.libretube.enums.SbSkipOptions
|
import com.github.libretube.enums.SbSkipOptions
|
||||||
import com.github.libretube.extensions.seekBy
|
import com.github.libretube.extensions.seekBy
|
||||||
|
import com.github.libretube.extensions.toastFromMainThread
|
||||||
import com.github.libretube.extensions.togglePlayPauseState
|
import com.github.libretube.extensions.togglePlayPauseState
|
||||||
import com.github.libretube.extensions.updateParameters
|
import com.github.libretube.extensions.updateParameters
|
||||||
import com.github.libretube.obj.VideoStats
|
import com.github.libretube.obj.VideoStats
|
||||||
@ -333,13 +333,13 @@ object PlayerHelper {
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
val enabledVideoCodecs: String
|
private val enabledVideoCodecs: String
|
||||||
get() = PreferenceHelper.getString(
|
get() = PreferenceHelper.getString(
|
||||||
PreferenceKeys.ENABLED_VIDEO_CODECS,
|
PreferenceKeys.ENABLED_VIDEO_CODECS,
|
||||||
"all"
|
"all"
|
||||||
)
|
)
|
||||||
|
|
||||||
val enabledAudioCodecs: String
|
private val enabledAudioCodecs: String
|
||||||
get() = PreferenceHelper.getString(
|
get() = PreferenceHelper.getString(
|
||||||
PreferenceKeys.ENABLED_AUDIO_CODECS,
|
PreferenceKeys.ENABLED_AUDIO_CODECS,
|
||||||
"all"
|
"all"
|
||||||
@ -601,7 +601,8 @@ object PlayerHelper {
|
|||||||
fun Player.checkForSegments(
|
fun Player.checkForSegments(
|
||||||
context: Context,
|
context: Context,
|
||||||
segments: List<Segment>,
|
segments: List<Segment>,
|
||||||
sponsorBlockConfig: MutableMap<String, SbSkipOptions>
|
sponsorBlockConfig: MutableMap<String, SbSkipOptions>,
|
||||||
|
skipAutomaticallyIfEnabled: Boolean
|
||||||
): Segment? {
|
): Segment? {
|
||||||
for (segment in segments.filter { it.category != SPONSOR_HIGHLIGHT_CATEGORY }) {
|
for (segment in segments.filter { it.category != SPONSOR_HIGHLIGHT_CATEGORY }) {
|
||||||
val (start, end) = segment.segmentStartAndEnd
|
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
|
// 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 ((duration - currentPosition).absoluteValue < 500) continue
|
||||||
|
if (currentPosition !in segmentStart until segmentEnd) continue
|
||||||
|
|
||||||
if (currentPosition in segmentStart until segmentEnd) {
|
val key = sponsorBlockConfig[segment.category]
|
||||||
val key = sponsorBlockConfig[segment.category]
|
|
||||||
if (key == SbSkipOptions.AUTOMATIC ||
|
if (!skipAutomaticallyIfEnabled || key == SbSkipOptions.MANUAL ||
|
||||||
(key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped)
|
(key == SbSkipOptions.AUTOMATIC_ONCE && segment.skipped)
|
||||||
) {
|
) {
|
||||||
if (sponsorBlockNotifications) {
|
return segment
|
||||||
runCatching {
|
} else if (key == SbSkipOptions.AUTOMATIC ||
|
||||||
Toast.makeText(context, R.string.segment_skipped, Toast.LENGTH_SHORT)
|
(key == SbSkipOptions.AUTOMATIC_ONCE && !segment.skipped)
|
||||||
.show()
|
) {
|
||||||
}
|
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
|
return null
|
||||||
|
@ -19,8 +19,10 @@ import androidx.media3.session.MediaSession
|
|||||||
import androidx.media3.session.SessionCommand
|
import androidx.media3.session.SessionCommand
|
||||||
import androidx.media3.session.SessionResult
|
import androidx.media3.session.SessionResult
|
||||||
import com.github.libretube.R
|
import com.github.libretube.R
|
||||||
|
import com.github.libretube.api.obj.Subtitle
|
||||||
import com.github.libretube.enums.PlayerCommand
|
import com.github.libretube.enums.PlayerCommand
|
||||||
import com.github.libretube.enums.PlayerEvent
|
import com.github.libretube.enums.PlayerEvent
|
||||||
|
import com.github.libretube.extensions.parcelable
|
||||||
import com.github.libretube.extensions.toastFromMainThread
|
import com.github.libretube.extensions.toastFromMainThread
|
||||||
import com.github.libretube.extensions.updateParameters
|
import com.github.libretube.extensions.updateParameters
|
||||||
import com.github.libretube.helpers.PlayerHelper
|
import com.github.libretube.helpers.PlayerHelper
|
||||||
@ -110,8 +112,53 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
|
|||||||
|
|
||||||
open fun runPlayerCommand(args: Bundle) {
|
open fun runPlayerCommand(args: Bundle) {
|
||||||
when {
|
when {
|
||||||
args.containsKey(PlayerCommand.SKIP_SILENCE.name) ->
|
args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> exoPlayer?.skipSilenceEnabled =
|
||||||
exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name)
|
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
|
package com.github.libretube.services
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
import androidx.media3.common.MediaItem
|
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.MimeTypes
|
||||||
import androidx.media3.common.Player
|
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.JsonHelper
|
||||||
import com.github.libretube.api.RetrofitInstance
|
import com.github.libretube.api.RetrofitInstance
|
||||||
import com.github.libretube.api.StreamsExtractor
|
import com.github.libretube.api.StreamsExtractor
|
||||||
import com.github.libretube.api.obj.Segment
|
import com.github.libretube.api.obj.Segment
|
||||||
import com.github.libretube.api.obj.Streams
|
import com.github.libretube.api.obj.Streams
|
||||||
import com.github.libretube.constants.IntentData
|
import com.github.libretube.constants.IntentData
|
||||||
|
import com.github.libretube.constants.PreferenceKeys
|
||||||
import com.github.libretube.db.DatabaseHelper
|
import com.github.libretube.db.DatabaseHelper
|
||||||
|
import com.github.libretube.enums.PlayerCommand
|
||||||
import com.github.libretube.extensions.parcelable
|
import com.github.libretube.extensions.parcelable
|
||||||
import com.github.libretube.extensions.setMetadata
|
import com.github.libretube.extensions.setMetadata
|
||||||
import com.github.libretube.extensions.toID
|
import com.github.libretube.extensions.toID
|
||||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
import com.github.libretube.extensions.toastFromMainDispatcher
|
||||||
|
import com.github.libretube.extensions.toastFromMainThread
|
||||||
import com.github.libretube.helpers.PlayerHelper
|
import com.github.libretube.helpers.PlayerHelper
|
||||||
import com.github.libretube.helpers.PlayerHelper.checkForSegments
|
import com.github.libretube.helpers.PlayerHelper.checkForSegments
|
||||||
|
import com.github.libretube.helpers.PreferenceHelper
|
||||||
import com.github.libretube.helpers.ProxyHelper
|
import com.github.libretube.helpers.ProxyHelper
|
||||||
import com.github.libretube.parcelable.PlayerData
|
import com.github.libretube.parcelable.PlayerData
|
||||||
import com.github.libretube.ui.activities.MainActivity
|
import com.github.libretube.ui.activities.MainActivity
|
||||||
import com.github.libretube.util.PlayingQueue
|
import com.github.libretube.util.PlayingQueue
|
||||||
|
import com.github.libretube.util.YoutubeHlsPlaylistParser
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the selected videos audio in background mode with a notification area.
|
* Loads the selected videos audio in background mode with a notification area.
|
||||||
*/
|
*/
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
class OnlinePlayerService : AbstractPlayerService() {
|
open class OnlinePlayerService : AbstractPlayerService() {
|
||||||
override val isOfflinePlayer: Boolean = false
|
override val isOfflinePlayer: Boolean = false
|
||||||
override val isAudioOnlyPlayer: Boolean = true
|
override val isAudioOnlyPlayer: Boolean = true
|
||||||
override val intentActivity: Class<*> = MainActivity::class.java
|
override val intentActivity: Class<*> = MainActivity::class.java
|
||||||
@ -42,6 +56,11 @@ class OnlinePlayerService : AbstractPlayerService() {
|
|||||||
private var channelId: String? = null
|
private var channelId: String? = null
|
||||||
private var startTimestamp: Long? = null
|
private var startTimestamp: Long? = null
|
||||||
|
|
||||||
|
private val cronetDataSourceFactory = CronetDataSource.Factory(
|
||||||
|
CronetHelper.cronetEngine,
|
||||||
|
Executors.newCachedThreadPool()
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The response that gets when called the Api.
|
* The response that gets when called the Api.
|
||||||
*/
|
*/
|
||||||
@ -49,6 +68,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
// SponsorBlock Segment data
|
// SponsorBlock Segment data
|
||||||
|
private var sponsorBlockAutoSkip = true
|
||||||
private var sponsorBlockSegments = listOf<Segment>()
|
private var sponsorBlockSegments = listOf<Segment>()
|
||||||
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
||||||
|
|
||||||
@ -90,6 +110,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
|||||||
// get the intent arguments
|
// get the intent arguments
|
||||||
videoId = playerData.videoId
|
videoId = playerData.videoId
|
||||||
playlistId = playerData.playlistId
|
playlistId = playerData.playlistId
|
||||||
|
channelId = playerData.channelId
|
||||||
startTimestamp = playerData.timestamp
|
startTimestamp = playerData.timestamp
|
||||||
|
|
||||||
if (!playerData.keepQueue) PlayingQueue.clear()
|
if (!playerData.keepQueue) PlayingQueue.clear()
|
||||||
@ -111,7 +132,8 @@ class OnlinePlayerService : AbstractPlayerService() {
|
|||||||
try {
|
try {
|
||||||
StreamsExtractor.extractStreams(videoId)
|
StreamsExtractor.extractStreams(videoId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val errorMessage = StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e)
|
val errorMessage =
|
||||||
|
StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e)
|
||||||
this@OnlinePlayerService.toastFromMainDispatcher(errorMessage)
|
this@OnlinePlayerService.toastFromMainDispatcher(errorMessage)
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
@ -139,18 +161,14 @@ class OnlinePlayerService : AbstractPlayerService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun playAudio(seekToPosition: Long) {
|
private fun playAudio(seekToPosition: Long) {
|
||||||
scope.launch {
|
setStreamSource()
|
||||||
setMediaItem()
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
// seek to the previous position if available
|
||||||
// seek to the previous position if available
|
if (seekToPosition != 0L) {
|
||||||
if (seekToPosition != 0L) {
|
exoPlayer?.seekTo(seekToPosition)
|
||||||
exoPlayer?.seekTo(seekToPosition)
|
} else if (PlayerHelper.watchPositionsAudio) {
|
||||||
} else if (PlayerHelper.watchPositionsAudio) {
|
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
|
||||||
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
|
exoPlayer?.seekTo(it)
|
||||||
exoPlayer?.seekTo(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,6 +192,7 @@ class OnlinePlayerService : AbstractPlayerService() {
|
|||||||
saveWatchPosition()
|
saveWatchPosition()
|
||||||
|
|
||||||
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return
|
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return
|
||||||
|
if (!isAudioOnlyPlayer && PlayerHelper.autoPlayCountdown) return
|
||||||
|
|
||||||
val nextVideo = nextId ?: PlayingQueue.getNext() ?: 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
|
* fetch the segments for SponsorBlock
|
||||||
*/
|
*/
|
||||||
private fun fetchSponsorBlockSegments() {
|
private fun fetchSponsorBlockSegments() = scope.launch(Dispatchers.IO) {
|
||||||
scope.launch(Dispatchers.IO) {
|
runCatching {
|
||||||
runCatching {
|
if (sponsorBlockConfig.isEmpty()) return@runCatching
|
||||||
if (sponsorBlockConfig.isEmpty()) return@runCatching
|
sponsorBlockSegments = RetrofitInstance.api.getSegments(
|
||||||
sponsorBlockSegments = RetrofitInstance.api.getSegments(
|
videoId,
|
||||||
videoId,
|
JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
|
||||||
JsonHelper.json.encodeToString(sponsorBlockConfig.keys)
|
).segments
|
||||||
).segments
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
exoPlayer?.playlistMetadata = MediaMetadata.Builder()
|
||||||
|
.setExtras(bundleOf(IntentData.segments to ArrayList(sponsorBlockSegments)))
|
||||||
|
.build()
|
||||||
|
|
||||||
checkForSegments()
|
checkForSegments()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* check for SponsorBlock segments
|
* check for SponsorBlock segments
|
||||||
*/
|
*/
|
||||||
private fun checkForSegments() {
|
private fun checkForSegments() {
|
||||||
handler.postDelayed(this::checkForSegments, 100)
|
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
|
package com.github.libretube.services
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.annotation.OptIn
|
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.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)
|
@OptIn(UnstableApi::class)
|
||||||
class VideoOnlinePlayerService : AbstractPlayerService() {
|
class VideoOnlinePlayerService : OnlinePlayerService() {
|
||||||
override val isOfflinePlayer: Boolean = false
|
|
||||||
override val isAudioOnlyPlayer: Boolean = false
|
override val isAudioOnlyPlayer: Boolean = false
|
||||||
override val intentActivity: Class<*> = MainActivity::class.java
|
|
||||||
|
|
||||||
private val cronetDataSourceFactory = CronetDataSource.Factory(
|
|
||||||
CronetHelper.cronetEngine,
|
|
||||||
Executors.newCachedThreadPool()
|
|
||||||
)
|
|
||||||
|
|
||||||
private lateinit var streams: Streams
|
|
||||||
|
|
||||||
override suspend fun onServiceCreated(args: Bundle) {
|
|
||||||
this.streams = args.parcelable<Streams>(IntentData.streams) ?: return
|
|
||||||
|
|
||||||
startPlayback()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun startPlayback() = Unit
|
|
||||||
|
|
||||||
override fun runPlayerCommand(args: Bundle) {
|
|
||||||
when {
|
|
||||||
args.containsKey(PlayerCommand.START_PLAYBACK.name) -> setStreamSource()
|
|
||||||
args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name)
|
|
||||||
args.containsKey(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) -> trackSelector?.updateParameters {
|
|
||||||
setTrackTypeDisabled(
|
|
||||||
C.TRACK_TYPE_VIDEO,
|
|
||||||
args.getBoolean(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
args.containsKey(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name) -> {
|
|
||||||
trackSelector?.updateParameters {
|
|
||||||
setPreferredAudioRoleFlags(args.getInt(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args.containsKey(PlayerCommand.SET_AUDIO_LANGUAGE.name) -> {
|
|
||||||
trackSelector?.updateParameters {
|
|
||||||
setPreferredAudioLanguage(args.getString(PlayerCommand.SET_AUDIO_LANGUAGE.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args.containsKey(PlayerCommand.SET_RESOLUTION.name) -> {
|
|
||||||
trackSelector?.updateParameters {
|
|
||||||
val resolution = args.getInt(PlayerCommand.SET_RESOLUTION.name)
|
|
||||||
setMinVideoSize(Int.MIN_VALUE, resolution)
|
|
||||||
setMaxVideoSize(Int.MAX_VALUE, resolution)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args.containsKey(PlayerCommand.SET_SUBTITLE.name) -> {
|
|
||||||
updateCurrentSubtitle(args.parcelable(PlayerCommand.SET_SUBTITLE.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setStreamSource() {
|
|
||||||
if (!this::streams.isInitialized) return
|
|
||||||
|
|
||||||
val (uri, mimeType) = when {
|
|
||||||
// LBRY HLS
|
|
||||||
PreferenceHelper.getBoolean(
|
|
||||||
PreferenceKeys.LBRY_HLS,
|
|
||||||
false
|
|
||||||
) && streams.videoStreams.any {
|
|
||||||
it.quality.orEmpty().contains("LBRY HLS")
|
|
||||||
} -> {
|
|
||||||
val lbryHlsUrl = streams.videoStreams.first {
|
|
||||||
it.quality!!.contains("LBRY HLS")
|
|
||||||
}.url!!
|
|
||||||
lbryHlsUrl.toUri() to MimeTypes.APPLICATION_M3U8
|
|
||||||
}
|
|
||||||
// DASH
|
|
||||||
!PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> {
|
|
||||||
// only use the dash manifest generated by YT if either it's a livestream or no other source is available
|
|
||||||
val dashUri =
|
|
||||||
if (streams.isLive && streams.dash != null) {
|
|
||||||
ProxyHelper.unwrapStreamUrl(
|
|
||||||
streams.dash!!
|
|
||||||
).toUri()
|
|
||||||
} else {
|
|
||||||
// skip LBRY urls when checking whether the stream source is usable
|
|
||||||
PlayerHelper.createDashSource(streams, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
dashUri to MimeTypes.APPLICATION_MPD
|
|
||||||
}
|
|
||||||
// HLS
|
|
||||||
streams.hls != null -> {
|
|
||||||
val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory)
|
|
||||||
.setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
|
|
||||||
|
|
||||||
val mediaSource = hlsMediaSourceFactory.createMediaSource(
|
|
||||||
createMediaItem(
|
|
||||||
ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri(),
|
|
||||||
MimeTypes.APPLICATION_M3U8
|
|
||||||
)
|
|
||||||
)
|
|
||||||
exoPlayer?.setMediaSource(mediaSource)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// NO STREAM FOUND
|
|
||||||
else -> {
|
|
||||||
toastFromMainThread(R.string.unknown_error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMediaSource(uri, mimeType)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSubtitleConfigs(): List<SubtitleConfiguration> = streams.subtitles.map {
|
|
||||||
val roleFlags = getSubtitleRoleFlags(it)
|
|
||||||
SubtitleConfiguration.Builder(it.url!!.toUri())
|
|
||||||
.setRoleFlags(roleFlags)
|
|
||||||
.setLanguage(it.code)
|
|
||||||
.setMimeType(it.mimeType).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createMediaItem(uri: Uri, mimeType: String) = MediaItem.Builder()
|
|
||||||
.setUri(uri)
|
|
||||||
.setMimeType(mimeType)
|
|
||||||
.setSubtitleConfigurations(getSubtitleConfigs())
|
|
||||||
.setMetadata(streams)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun setMediaSource(uri: Uri, mimeType: String) {
|
|
||||||
val mediaItem = createMediaItem(uri, mimeType)
|
|
||||||
exoPlayer?.setMediaItem(mediaItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSubtitleRoleFlags(subtitle: Subtitle?): Int {
|
|
||||||
return if (subtitle?.autoGenerated != true) {
|
|
||||||
C.ROLE_FLAG_CAPTION
|
|
||||||
} else {
|
|
||||||
PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateCurrentSubtitle(subtitle: Subtitle?) =
|
|
||||||
trackSelector?.updateParameters {
|
|
||||||
val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0
|
|
||||||
setPreferredTextRoleFlags(roleFlags)
|
|
||||||
setPreferredTextLanguage(subtitle?.code)
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -24,7 +24,6 @@ import com.github.libretube.databinding.ActivityOfflinePlayerBinding
|
|||||||
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
|
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
|
||||||
import com.github.libretube.db.DatabaseHolder.Database
|
import com.github.libretube.db.DatabaseHolder.Database
|
||||||
import com.github.libretube.db.obj.DownloadChapter
|
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.FileType
|
||||||
import com.github.libretube.enums.PlayerEvent
|
import com.github.libretube.enums.PlayerEvent
|
||||||
import com.github.libretube.extensions.serializableExtra
|
import com.github.libretube.extensions.serializableExtra
|
||||||
@ -39,7 +38,6 @@ import com.github.libretube.ui.listeners.SeekbarPreviewListener
|
|||||||
import com.github.libretube.ui.models.ChaptersViewModel
|
import com.github.libretube.ui.models.ChaptersViewModel
|
||||||
import com.github.libretube.ui.models.CommonPlayerViewModel
|
import com.github.libretube.ui.models.CommonPlayerViewModel
|
||||||
import com.github.libretube.util.OfflineTimeFrameReceiver
|
import com.github.libretube.util.OfflineTimeFrameReceiver
|
||||||
import com.github.libretube.util.PauseableTimer
|
|
||||||
import com.github.libretube.util.PlayingQueue
|
import com.github.libretube.util.PlayingQueue
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -59,11 +57,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
|
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
|
||||||
private val chaptersViewModel: ChaptersViewModel 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 {
|
private val playerListener = object : Player.Listener {
|
||||||
override fun onEvents(player: Player, events: Player.Events) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
super.onEvents(player, events)
|
super.onEvents(player, events)
|
||||||
@ -87,13 +80,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
pipParams
|
pipParams
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start or pause watch position timer
|
|
||||||
if (isPlaying) {
|
|
||||||
watchPositionTimer.resume()
|
|
||||||
} else {
|
|
||||||
watchPositionTimer.pause()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
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)
|
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
PlayingQueue.resetToDefaults()
|
|
||||||
PlayingQueue.clear()
|
|
||||||
|
|
||||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
PlayingQueue.setOnQueueTapListener { streamItem ->
|
||||||
playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
|
playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
|
||||||
}
|
}
|
||||||
@ -180,12 +159,9 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
if (PlayerHelper.pipEnabled) {
|
if (PlayerHelper.pipEnabled) {
|
||||||
PictureInPictureCompat.setPictureInPictureParams(this, pipParams)
|
PictureInPictureCompat.setPictureInPictureParams(this, pipParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycleScope.launch { fillQueue() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playNextVideo(videoId: String) {
|
private fun playNextVideo(videoId: String) {
|
||||||
saveWatchPosition()
|
|
||||||
this.videoId = videoId
|
this.videoId = videoId
|
||||||
playVideo()
|
playVideo()
|
||||||
}
|
}
|
||||||
@ -234,29 +210,9 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let {
|
timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let {
|
||||||
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it)
|
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() {
|
override fun onResume() {
|
||||||
commonPlayerViewModel.isFullscreen.value = true
|
commonPlayerViewModel.isFullscreen.value = true
|
||||||
super.onResume()
|
super.onResume()
|
||||||
@ -272,14 +228,6 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
saveWatchPosition()
|
|
||||||
|
|
||||||
watchPositionTimer.destroy()
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
playerController.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
unregisterReceiver(playerActionReceiver)
|
unregisterReceiver(playerActionReceiver)
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ import android.os.Bundle
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.PixelCopy
|
import android.view.PixelCopy
|
||||||
@ -44,6 +45,7 @@ import androidx.fragment.app.viewModels
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.session.MediaController
|
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.IntentData
|
||||||
import com.github.libretube.constants.PreferenceKeys
|
import com.github.libretube.constants.PreferenceKeys
|
||||||
import com.github.libretube.databinding.FragmentPlayerBinding
|
import com.github.libretube.databinding.FragmentPlayerBinding
|
||||||
import com.github.libretube.db.DatabaseHelper
|
|
||||||
import com.github.libretube.db.DatabaseHolder
|
import com.github.libretube.db.DatabaseHolder
|
||||||
import com.github.libretube.enums.PlayerCommand
|
import com.github.libretube.enums.PlayerCommand
|
||||||
import com.github.libretube.enums.PlayerEvent
|
import com.github.libretube.enums.PlayerEvent
|
||||||
import com.github.libretube.enums.ShareObjectType
|
import com.github.libretube.enums.ShareObjectType
|
||||||
import com.github.libretube.extensions.formatShort
|
import com.github.libretube.extensions.formatShort
|
||||||
import com.github.libretube.extensions.parcelable
|
import com.github.libretube.extensions.parcelable
|
||||||
|
import com.github.libretube.extensions.parcelableList
|
||||||
import com.github.libretube.extensions.serializableExtra
|
import com.github.libretube.extensions.serializableExtra
|
||||||
import com.github.libretube.extensions.toID
|
import com.github.libretube.extensions.toID
|
||||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
|
||||||
import com.github.libretube.extensions.togglePlayPauseState
|
import com.github.libretube.extensions.togglePlayPauseState
|
||||||
import com.github.libretube.extensions.updateIfChanged
|
import com.github.libretube.extensions.updateIfChanged
|
||||||
import com.github.libretube.helpers.BackgroundHelper
|
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.CommentsSheet
|
||||||
import com.github.libretube.ui.sheets.StatsSheet
|
import com.github.libretube.ui.sheets.StatsSheet
|
||||||
import com.github.libretube.util.OnlineTimeFrameReceiver
|
import com.github.libretube.util.OnlineTimeFrameReceiver
|
||||||
import com.github.libretube.util.PauseableTimer
|
|
||||||
import com.github.libretube.util.PlayingQueue
|
import com.github.libretube.util.PlayingQueue
|
||||||
import com.github.libretube.util.TextUtils
|
import com.github.libretube.util.TextUtils
|
||||||
import com.github.libretube.util.TextUtils.toTimeInSeconds
|
import com.github.libretube.util.TextUtils.toTimeInSeconds
|
||||||
@ -137,13 +137,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
private lateinit var videoId: String
|
private lateinit var videoId: String
|
||||||
private var playlistId: String? = null
|
private var playlistId: String? = null
|
||||||
private var channelId: String? = null
|
private var channelId: String? = null
|
||||||
private var keepQueue = false
|
|
||||||
private var timeStamp = 0L
|
|
||||||
private var isShort = false
|
private var isShort = false
|
||||||
|
|
||||||
// data and objects stored for the player
|
// data and objects stored for the player
|
||||||
private lateinit var streams: Streams
|
private lateinit var streams: Streams
|
||||||
private var isPlayerTransitioning = true
|
|
||||||
|
|
||||||
// if null, it's been set to automatic
|
// if null, it's been set to automatic
|
||||||
private var fullscreenResolution: Int? = null
|
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 var bufferingTimeoutTask: Runnable? = null
|
||||||
|
|
||||||
private val playerListener = object : Player.Listener {
|
private val playerListener = object : Player.Listener {
|
||||||
@ -246,26 +237,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
BackgroundHelper.stopBackgroundPlay(requireContext())
|
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) {
|
if (isPlaying && PlayerHelper.sponsorBlockEnabled) {
|
||||||
handler.postDelayed(
|
handler.postDelayed(
|
||||||
this@PlayerFragment::checkForSegments,
|
this@PlayerFragment::checkForSegments,
|
||||||
100
|
100
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start or pause watch position timer
|
|
||||||
if (isPlaying) {
|
|
||||||
watchPositionTimer.resume()
|
|
||||||
} else {
|
|
||||||
watchPositionTimer.pause()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvents(player: Player, events: Player.Events) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
@ -282,8 +259,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
saveWatchPosition()
|
|
||||||
|
|
||||||
// set the playback speed to one if having reached the end of a livestream
|
// set the playback speed to one if having reached the end of a livestream
|
||||||
if (playbackState == Player.STATE_BUFFERING && binding.player.isLive &&
|
if (playbackState == Player.STATE_BUFFERING && binding.player.isLive &&
|
||||||
playerController.duration - playerController.currentPosition < 700
|
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.
|
// 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 (playbackState == Player.STATE_ENDED) {
|
||||||
if (!isPlayerTransitioning && PlayerHelper.isAutoPlayEnabled(playlistId != null)) {
|
if (PlayerHelper.isAutoPlayEnabled(playlistId != null) && PlayerHelper.autoPlayCountdown) {
|
||||||
isPlayerTransitioning = true
|
showAutoPlayCountdown()
|
||||||
if (PlayerHelper.autoPlayCountdown) {
|
|
||||||
showAutoPlayCountdown()
|
|
||||||
} else {
|
|
||||||
playNextVideo()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
binding.player.showControllerPermanently()
|
binding.player.showControllerPermanently()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playbackState == Player.STATE_READY) {
|
|
||||||
// media actually playing
|
|
||||||
isPlayerTransitioning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// listen for the stop button in the notification
|
// listen for the stop button in the notification
|
||||||
if (playbackState == PlaybackState.STATE_STOPPED && PlayerHelper.pipEnabled &&
|
if (playbackState == PlaybackState.STATE_STOPPED && PlayerHelper.pipEnabled &&
|
||||||
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
|
PictureInPictureCompat.isInPictureInPictureMode(requireActivity())
|
||||||
@ -334,6 +299,37 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
super.onPlaybackStateChanged(playbackState)
|
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
|
* Catch player errors to prevent the app from stopping
|
||||||
*/
|
*/
|
||||||
@ -369,12 +365,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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
|
// broadcast receiver for PiP actions
|
||||||
ContextCompat.registerReceiver(
|
ContextCompat.registerReceiver(
|
||||||
@ -386,11 +376,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
|
|
||||||
fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true)
|
fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true)
|
||||||
noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false)
|
noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false)
|
||||||
|
|
||||||
BackgroundHelper.startMediaService(requireContext(), VideoOnlinePlayerService::class.java, bundleOf()) {
|
|
||||||
playerController = it
|
|
||||||
playerController.addListener(playerListener)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
@ -406,11 +391,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
SoftwareKeyboardControllerCompat(view).hide()
|
SoftwareKeyboardControllerCompat(view).hide()
|
||||||
|
|
||||||
// reset the callbacks of the playing queue
|
val playerData = requireArguments().parcelable<PlayerData>(IntentData.playerData)!!
|
||||||
PlayingQueue.resetToDefaults()
|
videoId = playerData.videoId
|
||||||
|
playlistId = playerData.playlistId
|
||||||
// clear the playing queue
|
channelId = playerData.channelId
|
||||||
if (!keepQueue) PlayingQueue.clear()
|
|
||||||
|
|
||||||
changeOrientationMode()
|
changeOrientationMode()
|
||||||
|
|
||||||
@ -439,7 +423,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
// offline video playback started and thus the player fragment is no longer needed
|
// offline video playback started and thus the player fragment is no longer needed
|
||||||
killPlayerFragment()
|
killPlayerFragment()
|
||||||
} else {
|
} else {
|
||||||
playVideo()
|
attachToPlayerService(playerData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,20 +440,19 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
)
|
)
|
||||||
}.show(childFragmentManager, null)
|
}.show(childFragmentManager, null)
|
||||||
} else {
|
} else {
|
||||||
playVideo()
|
attachToPlayerService(playerData)
|
||||||
}
|
}
|
||||||
|
|
||||||
showBottomBar()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun attachToPlayerService(playerData: PlayerData) {
|
||||||
* somehow the bottom bar is invisible on low screen resolutions, this fixes it
|
BackgroundHelper.startMediaService(
|
||||||
*/
|
requireContext(),
|
||||||
private fun showBottomBar() {
|
VideoOnlinePlayerService::class.java,
|
||||||
if (_binding?.player?.isPlayerLocked == false) {
|
bundleOf(IntentData.playerData to playerData)
|
||||||
playerBinding.bottomBar.isVisible = true
|
) {
|
||||||
|
playerController = it
|
||||||
|
playerController.addListener(playerListener)
|
||||||
}
|
}
|
||||||
handler.postDelayed(this::showBottomBar, 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
@ -838,13 +821,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
playerController.pause()
|
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()
|
super.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -868,9 +844,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
saveWatchPosition()
|
|
||||||
|
|
||||||
watchPositionTimer.destroy()
|
|
||||||
handler.removeCallbacksAndMessages(null)
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
|
||||||
playerController.removeListener(playerListener)
|
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() {
|
private fun checkForSegments() {
|
||||||
if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
|
if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
|
||||||
|
|
||||||
handler.postDelayed(this::checkForSegments, 100)
|
handler.postDelayed(this::checkForSegments, 100)
|
||||||
if (!viewModel.sponsorBlockEnabled || viewModel.segments.isEmpty()) return
|
if (!PlayerHelper.sponsorBlockEnabled || viewModel.segments.isEmpty()) return
|
||||||
|
|
||||||
playerController.checkForSegments(
|
playerController.checkForSegments(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
viewModel.segments,
|
viewModel.segments,
|
||||||
viewModel.sponsorBlockConfig
|
viewModel.sponsorBlockConfig,
|
||||||
|
// skipping is done by player service
|
||||||
|
skipAutomaticallyIfEnabled = false
|
||||||
)
|
)
|
||||||
?.let { segment ->
|
?.let { segment ->
|
||||||
if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let
|
if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let
|
||||||
|
|
||||||
binding.sbSkipBtn.isVisible = true
|
binding.sbSkipBtn.isVisible = true
|
||||||
binding.sbSkipBtn.setOnClickListener {
|
binding.sbSkipBtn.setOnClickListener {
|
||||||
playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
|
playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
|
||||||
@ -954,8 +923,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone =
|
|
||||||
true
|
if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playVideo() {
|
private fun playVideo() {
|
||||||
@ -966,103 +935,49 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
// reset the comments to become reloaded later
|
// reset the comments to become reloaded later
|
||||||
commentsViewModel.reset()
|
commentsViewModel.reset()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
// hide the button to skip SponsorBlock segments manually
|
||||||
viewModel.fetchVideoInfo(requireContext(), videoId).let { (streams, errorMessage) ->
|
binding.sbSkipBtn.isGone = true
|
||||||
if (errorMessage != null) {
|
|
||||||
context?.toastFromMainDispatcher(errorMessage, Toast.LENGTH_LONG)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
this@PlayerFragment.streams = streams!!
|
// use the video's default audio track when starting playback
|
||||||
playerController.sendCustomCommand(
|
playerController.sendCustomCommand(
|
||||||
AbstractPlayerService.startServiceCommand,
|
AbstractPlayerService.runPlayerActionCommand, bundleOf(
|
||||||
bundleOf(IntentData.streams to streams)
|
PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
|
||||||
val isFirstVideo = PlayingQueue.isEmpty()
|
// set the default subtitle if available
|
||||||
if (isFirstVideo) {
|
updateCurrentSubtitle(viewModel.currentSubtitle)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
val videoStream = streams.videoStreams.firstOrNull()
|
// set media source and resolution in the beginning
|
||||||
isShort = PlayingQueue.getCurrent()?.isShort == true ||
|
updateResolution(commonPlayerViewModel.isFullscreen.value == true)
|
||||||
(videoStream?.height ?: 0) > (videoStream?.width ?: 0)
|
|
||||||
|
|
||||||
PlayingQueue.setOnQueueTapListener { streamItem ->
|
if (PreferenceHelper.getBoolean(PreferenceKeys.AUTO_FULLSCREEN_SHORTS, false) &&
|
||||||
streamItem.url?.toID()?.let { playNextVideo(it) }
|
isShort && binding.playerMotionLayout.progress == 0f
|
||||||
}
|
) {
|
||||||
|
setFullscreen()
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchSponsorBlockSegments() {
|
binding.player.apply {
|
||||||
viewModel.sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
useController = false
|
||||||
|
player = playerController
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
viewModel.segments.firstOrNull { it.category == PlayerHelper.SPONSOR_HIGHLIGHT_CATEGORY }
|
|
||||||
?.let {
|
initializePlayerView()
|
||||||
initializeHighlight(it)
|
|
||||||
|
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
|
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) && nextId == null) return
|
||||||
|
|
||||||
// save the current watch position before starting the next video
|
|
||||||
saveWatchPosition()
|
|
||||||
|
|
||||||
videoId = nextId ?: PlayingQueue.getNext() ?: return
|
videoId = nextId ?: PlayingQueue.getNext() ?: return
|
||||||
isPlayerTransitioning = true
|
|
||||||
|
|
||||||
// fix: if the fragment is recreated, play the current video, and not the initial one
|
// fix: if the fragment is recreated, play the current video, and not the initial one
|
||||||
arguments?.run {
|
arguments?.run {
|
||||||
val playerData = parcelable<PlayerData>(IntentData.playerData)!!.copy(videoId = videoId)
|
val playerData = parcelable<PlayerData>(IntentData.playerData)!!.copy(videoId = videoId)
|
||||||
putParcelable(IntentData.playerData, playerData)
|
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
|
// start to play the next video
|
||||||
@ -1245,37 +1153,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
|
|||||||
return resolutions.toList()
|
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) {
|
private fun setPlayerResolution(resolution: Int, isSelectedByUser: Boolean = false) {
|
||||||
val transformedResolution = if (!isSelectedByUser && isShort) {
|
val transformedResolution = if (!isSelectedByUser && isShort) {
|
||||||
ceil(resolution * 16.0 / 9.0).toInt()
|
ceil(resolution * 16.0 / 9.0).toInt()
|
||||||
|
@ -1,27 +1,14 @@
|
|||||||
package com.github.libretube.ui.models
|
package com.github.libretube.ui.models
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
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.Segment
|
||||||
import com.github.libretube.api.obj.Streams
|
|
||||||
import com.github.libretube.api.obj.Subtitle
|
import com.github.libretube.api.obj.Subtitle
|
||||||
import com.github.libretube.helpers.PlayerHelper
|
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
|
@UnstableApi
|
||||||
class PlayerViewModel : ViewModel() {
|
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 segments = listOf<Segment>()
|
||||||
var currentSubtitle = Subtitle(code = PlayerHelper.defaultSubtitleCode)
|
var currentSubtitle = Subtitle(code = PlayerHelper.defaultSubtitleCode)
|
||||||
var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
|
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
|
* Set to true if the activity will be recreated due to an orientation change
|
||||||
*/
|
*/
|
||||||
var isOrientationChangeInProgress = false
|
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.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
import com.github.libretube.R
|
import com.github.libretube.R
|
||||||
import com.github.libretube.constants.IntentData
|
import com.github.libretube.constants.IntentData
|
||||||
import com.github.libretube.constants.PreferenceKeys
|
import com.github.libretube.constants.PreferenceKeys
|
||||||
|
import com.github.libretube.enums.PlayerCommand
|
||||||
import com.github.libretube.extensions.toID
|
import com.github.libretube.extensions.toID
|
||||||
import com.github.libretube.helpers.PlayerHelper
|
import com.github.libretube.helpers.PlayerHelper
|
||||||
import com.github.libretube.helpers.PreferenceHelper
|
import com.github.libretube.helpers.PreferenceHelper
|
||||||
import com.github.libretube.helpers.WindowHelper
|
import com.github.libretube.helpers.WindowHelper
|
||||||
import com.github.libretube.obj.BottomSheetItem
|
import com.github.libretube.obj.BottomSheetItem
|
||||||
|
import com.github.libretube.services.AbstractPlayerService
|
||||||
import com.github.libretube.ui.base.BaseActivity
|
import com.github.libretube.ui.base.BaseActivity
|
||||||
import com.github.libretube.ui.dialogs.SubmitDeArrowDialog
|
import com.github.libretube.ui.dialogs.SubmitDeArrowDialog
|
||||||
import com.github.libretube.ui.dialogs.SubmitSegmentDialog
|
import com.github.libretube.ui.dialogs.SubmitSegmentDialog
|
||||||
@ -49,39 +52,43 @@ class OnlinePlayerView(
|
|||||||
var currentWindow: Window? = null
|
var currentWindow: Window? = null
|
||||||
|
|
||||||
var selectedResolution: Int? = null
|
var selectedResolution: Int? = null
|
||||||
|
private var sponsorBlockAutoSkip = true
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
override fun getOptionsMenuItems(): List<BottomSheetItem> {
|
override fun getOptionsMenuItems(): List<BottomSheetItem> {
|
||||||
return super.getOptionsMenuItems() +
|
return super.getOptionsMenuItems() +
|
||||||
listOf(
|
listOf(
|
||||||
BottomSheetItem(
|
BottomSheetItem(
|
||||||
context.getString(R.string.quality),
|
context.getString(R.string.quality),
|
||||||
R.drawable.ic_hd,
|
R.drawable.ic_hd,
|
||||||
this::getCurrentResolutionSummary
|
this::getCurrentResolutionSummary
|
||||||
) {
|
) {
|
||||||
playerOptions?.onQualityClicked()
|
playerOptions?.onQualityClicked()
|
||||||
},
|
},
|
||||||
BottomSheetItem(
|
BottomSheetItem(
|
||||||
context.getString(R.string.audio_track),
|
context.getString(R.string.audio_track),
|
||||||
R.drawable.ic_audio,
|
R.drawable.ic_audio,
|
||||||
this::getCurrentAudioTrackTitle
|
this::getCurrentAudioTrackTitle
|
||||||
) {
|
) {
|
||||||
playerOptions?.onAudioStreamClicked()
|
playerOptions?.onAudioStreamClicked()
|
||||||
},
|
},
|
||||||
BottomSheetItem(
|
BottomSheetItem(
|
||||||
context.getString(R.string.captions),
|
context.getString(R.string.captions),
|
||||||
R.drawable.ic_caption,
|
R.drawable.ic_caption,
|
||||||
{ playerViewModel?.currentSubtitle?.code ?: context.getString(R.string.none) }
|
{
|
||||||
) {
|
playerViewModel?.currentSubtitle?.code
|
||||||
playerOptions?.onCaptionsClicked()
|
?: context.getString(R.string.none)
|
||||||
},
|
}
|
||||||
BottomSheetItem(
|
) {
|
||||||
context.getString(R.string.stats_for_nerds),
|
playerOptions?.onCaptionsClicked()
|
||||||
R.drawable.ic_info
|
},
|
||||||
) {
|
BottomSheetItem(
|
||||||
playerOptions?.onStatsClicked()
|
context.getString(R.string.stats_for_nerds),
|
||||||
}
|
R.drawable.ic_info
|
||||||
)
|
) {
|
||||||
|
playerOptions?.onStatsClicked()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
@ -153,7 +160,8 @@ class OnlinePlayerView(
|
|||||||
updateTopBarMargin()
|
updateTopBarMargin()
|
||||||
|
|
||||||
binding.fullscreen.isInvisible = PlayerHelper.autoFullscreenEnabled
|
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.fullscreen.setImageResource(fullscreenDrawable)
|
||||||
|
|
||||||
binding.exoTitle.isInvisible = !isFullscreen
|
binding.exoTitle.isInvisible = !isFullscreen
|
||||||
@ -161,25 +169,32 @@ class OnlinePlayerView(
|
|||||||
|
|
||||||
val updateSbImageResource = {
|
val updateSbImageResource = {
|
||||||
binding.sbToggle.setImageResource(
|
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()
|
updateSbImageResource()
|
||||||
binding.sbToggle.setOnClickListener {
|
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()
|
updateSbImageResource()
|
||||||
}
|
}
|
||||||
|
|
||||||
syncQueueButtons()
|
syncQueueButtons()
|
||||||
|
|
||||||
binding.sbSubmit.isVisible = PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false)
|
binding.sbSubmit.isVisible =
|
||||||
|
PreferenceHelper.getBoolean(PreferenceKeys.CONTRIBUTE_TO_SB, false)
|
||||||
binding.sbSubmit.setOnClickListener {
|
binding.sbSubmit.setOnClickListener {
|
||||||
val submitSegmentDialog = SubmitSegmentDialog()
|
val submitSegmentDialog = SubmitSegmentDialog()
|
||||||
submitSegmentDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener
|
submitSegmentDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener
|
||||||
submitSegmentDialog.show((context as BaseActivity).supportFragmentManager, null)
|
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 {
|
binding.dearrowSubmit.setOnClickListener {
|
||||||
val submitDialog = SubmitDeArrowDialog()
|
val submitDialog = SubmitDeArrowDialog()
|
||||||
submitDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener
|
submitDialog.arguments = buildSbBundleArgs() ?: return@setOnClickListener
|
||||||
|
Loading…
x
Reference in New Issue
Block a user