diff --git a/app/schemas/com.github.libretube.db.AppDatabase/18.json b/app/schemas/com.github.libretube.db.AppDatabase/18.json new file mode 100644 index 000000000..cdeb1b997 --- /dev/null +++ b/app/schemas/com.github.libretube.db.AppDatabase/18.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/db/AppDatabase.kt b/app/src/main/java/com/github/libretube/db/AppDatabase.kt index d4a238e39..1f877ffaa 100644 --- a/app/src/main/java/com/github/libretube/db/AppDatabase.kt +++ b/app/src/main/java/com/github/libretube/db/AppDatabase.kt @@ -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), diff --git a/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt b/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt index 7f3c0a641..b314eec6c 100644 --- a/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt +++ b/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt @@ -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() diff --git a/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt index 4e896470a..297676ef5 100644 --- a/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt +++ b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt @@ -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 diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadChapter.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadChapter.kt new file mode 100644 index 000000000..d93f641c3 --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/DownloadChapter.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt index 1357a8dec..9031b60dd 100644 --- a/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt +++ b/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt @@ -9,5 +9,10 @@ data class DownloadWithItems( parentColumn = "videoId", entityColumn = "videoId" ) - val downloadItems: List + val downloadItems: List, + @Relation( + parentColumn = "videoId", + entityColumn = "videoId" + ) + val downloadChapters: List = emptyList() ) diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt index 852f83f71..64654bf12 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -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, diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt index 416763cea..a10ab23bb 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt @@ -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() diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index a5c769c1c..853d1f5ba 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -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 ) } diff --git a/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt b/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt index a3cb19559..298f68954 100644 --- a/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt +++ b/app/src/main/java/com/github/libretube/ui/listeners/SeekbarPreviewListener.kt @@ -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) } /** diff --git a/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt index cf0dddb7f..fccaaac3a 100644 --- a/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt @@ -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 diff --git a/app/src/main/java/com/github/libretube/ui/sheets/ChaptersBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/ChaptersBottomSheet.kt index bf7d96103..609a043c2 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/ChaptersBottomSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/ChaptersBottomSheet.kt @@ -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!! diff --git a/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt b/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt index 8c90a2f56..b3cf2ba53 100644 --- a/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt +++ b/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt @@ -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 + open fun getWindow(): Window = activity.window companion object { diff --git a/app/src/main/java/com/github/libretube/ui/views/OfflinePlayerView.kt b/app/src/main/java/com/github/libretube/ui/views/OfflinePlayerView.kt index 31b94952a..5e82897cc 100644 --- a/app/src/main/java/com/github/libretube/ui/views/OfflinePlayerView.kt +++ b/app/src/main/java/com/github/libretube/ui/views/OfflinePlayerView.kt @@ -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 = 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) { + this.chapters = chapters + setCurrentChapterName() + } + + override fun getChapters(): List = chapters } diff --git a/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt b/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt index 8cd6ad954..68e498a3f 100644 --- a/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt +++ b/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt @@ -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 { + return playerViewModel?.chapters.orEmpty() + } }