feat: chapters support for downloaded videos

This commit is contained in:
Bnyro 2024-04-06 15:22:57 +02:00
parent f889b36e6c
commit f35ea239d5
15 changed files with 717 additions and 75 deletions

View File

@ -0,0 +1,577 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "e855c945119df154adaf1ecfe9e3b646",
"entities": [
{
"tableName": "watchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, `isShort` INTEGER NOT NULL, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isShort",
"columnName": "isShort",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "watchPosition",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "searchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))",
"fields": [
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"query"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "customInstance",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "apiUrl",
"columnName": "apiUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "frontendUrl",
"columnName": "frontendUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "localSubscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))",
"fields": [
{
"fieldPath": "channelId",
"columnName": "channelId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"channelId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "playlistBookmark",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `videos` INTEGER NOT NULL, PRIMARY KEY(`playlistId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistName",
"columnName": "playlistName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "videos",
"columnName": "videos",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `description` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylistItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `uploader` TEXT NOT NULL, `duration` INTEGER DEFAULT NULL, `uploadDate` TEXT, `thumbnailPath` TEXT, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailPath",
"columnName": "thumbnailPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "downloadItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `videoId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `path` TEXT NOT NULL, `url` TEXT, `format` TEXT, `quality` TEXT, `language` TEXT, `downloadSize` INTEGER NOT NULL, FOREIGN KEY(`videoId`) REFERENCES `download`(`videoId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "format",
"columnName": "format",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "quality",
"columnName": "quality",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "downloadSize",
"columnName": "downloadSize",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_downloadItem_path",
"unique": true,
"columnNames": [
"path"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloadItem_path` ON `${TABLE_NAME}` (`path`)"
}
],
"foreignKeys": [
{
"table": "download",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"videoId"
],
"referencedColumns": [
"videoId"
]
}
]
},
{
"tableName": "downloadChapters",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoId` TEXT NOT NULL, `name` TEXT NOT NULL, `start` INTEGER NOT NULL, `thumbnailUrl` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "subscriptionGroups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `channels` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "channels",
"columnName": "channels",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e855c945119df154adaf1ecfe9e3b646')"
]
}
}

View File

@ -15,6 +15,7 @@ import com.github.libretube.db.dao.WatchHistoryDao
import com.github.libretube.db.dao.WatchPositionDao
import com.github.libretube.db.obj.CustomInstance
import com.github.libretube.db.obj.Download
import com.github.libretube.db.obj.DownloadChapter
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.db.obj.LocalPlaylist
import com.github.libretube.db.obj.LocalPlaylistItem
@ -37,9 +38,10 @@ import com.github.libretube.db.obj.WatchPosition
LocalPlaylistItem::class,
Download::class,
DownloadItem::class,
DownloadChapter::class,
SubscriptionGroup::class
],
version = 17,
version = 18,
autoMigrations = [
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),

View File

@ -44,6 +44,18 @@ object DatabaseHolder {
}
}
private val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE 'downloadChapters' (" +
"id INTEGER PRIMARY KEY NOT NULL, " +
"videoId TEXT NOT NULL, " +
"name TEXT NOT NULL, " +
"start INTEGER NOT NULL, " +
"thumbnailUrl TEXT NOT NULL" +
")")
}
}
val Database by lazy {
Room.databaseBuilder(LibreTubeApp.instance, AppDatabase::class.java, DATABASE_NAME)
.addMigrations(
@ -51,7 +63,8 @@ object DatabaseHolder {
MIGRATION_12_13,
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16
MIGRATION_15_16,
MIGRATION_17_18
)
.fallbackToDestructiveMigration()
.build()

View File

@ -8,6 +8,7 @@ import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.github.libretube.db.obj.Download
import com.github.libretube.db.obj.DownloadChapter
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.db.obj.DownloadWithItems
@ -30,6 +31,9 @@ interface DownloadDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertDownload(download: Download)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertDownloadChapter(downloadChapter: DownloadChapter)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDownloadItem(downloadItem: DownloadItem): Long

View File

@ -0,0 +1,18 @@
package com.github.libretube.db.obj
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.github.libretube.api.obj.ChapterSegment
@Entity(tableName = "downloadChapters")
data class DownloadChapter(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val videoId: String,
val name: String,
val start: Long,
val thumbnailUrl: String
) {
fun toChapterSegment(): ChapterSegment {
return ChapterSegment(name, thumbnailUrl, start)
}
}

View File

@ -9,5 +9,10 @@ data class DownloadWithItems(
parentColumn = "videoId",
entityColumn = "videoId"
)
val downloadItems: List<DownloadItem>
val downloadItems: List<DownloadItem>,
@Relation(
parentColumn = "videoId",
entityColumn = "videoId"
)
val downloadChapters: List<DownloadChapter> = emptyList()
)

View File

@ -23,6 +23,7 @@ import com.github.libretube.api.RetrofitInstance
import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.Download
import com.github.libretube.db.obj.DownloadChapter
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.enums.FileType
import com.github.libretube.enums.NotificationId
@ -124,6 +125,15 @@ class DownloadService : LifecycleService() {
thumbnailTargetPath
)
Database.downloadDao().insertDownload(download)
for (chapter in streams.chapters) {
val downloadChapter = DownloadChapter(
videoId = videoId,
name = chapter.title,
start = chapter.start,
thumbnailUrl = chapter.image
)
Database.downloadDao().insertDownloadChapter(downloadChapter)
}
ImageHelper.downloadImage(
this@DownloadService,
streams.thumbnailUrl,

View File

@ -28,6 +28,7 @@ import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.ActivityOfflinePlayerBinding
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.DownloadChapter
import com.github.libretube.enums.FileType
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.extensions.updateParameters
@ -123,6 +124,7 @@ class OfflinePlayerActivity : BaseActivity() {
player = PlayerHelper.createPlayer(this, trackSelector, false)
player.setWakeMode(C.WAKE_MODE_LOCAL)
player.addListener(playerListener)
playerViewModel.player = player
playerView = binding.player
playerView.setShowSubtitleButton(true)
@ -146,6 +148,10 @@ class OfflinePlayerActivity : BaseActivity() {
val downloadInfo = withContext(Dispatchers.IO) {
Database.downloadDao().findById(videoId)
}
val chapters = downloadInfo.downloadChapters.map(DownloadChapter::toChapterSegment)
playerViewModel.chaptersLiveData.value = chapters
binding.player.setChapters(chapters)
val downloadFiles = downloadInfo.downloadItems.filter { it.path.exists() }
playerBinding.exoTitle.text = downloadInfo.download.title
playerBinding.exoTitle.isVisible = true
@ -247,6 +253,7 @@ class OfflinePlayerActivity : BaseActivity() {
override fun onDestroy() {
saveWatchPosition()
playerViewModel.player = null
player.release()
watchPositionTimer.destroy()

View File

@ -102,7 +102,6 @@ import com.github.libretube.ui.listeners.SeekbarPreviewListener
import com.github.libretube.ui.models.CommentsViewModel
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.sheets.BaseBottomSheet
import com.github.libretube.ui.sheets.ChaptersBottomSheet
import com.github.libretube.ui.sheets.CommentsSheet
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.ui.sheets.StatsSheet
@ -166,8 +165,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
private val handler = Handler(Looper.getMainLooper())
private var seekBarPreviewListener: SeekbarPreviewListener? = null
private var scrubbingTimeBar = false
private var chaptersBottomSheet: ChaptersBottomSheet? = null
// True when the video was closed through the close button on PiP mode
private var closedVideo = false
@ -441,7 +438,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
disableController()
commentsViewModel.setCommentSheetExpand(false)
chaptersBottomSheet?.dismiss()
transitionEndId = endId
transitionStartId = startId
}
@ -971,21 +967,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// show the player notification
initializePlayerNotification()
// enable the chapters dialog in the player
playerBinding.chapterName.setOnClickListener {
updateMaxSheetHeight()
val sheet =
chaptersBottomSheet ?: ChaptersBottomSheet().also {
chaptersBottomSheet = it
}
if (sheet.isVisible) {
sheet.dismiss()
} else {
sheet.show(childFragmentManager)
}
}
setCurrentChapterName()
binding.player.setCurrentChapterName()
fetchSponsorBlockSegments()
@ -1048,9 +1030,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// close comment bottom sheet if opened for next video
runCatching { commentsViewModel.commentsSheetDismiss?.invoke() }
// kill the chapters bottom sheet if opened
runCatching { chaptersBottomSheet?.dismiss() }
chaptersBottomSheet = null
}
@SuppressLint("SetTextI18n")
@ -1202,36 +1181,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
)
withContext(Dispatchers.Main) {
setCurrentChapterName()
}
}
// set the name of the video chapter in the exoPlayerView
private fun setCurrentChapterName(forceUpdate: Boolean = false, enqueueNew: Boolean = true) {
// return if fragment view got killed already to avoid crashes
if (_binding == null) return
// only show the chapters layout if there are some chapters available
playerBinding.chapterName.isInvisible = viewModel.chapters.isEmpty()
// the following logic to set the chapter title can be skipped if no chapters are available
if (viewModel.chapters.isEmpty()) return
// call the function again in 100ms
if (enqueueNew) binding.player.postDelayed(this::setCurrentChapterName, 100)
// if the user is scrubbing the time bar, don't update
if (scrubbingTimeBar && !forceUpdate) return
val chapterName =
PlayerHelper.getCurrentChapterIndex(exoPlayer.currentPosition, viewModel.chapters)
?.let {
viewModel.chapters[it].title.trim()
} ?: getString(R.string.no_chapter)
// change the chapter name textView text to the chapterName
if (chapterName != playerBinding.chapterName.text) {
playerBinding.chapterName.text = chapterName
binding.player.setCurrentChapterName()
}
}
@ -1608,15 +1558,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
return SeekbarPreviewListener(
OnlineTimeFrameReceiver(requireContext(), streams.previewFrames),
playerBinding,
streams.duration * 1000,
onScrub = {
setCurrentChapterName(forceUpdate = true, enqueueNew = false)
scrubbingTimeBar = true
},
onScrubEnd = {
scrubbingTimeBar = false
setCurrentChapterName(forceUpdate = true, enqueueNew = false)
}
streams.duration * 1000
)
}

View File

@ -20,9 +20,7 @@ import kotlinx.coroutines.withContext
class SeekbarPreviewListener(
private val timeFrameReceiver: TimeFrameReceiver,
private val playerBinding: ExoStyledPlayerControlViewBinding,
private val duration: Long,
private val onScrub: (position: Long) -> Unit = {},
private val onScrubEnd: (position: Long) -> Unit = {}
private val duration: Long
) : TimeBar.OnScrubListener {
private var scrubInProgress = false
private var lastPreviewPosition = Long.MAX_VALUE
@ -52,10 +50,6 @@ class SeekbarPreviewListener(
CoroutineScope(Dispatchers.IO).launch {
processPreview(position)
}
runCatching {
onScrub.invoke(position)
}
}
/**
@ -74,8 +68,6 @@ class SeekbarPreviewListener(
playerBinding.seekbarPreview.alpha = 1f
}
.start()
onScrubEnd.invoke(position)
}
/**

View File

@ -1,4 +1,3 @@
package com.github.libretube.ui.models
import android.content.Context
@ -25,6 +24,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import retrofit2.HttpException
@UnstableApi
class PlayerViewModel : ViewModel() {
var player: ExoPlayer? = null
var trackSelector: DefaultTrackSelector? = null

View File

@ -9,6 +9,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
import com.github.libretube.databinding.BottomSheetBinding
@ -16,6 +17,7 @@ import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.ui.adapters.ChaptersAdapter
import com.github.libretube.ui.models.PlayerViewModel
@UnstableApi
class ChaptersBottomSheet : UndimmedBottomSheet() {
private var _binding: BottomSheetBinding? = null
private val binding get() = _binding!!

View File

@ -22,6 +22,7 @@ import androidx.core.content.ContextCompat
import androidx.core.os.postDelayed
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.marginStart
import androidx.core.view.updateLayoutParams
@ -35,6 +36,7 @@ import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.TimeBar
import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.DoubleTapOverlayBinding
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
@ -58,13 +60,14 @@ import com.github.libretube.ui.interfaces.PlayerGestureOptions
import com.github.libretube.ui.interfaces.PlayerOptions
import com.github.libretube.ui.listeners.PlayerGestureController
import com.github.libretube.ui.sheets.BaseBottomSheet
import com.github.libretube.ui.sheets.ChaptersBottomSheet
import com.github.libretube.ui.sheets.PlaybackOptionsSheet
import com.github.libretube.ui.sheets.SleepTimerSheet
import com.github.libretube.util.PlayingQueue
@SuppressLint("ClickableViewAccessibility")
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
open class CustomExoPlayerView(
abstract class CustomExoPlayerView(
context: Context,
attributeSet: AttributeSet? = null
) : PlayerView(context, attributeSet), PlayerOptions, PlayerGestureOptions {
@ -79,6 +82,8 @@ open class CustomExoPlayerView(
private lateinit var brightnessHelper: BrightnessHelper
private lateinit var audioHelper: AudioHelper
private var doubleTapOverlayBinding: DoubleTapOverlayBinding? = null
private var chaptersBottomSheet: ChaptersBottomSheet? = null
private var scrubbingTimeBar = false
/**
* Objects from the parent fragment
@ -191,10 +196,16 @@ open class CustomExoPlayerView(
override fun onScrubMove(timeBar: TimeBar, position: Long) {
cancelHideControllerTask()
setCurrentChapterName(forceUpdate = true, enqueueNew = false)
scrubbingTimeBar = true
}
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
enqueueHideControllerTask()
setCurrentChapterName(forceUpdate = true, enqueueNew = false)
scrubbingTimeBar = false
}
})
@ -217,6 +228,46 @@ open class CustomExoPlayerView(
}
updateCurrentPosition()
// enable the chapters dialog in the player
binding.chapterName.setOnClickListener {
val sheet = chaptersBottomSheet ?: ChaptersBottomSheet().also {
chaptersBottomSheet = it
}
if (sheet.isVisible) {
sheet.dismiss()
} else {
sheet.show(activity.supportFragmentManager)
}
}
}
// set the name of the video chapter in the exoPlayerView
fun setCurrentChapterName(forceUpdate: Boolean = false, enqueueNew: Boolean = true) {
val player = player ?: return
val chapters = getChapters()
binding.chapterName.isInvisible = chapters.isEmpty()
// the following logic to set the chapter title can be skipped if no chapters are available
if (chapters.isEmpty()) return
// call the function again in 100ms
if (enqueueNew) postDelayed(this::setCurrentChapterName, 100)
// if the user is scrubbing the time bar, don't update
if (scrubbingTimeBar && !forceUpdate) return
val newChapterName =
PlayerHelper.getCurrentChapterIndex(player.currentPosition, chapters)
?.let { chapters[it].title.trim() }
?: context.getString(R.string.no_chapter)
// change the chapter name textView text to the chapterName
if (newChapterName != binding.chapterName.text) {
binding.chapterName.text = newChapterName
}
}
fun toggleSystemBars(showBars: Boolean) {
@ -771,6 +822,8 @@ open class CustomExoPlayerView(
open fun minimizeOrExitPlayer() = Unit
abstract fun getChapters(): List<ChapterSegment>
open fun getWindow(): Window = activity.window
companion object {

View File

@ -3,11 +3,14 @@ package com.github.libretube.ui.views
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.app.AppCompatActivity
import com.github.libretube.api.obj.ChapterSegment
class OfflinePlayerView(
context: Context,
attributeSet: AttributeSet? = null
) : CustomExoPlayerView(context, attributeSet) {
private var chapters: List<ChapterSegment> = emptyList()
override fun hideController() {
super.hideController()
// hide the status bars when continuing to watch video
@ -23,4 +26,11 @@ class OfflinePlayerView(
override fun minimizeOrExitPlayer() {
(context as AppCompatActivity).onBackPressedDispatcher.onBackPressed()
}
fun setChapters(chapters: List<ChapterSegment>) {
this.chapters = chapters
setCurrentChapterName()
}
override fun getChapters(): List<ChapterSegment> = chapters
}

View File

@ -7,8 +7,10 @@ import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.trackselection.TrackSelector
import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.extensions.toID
@ -22,6 +24,7 @@ import com.github.libretube.ui.interfaces.OnlinePlayerOptions
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.util.PlayingQueue
@UnstableApi
class OnlinePlayerView(
context: Context,
attributeSet: AttributeSet? = null
@ -200,4 +203,8 @@ class OnlinePlayerView(
override fun minimizeOrExitPlayer() {
playerOptions?.exitFullscreen()
}
override fun getChapters(): List<ChapterSegment> {
return playerViewModel?.chapters.orEmpty()
}
}