From 9030a6e871db3a1e56539a1743c340cbbf5e2b31 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 6 Oct 2024 13:43:28 +0200 Subject: [PATCH] feat: playing queue support for downloaded videos --- .../com/github/libretube/db/obj/Download.kt | 13 ++- .../services/AbstractPlayerService.kt | 12 +-- .../services/OfflinePlayerService.kt | 66 ++++++++-------- .../libretube/services/OnlinePlayerService.kt | 27 ------- .../ui/activities/OfflinePlayerActivity.kt | 79 ++++++++++++++++--- .../ui/fragments/DownloadsFragment.kt | 5 -- .../libretube/ui/views/CustomExoPlayerView.kt | 11 +++ .../libretube/ui/views/OnlinePlayerView.kt | 12 --- .../layout/exo_styled_player_control_view.xml | 2 - .../main/res/layout/fragment_downloads.xml | 17 +--- 10 files changed, 129 insertions(+), 115 deletions(-) diff --git a/app/src/main/java/com/github/libretube/db/obj/Download.kt b/app/src/main/java/com/github/libretube/db/obj/Download.kt index e1a4862b3..faff940b1 100644 --- a/app/src/main/java/com/github/libretube/db/obj/Download.kt +++ b/app/src/main/java/com/github/libretube/db/obj/Download.kt @@ -3,6 +3,7 @@ package com.github.libretube.db.obj import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import com.github.libretube.api.obj.StreamItem import kotlinx.datetime.LocalDate import java.nio.file.Path @@ -17,4 +18,14 @@ data class Download( val duration: Long? = null, val uploadDate: LocalDate? = null, val thumbnailPath: Path? = null -) +) { + fun toStreamItem() = StreamItem( + url = videoId, + title = title, + shortDescription = description, + thumbnail = thumbnailPath?.toUri()?.toString(), + duration = duration, + uploadedDate = uploadDate?.toString(), + uploaderName = uploader, + ) +} diff --git a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt index 7214615be..25902b5d2 100644 --- a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt @@ -15,7 +15,6 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import androidx.media3.common.C -import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -23,24 +22,15 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME import com.github.libretube.R -import com.github.libretube.constants.IntentData -import com.github.libretube.db.DatabaseHolder -import com.github.libretube.db.obj.DownloadWithItems -import com.github.libretube.enums.FileType import com.github.libretube.enums.NotificationId import com.github.libretube.enums.PlayerEvent import com.github.libretube.extensions.serializableExtra -import com.github.libretube.extensions.toAndroidUri import com.github.libretube.extensions.updateParameters import com.github.libretube.helpers.PlayerHelper -import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PlayingQueue -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.io.path.exists @UnstableApi abstract class AbstractPlayerService : LifecycleService() { @@ -138,6 +128,8 @@ abstract class AbstractPlayerService : LifecycleService() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + PlayingQueue.resetToDefaults() + lifecycleScope.launch { if (intent != null) { createPlayerAndNotification() diff --git a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt index 669b5e7c7..714057447 100644 --- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt @@ -8,11 +8,13 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.github.libretube.constants.IntentData import com.github.libretube.db.DatabaseHolder -import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.enums.FileType import com.github.libretube.extensions.toAndroidUri +import com.github.libretube.extensions.toID import com.github.libretube.helpers.PlayerHelper import com.github.libretube.obj.PlayerNotificationData +import com.github.libretube.util.PlayingQueue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,39 +25,28 @@ import kotlin.io.path.exists */ @UnstableApi class OfflinePlayerService : AbstractPlayerService() { - private var downloadsWithItems: List = emptyList() - override suspend fun onServiceCreated(intent: Intent) { - downloadsWithItems = withContext(Dispatchers.IO) { - DatabaseHolder.Database.downloadDao().getAll() - } - if (downloadsWithItems.isEmpty()) { - onDestroy() - return + videoId = intent.getStringExtra(IntentData.videoId) ?: return + + PlayingQueue.clear() + + PlayingQueue.setOnQueueTapListener { streamItem -> + streamItem.url?.toID()?.let { playNextVideo(it) } } - val videoId = intent.getStringExtra(IntentData.videoId) - - val downloadToPlay = if (videoId == null) { - downloadsWithItems = downloadsWithItems.shuffled() - downloadsWithItems.first() - } else { - downloadsWithItems.first { it.download.videoId == videoId } - } - - this@OfflinePlayerService.videoId = downloadToPlay.download.videoId + fillQueue() } /** * Attempt to start an audio player with the given download items */ override suspend fun startPlaybackAndUpdateNotification() { - val downloadWithItems = downloadsWithItems.firstOrNull { it.download.videoId == videoId } - if (downloadWithItems == null) { - stopSelf() - return + val downloadWithItems = withContext(Dispatchers.IO) { + Database.downloadDao().findById(videoId) } + PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem()) + val notificationData = PlayerNotificationData( title = downloadWithItems.download.title, uploaderName = downloadWithItems.download.uploader, @@ -88,6 +79,24 @@ class OfflinePlayerService : AbstractPlayerService() { } } + private suspend fun fillQueue() { + val downloads = withContext(Dispatchers.IO) { + Database.downloadDao().getAll() + } + + PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() }) + } + + private fun playNextVideo(videoId: String) { + saveWatchPosition() + + this.videoId = videoId + + lifecycleScope.launch { + startPlaybackAndUpdateNotification() + } + } + override fun onBind(intent: Intent): IBinder? { super.onBind(intent) return null @@ -103,15 +112,8 @@ class OfflinePlayerService : AbstractPlayerService() { override fun onPlaybackStateChanged(playbackState: Int) { // automatically go to the next video/audio when the current one ended - if (playbackState == Player.STATE_ENDED) { - val currentIndex = downloadsWithItems.indexOfFirst { it.download.videoId == videoId } - downloadsWithItems.getOrNull(currentIndex + 1)?.let { - this@OfflinePlayerService.videoId = it.download.videoId - - lifecycleScope.launch { - startPlaybackAndUpdateNotification() - } - } + if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) { + playNextVideo(PlayingQueue.getNext() ?: return) } } } diff --git a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt index 8db5458b5..1ac334f97 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -1,31 +1,13 @@ package com.github.libretube.services -import android.app.Notification -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Binder -import android.os.Handler import android.os.IBinder -import android.os.Looper -import android.widget.Toast -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat import androidx.core.net.toUri -import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope -import androidx.media3.common.C -import androidx.media3.common.C.WAKE_MODE_NETWORK import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes -import androidx.media3.common.PlaybackException import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME -import com.github.libretube.R import com.github.libretube.api.JsonHelper import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.StreamsExtractor @@ -33,21 +15,15 @@ import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Streams import com.github.libretube.constants.IntentData import com.github.libretube.db.DatabaseHelper -import com.github.libretube.enums.NotificationId -import com.github.libretube.enums.PlayerEvent import com.github.libretube.extensions.parcelableExtra -import com.github.libretube.extensions.serializableExtra import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.toID import com.github.libretube.extensions.toastFromMainDispatcher -import com.github.libretube.extensions.updateParameters import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper.checkForSegments import com.github.libretube.helpers.ProxyHelper import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.parcelable.PlayerData -import com.github.libretube.util.NowPlayingNotification -import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PlayingQueue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -90,9 +66,6 @@ class OnlinePlayerService : AbstractPlayerService() { var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null override suspend fun onServiceCreated(intent: Intent) { - // reset the playing queue listeners - PlayingQueue.resetToDefaults() - val playerData = intent.parcelableExtra(IntentData.playerData) if (playerData == null) { stopSelf() 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 52dfb3a3c..6d0fa50ee 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 @@ -49,6 +49,7 @@ import com.github.libretube.ui.models.OfflinePlayerViewModel import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.OfflineTimeFrameReceiver import com.github.libretube.util.PauseableTimer +import com.github.libretube.util.PlayingQueue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -85,7 +86,10 @@ class OfflinePlayerActivity : BaseActivity() { super.onIsPlayingChanged(isPlaying) if (PlayerHelper.pipEnabled) { - PictureInPictureCompat.setPictureInPictureParams(this@OfflinePlayerActivity, pipParams) + PictureInPictureCompat.setPictureInPictureParams( + this@OfflinePlayerActivity, + pipParams + ) } // Start or pause watch position timer @@ -108,21 +112,32 @@ class OfflinePlayerActivity : BaseActivity() { ) ) } + + if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) { + playNextVideo(PlayingQueue.getNext() ?: return) + } } } private val playerActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val event = intent.serializableExtra(PlayerHelper.CONTROL_TYPE) ?: return - PlayerHelper.handlePlayerAction(viewModel.player, event) + if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return + + when (event) { + PlayerEvent.Prev -> playNextVideo(PlayingQueue.getPrev() ?: return) + PlayerEvent.Next -> playNextVideo(PlayingQueue.getNext() ?: return) + else -> Unit + } } } - private val pipParams get() = PictureInPictureParamsCompat.Builder() - .setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying)) - .setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying) - .setAspectRatio(viewModel.player.videoSize) - .build() + private val pipParams + get() = PictureInPictureParamsCompat.Builder() + .setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying)) + .setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying) + .setAspectRatio(viewModel.player.videoSize) + .build() override fun onCreate(savedInstanceState: Bundle?) { WindowHelper.toggleFullscreen(window, true) @@ -136,6 +151,13 @@ class OfflinePlayerActivity : BaseActivity() { binding = ActivityOfflinePlayerBinding.inflate(layoutInflater) setContentView(binding.root) + PlayingQueue.resetToDefaults() + PlayingQueue.clear() + + PlayingQueue.setOnQueueTapListener { streamItem -> + playNextVideo(streamItem.url ?: return@setOnQueueTapListener) + } + initializePlayer() playVideo() @@ -154,6 +176,14 @@ class OfflinePlayerActivity : BaseActivity() { if (PlayerHelper.pipEnabled) { PictureInPictureCompat.setPictureInPictureParams(this, pipParams) } + + lifecycleScope.launch { fillQueue() } + } + + private fun playNextVideo(videoId: String) { + saveWatchPosition() + this.videoId = videoId + playVideo() } private fun initializePlayer() { @@ -171,13 +201,25 @@ class OfflinePlayerActivity : BaseActivity() { finish() } + playerBinding.skipPrev.setOnClickListener { + playNextVideo(PlayingQueue.getPrev() ?: return@setOnClickListener) + } + + playerBinding.skipNext.setOnClickListener { + playNextVideo(PlayingQueue.getNext() ?: return@setOnClickListener) + } + binding.player.initialize( binding.doubleTapOverlay.binding, binding.playerGestureControlsView.binding, chaptersViewModel ) - nowPlayingNotification = NowPlayingNotification(this, viewModel.player, NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE) + nowPlayingNotification = NowPlayingNotification( + this, + viewModel.player, + NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE + ) } private fun playVideo() { @@ -185,6 +227,8 @@ class OfflinePlayerActivity : BaseActivity() { val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) { Database.downloadDao().findById(videoId) } + PlayingQueue.updateCurrent(downloadInfo.toStreamItem()) + val chapters = downloadChapters.map(DownloadChapter::toChapterSegment) chaptersViewModel.chaptersLiveData.value = chapters binding.player.setChapters(chapters) @@ -221,7 +265,11 @@ class OfflinePlayerActivity : BaseActivity() { } } - val data = PlayerNotificationData(downloadInfo.title, downloadInfo.uploader, downloadInfo.thumbnailPath.toString()) + val data = PlayerNotificationData( + downloadInfo.title, + downloadInfo.uploader, + downloadInfo.thumbnailPath.toString() + ) nowPlayingNotification?.updatePlayerNotification(videoId, data) } } @@ -274,6 +322,14 @@ class OfflinePlayerActivity : BaseActivity() { } } + private suspend fun fillQueue() { + val downloads = withContext(Dispatchers.IO) { + Database.downloadDao().getAll() + } + + PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() }) + } + private fun saveWatchPosition() { if (!PlayerHelper.watchPositionsVideo) return @@ -320,7 +376,10 @@ class OfflinePlayerActivity : BaseActivity() { super.onUserLeaveHint() } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, configuration: Configuration) { + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) if (isInPictureInPictureMode) { diff --git a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt index eb4ebad16..b5de74d1b 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt @@ -171,10 +171,6 @@ class DownloadsFragment : DynamicLayoutManagerFragment() { toggleButtonsVisibility() - binding.shuffleBackground.setOnClickListener { - BackgroundHelper.playOnBackgroundOffline(requireContext(), null) - } - binding.deleteAll.setOnClickListener { showDeleteAllDialog(binding.root.context, adapter) } @@ -188,7 +184,6 @@ class DownloadsFragment : DynamicLayoutManagerFragment() { binding.downloads.isGone = isEmpty binding.sortType.isGone = isEmpty binding.deleteAll.isGone = isEmpty - binding.shuffleBackground.isGone = isEmpty } private fun sortDownloadList(sortType: Int, previousSortType: Int? = null) { 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 8fcfa42c7..1c426fe20 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 @@ -66,6 +66,7 @@ import com.github.libretube.ui.models.ChaptersViewModel 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.PlayingQueueSheet import com.github.libretube.ui.sheets.SleepTimerSheet import com.github.libretube.util.PlayingQueue @@ -203,6 +204,12 @@ abstract class CustomExoPlayerView( } }) + binding.autoPlay.isChecked = PlayerHelper.autoPlayEnabled + + binding.autoPlay.setOnCheckedChangeListener { _, isChecked -> + PlayerHelper.autoPlayEnabled = isChecked + } + // restore the duration type from the previous session updateDisplayedDurationType() @@ -248,6 +255,10 @@ abstract class CustomExoPlayerView( sheet.show(activity.supportFragmentManager) } } + + binding.queueToggle.setOnClickListener { + PlayingQueueSheet().show(supportFragmentManager, null) + } } /** 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 a0879918e..5b34c9473 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 @@ -173,18 +173,6 @@ class OnlinePlayerView( binding.exoTitle.isInvisible = !isFullscreen } - binding.autoPlay.isVisible = true - binding.autoPlay.isChecked = PlayerHelper.autoPlayEnabled - - binding.autoPlay.setOnCheckedChangeListener { _, isChecked -> - PlayerHelper.autoPlayEnabled = isChecked - } - - binding.queueToggle.isVisible = true - binding.queueToggle.setOnClickListener { - PlayingQueueSheet().show(activity.supportFragmentManager, null) - } - val updateSbImageResource = { binding.sbToggle.setImageResource( if (playerViewModel.sponsorBlockEnabled) R.drawable.ic_sb_enabled else R.drawable.ic_sb_disabled diff --git a/app/src/main/res/layout/exo_styled_player_control_view.xml b/app/src/main/res/layout/exo_styled_player_control_view.xml index e6256650e..122db418a 100644 --- a/app/src/main/res/layout/exo_styled_player_control_view.xml +++ b/app/src/main/res/layout/exo_styled_player_control_view.xml @@ -76,7 +76,6 @@ android:scaleY="0.8" android:thumb="@drawable/player_switch_thumb" android:tooltipText="@string/player_autoplay" - android:visibility="gone" app:thumbTint="@android:color/white" app:track="@drawable/player_switch_track" app:trackTint="#88ffffff" /> @@ -114,7 +113,6 @@ android:layout_marginEnd="2dp" android:src="@drawable/ic_queue" android:tooltipText="@string/queue" - android:visibility="gone" app:tint="@android:color/white" /> - - \ No newline at end of file