Skip to content

Commit 04cd435

Browse files
committed
feat(offline player): support for captions, SponsorBlock, chapters and video frame preview
1 parent 51af8b4 commit 04cd435

10 files changed

Lines changed: 164 additions & 108 deletions

File tree

app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ interface DownloadDao {
2323
@Query("SELECT * FROM download")
2424
suspend fun getAll(): List<DownloadWithItems>
2525

26+
@Transaction
27+
@Query("SELECT * FROM download WHERE videoId = :videoId")
28+
suspend fun getDownloadById(videoId: String): DownloadWithItems?
29+
2630
@Transaction
2731
@Query("SELECT * FROM download WHERE videoId = :videoId")
2832
suspend fun findById(videoId: String): DownloadWithItems?

app/src/main/java/com/github/libretube/db/obj/DownloadSponsorBlockSegment.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.github.libretube.db.obj
33
import androidx.room.Entity
44
import androidx.room.ForeignKey
55
import androidx.room.PrimaryKey
6+
import com.github.libretube.api.obj.Segment
67

78
@Entity(
89
tableName = "downloadSponsorBlockSegment",
@@ -28,4 +29,15 @@ data class DownloadSponsorBlockSegment(
2829
val endTime: Float,
2930
val videoDuration: Float,
3031
val votes: Int,
31-
)
32+
) {
33+
fun toSegment(): Segment = Segment(
34+
uuid = uuid,
35+
segment = listOf(startTime, endTime),
36+
actionType = actionType,
37+
category = category,
38+
description = description,
39+
locked = locked,
40+
videoDuration = videoDuration.toDouble(),
41+
votes = votes
42+
)
43+
}

app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ data class DownloadWithItems(
3535
uploaded = download.uploadDate?.toMillis(),
3636
duration = download.duration ?: 0,
3737
uploaderUrl = null,
38-
uploaderVerified = false
38+
uploaderVerified = false,
39+
chapters = downloadChapters.map { it.toChapterSegment() }
3940
)
4041
}
4142

app/src/main/java/com/github/libretube/enums/PlayerCommand.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ enum class PlayerCommand {
66
SET_AUDIO_ROLE_FLAGS,
77
SET_RESOLUTION,
88
SET_AUDIO_LANGUAGE,
9-
SET_SUBTITLE,
9+
SET_CAPTION_TRACK,
1010
SET_SB_AUTO_SKIP_ENABLED,
1111
PLAY_VIDEO_BY_ID,
1212
SET_AUTOPLAY_COUNTDOWN_ENABLED,

app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import androidx.media3.common.ForwardingPlayer
1515
import androidx.media3.common.MediaMetadata
1616
import androidx.media3.common.PlaybackException
1717
import androidx.media3.common.Player
18+
import androidx.media3.common.util.Log
1819
import androidx.media3.common.util.UnstableApi
1920
import androidx.media3.exoplayer.ExoPlayer
2021
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
@@ -23,16 +24,18 @@ import androidx.media3.session.MediaLibraryService.MediaLibrarySession
2324
import androidx.media3.session.MediaSession
2425
import androidx.media3.session.SessionCommand
2526
import androidx.media3.session.SessionResult
26-
import com.github.libretube.api.obj.Subtitle
27+
import com.github.libretube.R
28+
import com.github.libretube.api.JsonHelper
29+
import com.github.libretube.api.obj.Segment
2730
import com.github.libretube.constants.IntentData
2831
import com.github.libretube.enums.PlayerCommand
2932
import com.github.libretube.enums.PlayerEvent
30-
import com.github.libretube.extensions.parcelable
33+
import com.github.libretube.enums.SbSkipOptions
3134
import com.github.libretube.extensions.parcelableExtra
3235
import com.github.libretube.extensions.toastFromMainThread
3336
import com.github.libretube.extensions.updateParameters
3437
import com.github.libretube.helpers.PlayerHelper
35-
import com.github.libretube.helpers.PlayerHelper.getSubtitleRoleFlags
38+
import com.github.libretube.helpers.PlayerHelper.getCurrentSegment
3639
import com.github.libretube.ui.activities.MainActivity
3740
import com.github.libretube.util.DefaultTrackSelectorWithAudioQualitySupport
3841
import com.github.libretube.util.NowPlayingNotification
@@ -65,6 +68,18 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
6568
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
6669
)
6770

71+
// SponsorBlock Segment data
72+
private var sponsorBlockAutoSkip = true
73+
protected val sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
74+
private var sponsorBlockSegments = listOf<Segment>()
75+
76+
/**
77+
* Whether the service should automatically play the next video after the current video finished.
78+
*
79+
* If set to `false`, the player UI views have to handle autoplay themselves.
80+
*/
81+
protected var shouldHandleAutoplay = true
82+
6883
private val playerListener = object : Player.Listener {
6984
override fun onIsPlayingChanged(isPlaying: Boolean) {
7085
super.onIsPlayingChanged(isPlaying)
@@ -162,13 +177,15 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
162177
}
163178
}
164179

165-
args.containsKey(PlayerCommand.SET_SUBTITLE.name) -> {
166-
val subtitle: Subtitle? = args.parcelable(PlayerCommand.SET_SUBTITLE.name)
180+
args.containsKey(PlayerCommand.SET_CAPTION_TRACK.name) -> {
181+
val exoPlayer = exoPlayer ?: return
182+
183+
val captionId = args.getString(PlayerCommand.SET_CAPTION_TRACK.name) ?: return
184+
val caption = PlayerHelper.getCaptionTracks(exoPlayer).firstOrNull { it.id == captionId }
167185

168186
trackSelector?.updateParameters {
169-
val roleFlags = getSubtitleRoleFlags(subtitle)
170-
setPreferredTextRoleFlags(roleFlags)
171-
setPreferredTextLanguage(subtitle?.code)
187+
caption?.roleFlags?.let { setPreferredTextRoleFlags(it) }
188+
setPreferredTextLanguage(caption?.language)
172189
}
173190
}
174191

@@ -183,6 +200,15 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
183200
}
184201
updateNotification()
185202
}
203+
204+
args.containsKey(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name) -> {
205+
sponsorBlockAutoSkip = args.getBoolean(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name)
206+
}
207+
208+
args.containsKey(PlayerCommand.SET_AUTOPLAY_COUNTDOWN_ENABLED.name) -> {
209+
shouldHandleAutoplay =
210+
!args.getBoolean(PlayerCommand.SET_AUTOPLAY_COUNTDOWN_ENABLED.name)
211+
}
186212
}
187213
}
188214

@@ -193,6 +219,8 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
193219
*/
194220
@CallSuper
195221
open fun navigateVideo(videoId: String) {
222+
sponsorBlockSegments = emptyList()
223+
196224
updatePlaylistMetadata {
197225
setExtras(bundleOf(IntentData.videoId to videoId))
198226
}
@@ -206,6 +234,39 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
206234
}
207235
}
208236

237+
protected fun setSponsorBlockSegments(segments: List<Segment>) {
238+
sponsorBlockSegments = segments
239+
if (!PlayerHelper.sponsorBlockEnabled) return
240+
241+
updatePlaylistMetadata {
242+
// JSON-encode as work-around for https://github.com/androidx/media/issues/564
243+
val segments = JsonHelper.json.encodeToString(sponsorBlockSegments)
244+
setExtras(bundleOf(IntentData.segments to segments))
245+
}
246+
247+
Log.e("segments", sponsorBlockSegments.toString())
248+
checkForSegments()
249+
}
250+
251+
/**
252+
* check for SponsorBlock segments
253+
*/
254+
private fun checkForSegments() {
255+
handler.postDelayed(this::checkForSegments, 100)
256+
257+
val (currentSegment, sbSkipOption) = exoPlayer?.getCurrentSegment(
258+
sponsorBlockSegments,
259+
sponsorBlockConfig
260+
) ?: return
261+
262+
if (sbSkipOption in arrayOf(SbSkipOptions.AUTOMATIC, SbSkipOptions.AUTOMATIC_ONCE) && sponsorBlockAutoSkip) {
263+
exoPlayer?.seekTo(currentSegment.segmentStartAndEnd.second.toLong() * 1000)
264+
currentSegment.skipped = true
265+
266+
if (PlayerHelper.sponsorBlockNotifications) toastFromMainThread(R.string.segment_skipped)
267+
}
268+
}
269+
209270
protected fun updatePlaylistMetadata(updateAction: MediaMetadata.Builder.() -> Unit) {
210271
handler.post {
211272
exoPlayer?.playlistMetadata = MediaMetadata.Builder()
@@ -435,12 +496,12 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
435496

436497
override fun getAvailableCommands(): Player.Commands {
437498
return super.getAvailableCommands().buildUpon()
438-
.addAll(Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_NEXT)
499+
.addAll(COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_NEXT)
439500
.build()
440501
}
441502

442503
override fun isCommandAvailable(command: Int): Boolean {
443-
if (command == Player.COMMAND_SEEK_TO_NEXT || command == Player.COMMAND_SEEK_TO_PREVIOUS) return true
504+
if (command == COMMAND_SEEK_TO_NEXT || command == COMMAND_SEEK_TO_PREVIOUS) return true
444505

445506
return super.isCommandAvailable(command)
446507
}

app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ open class OfflinePlayerService : AbstractPlayerService() {
125125
PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem())
126126

127127
withContext(Dispatchers.Main) {
128+
setSponsorBlockSegments(
129+
downloadWithItems.downloadSponsorBlockSegments.map { it.toSegment() }
130+
)
131+
128132
setMediaItem(downloadWithItems)
129133
exoPlayer?.playWhenReady = PlayerHelper.playAutomatically
130134
exoPlayer?.prepare()
@@ -225,7 +229,7 @@ open class OfflinePlayerService : AbstractPlayerService() {
225229
private fun playNextVideo(videoId: String? = null) {
226230
if (PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
227231
exoPlayer?.seekTo(0)
228-
} else if (PlayerHelper.isAutoPlayEnabled()) {
232+
} else if (PlayerHelper.isAutoPlayEnabled() && shouldHandleAutoplay) {
229233
val nextId = videoId ?: PlayingQueue.getNext() ?: return
230234
navigateVideo(nextId)
231235
}

app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt

Lines changed: 10 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import android.net.Uri
44
import android.os.Bundle
55
import android.util.Log
66
import androidx.core.net.toUri
7-
import androidx.core.os.bundleOf
87
import androidx.media3.common.C
98
import androidx.media3.common.MediaItem
109
import androidx.media3.common.MediaItem.SubtitleConfiguration
@@ -13,23 +12,19 @@ import androidx.media3.common.Player
1312
import androidx.media3.datasource.DefaultDataSource
1413
import androidx.media3.exoplayer.hls.HlsMediaSource
1514
import com.github.libretube.R
16-
import com.github.libretube.api.JsonHelper
1715
import com.github.libretube.api.MediaServiceRepository
1816
import com.github.libretube.api.SubscriptionHelper
1917
import com.github.libretube.api.obj.Segment
2018
import com.github.libretube.api.obj.Streams
2119
import com.github.libretube.constants.IntentData
2220
import com.github.libretube.db.DatabaseHelper
23-
import com.github.libretube.enums.PlayerCommand
24-
import com.github.libretube.enums.SbSkipOptions
2521
import com.github.libretube.extensions.TAG
2622
import com.github.libretube.extensions.parcelable
2723
import com.github.libretube.extensions.setMetadata
2824
import com.github.libretube.extensions.toastFromMainDispatcher
2925
import com.github.libretube.extensions.toastFromMainThread
3026
import com.github.libretube.extensions.updateParameters
3127
import com.github.libretube.helpers.PlayerHelper
32-
import com.github.libretube.helpers.PlayerHelper.getCurrentSegment
3328
import com.github.libretube.helpers.PlayerHelper.getSubtitleRoleFlags
3429
import com.github.libretube.helpers.ProxyHelper
3530
import com.github.libretube.parcelable.PlayerData
@@ -42,7 +37,6 @@ import kotlinx.coroutines.Job
4237
import kotlinx.coroutines.cancelAndJoin
4338
import kotlinx.coroutines.launch
4439
import kotlinx.coroutines.withContext
45-
import kotlinx.serialization.encodeToString
4640

4741
/**
4842
* Loads the selected videos audio in background mode with a notification area.
@@ -61,13 +55,6 @@ open class OnlinePlayerService : AbstractPlayerService() {
6155
*/
6256
private var streams: Streams? = null
6357

64-
// SponsorBlock Segment data
65-
private var sponsorBlockAutoSkip = true
66-
private var sponsorBlockSegments = listOf<Segment>()
67-
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
68-
69-
private var autoPlayCountdownEnabled = false
70-
7158
private val scope = CoroutineScope(Dispatchers.IO)
7259

7360
/*
@@ -163,6 +150,11 @@ open class OnlinePlayerService : AbstractPlayerService() {
163150
SubscriptionHelper.submitFeedItemChange(it.toFeedItem())
164151
}
165152

153+
launch {
154+
val segments = getSponsorBlockSegments()
155+
withContext(Dispatchers.Main) { setSponsorBlockSegments(segments) }
156+
}
157+
166158
withContext(Dispatchers.Main) {
167159
setStreamSource()
168160
configurePlayer(timestampMs)
@@ -187,8 +179,6 @@ open class OnlinePlayerService : AbstractPlayerService() {
187179
playWhenReady = PlayerHelper.playAutomatically
188180
prepare()
189181
}
190-
191-
if (PlayerHelper.sponsorBlockEnabled) fetchSponsorBlockSegments()
192182
}
193183

194184
/**
@@ -201,7 +191,7 @@ open class OnlinePlayerService : AbstractPlayerService() {
201191
return
202192
}
203193

204-
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) || autoPlayCountdownEnabled) return
194+
if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) || !shouldHandleAutoplay) return
205195
}
206196

207197
val nextVideo = nextId ?: PlayingQueue.getNext() ?: return
@@ -210,64 +200,18 @@ open class OnlinePlayerService : AbstractPlayerService() {
210200
navigateVideo(nextVideo)
211201
}
212202

213-
/**
214-
* fetch the segments for SponsorBlock
215-
*/
216-
private fun fetchSponsorBlockSegments() = scope.launch(Dispatchers.IO) {
217-
runCatching {
218-
if (sponsorBlockConfig.isEmpty()) return@runCatching
219-
sponsorBlockSegments = MediaServiceRepository.instance.getSegments(
203+
private suspend fun getSponsorBlockSegments(): List<Segment> {
204+
return runCatching {
205+
MediaServiceRepository.instance.getSegments(
220206
videoId,
221207
sponsorBlockConfig.keys.toList(),
222208
listOf("skip", "mute", "full", "poi", "chapter")
223209
).segments
224-
225-
withContext(Dispatchers.Main) {
226-
updatePlaylistMetadata {
227-
// JSON-encode as work-around for https://github.com/androidx/media/issues/564
228-
val segments = JsonHelper.json.encodeToString(sponsorBlockSegments)
229-
setExtras(bundleOf(IntentData.segments to segments))
230-
}
231-
232-
checkForSegments()
233-
}
234-
}
235-
}
236-
237-
238-
/**
239-
* check for SponsorBlock segments
240-
*/
241-
private fun checkForSegments() {
242-
handler.postDelayed(this::checkForSegments, 100)
243-
244-
val (currentSegment, sbSkipOption) = exoPlayer?.getCurrentSegment(
245-
sponsorBlockSegments,
246-
sponsorBlockConfig
247-
) ?: return
248-
249-
if (sbSkipOption in arrayOf(SbSkipOptions.AUTOMATIC, SbSkipOptions.AUTOMATIC_ONCE) && sponsorBlockAutoSkip) {
250-
exoPlayer?.seekTo(currentSegment.segmentStartAndEnd.second.toLong() * 1000)
251-
currentSegment.skipped = true
252-
253-
if (PlayerHelper.sponsorBlockNotifications) toastFromMainThread(R.string.segment_skipped)
254-
}
255-
}
256-
257-
override fun runPlayerCommand(args: Bundle) {
258-
super.runPlayerCommand(args)
259-
260-
if (args.containsKey(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name)) {
261-
sponsorBlockAutoSkip = args.getBoolean(PlayerCommand.SET_SB_AUTO_SKIP_ENABLED.name)
262-
} else if (args.containsKey(PlayerCommand.SET_AUTOPLAY_COUNTDOWN_ENABLED.name)) {
263-
autoPlayCountdownEnabled =
264-
args.getBoolean(PlayerCommand.SET_AUTOPLAY_COUNTDOWN_ENABLED.name)
265-
}
210+
}.getOrElse { emptyList() }
266211
}
267212

268213
override fun navigateVideo(videoId: String) {
269214
this.streams = null
270-
this.sponsorBlockSegments = emptyList()
271215

272216
super.navigateVideo(videoId)
273217
}

0 commit comments

Comments
 (0)