From db8ec51b1294d93c3ecc317f353803ed3eacb4aa Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 6 Oct 2024 12:57:43 +0200 Subject: [PATCH 1/6] refactor: move common PlayerService code to AbstractPlayerService --- .../services/AbstractPlayerService.kt | 233 +++++++++++++ .../services/OfflinePlayerService.kt | 204 +++-------- .../libretube/services/OnlinePlayerService.kt | 323 ++++-------------- 3 files changed, 346 insertions(+), 414 deletions(-) create mode 100644 app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt diff --git a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt new file mode 100644 index 0000000000..7214615be8 --- /dev/null +++ b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt @@ -0,0 +1,233 @@ +package com.github.libretube.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +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 +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() { + var player: ExoPlayer? = null + var nowPlayingNotification: NowPlayingNotification? = null + var trackSelector: DefaultTrackSelector? = null + + lateinit var videoId: String + var isTransitioning = true + + val handler = Handler(Looper.getMainLooper()) + + private val watchPositionTimer = PauseableTimer( + onTick = ::saveWatchPosition, + delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS + ) + + private val playerListener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + + // Start or pause watch position timer + if (isPlaying) { + watchPositionTimer.resume() + } else { + watchPositionTimer.pause() + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + + this@AbstractPlayerService.onPlaybackStateChanged(playbackState) + } + + override fun onPlayerError(error: PlaybackException) { + // show a toast on errors + Handler(Looper.getMainLooper()).post { + Toast.makeText( + applicationContext, + error.localizedMessage, + Toast.LENGTH_SHORT + ).show() + } + } + + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + + if (events.contains(Player.EVENT_TRACKS_CHANGED)) { + PlayerHelper.setPreferredAudioQuality(this@AbstractPlayerService, player, trackSelector ?: return) + } + } + } + + private val playerActionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val event = intent.serializableExtra(PlayerHelper.CONTROL_TYPE) ?: return + val player = player ?: return + + if (PlayerHelper.handlePlayerAction(player, event)) return + + when (event) { + PlayerEvent.Next -> { + PlayingQueue.navigateNext() + } + PlayerEvent.Prev -> { + PlayingQueue.navigatePrev() + } + PlayerEvent.Stop -> { + onDestroy() + } + else -> Unit + } + } + } + + override fun onCreate() { + super.onCreate() + + val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.playingOnBackground)) + .setSmallIcon(R.drawable.ic_launcher_lockscreen) + .build() + + startForeground(NotificationId.PLAYER_PLAYBACK.id, notification) + + ContextCompat.registerReceiver( + this, + playerActionReceiver, + IntentFilter(PlayerHelper.getIntentActionName(this)), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + lifecycleScope.launch { + if (intent != null) { + createPlayerAndNotification() + onServiceCreated(intent) + startPlaybackAndUpdateNotification() + } + else stopSelf() + } + + return super.onStartCommand(intent, flags, startId) + } + + abstract suspend fun onServiceCreated(intent: Intent) + + @OptIn(UnstableApi::class) + private fun createPlayerAndNotification() { + val trackSelector = DefaultTrackSelector(this) + this.trackSelector = trackSelector + + trackSelector.updateParameters { + setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) + } + + player = PlayerHelper.createPlayer(this, trackSelector, true) + // prevent android from putting LibreTube to sleep when locked + player!!.setWakeMode(C.WAKE_MODE_LOCAL) + player!!.addListener(playerListener) + + PlayerHelper.setPreferredCodecs(trackSelector) + + nowPlayingNotification = NowPlayingNotification( + this, + player!!, + NowPlayingNotification.Companion.NowPlayingNotificationType.AUDIO_OFFLINE + ) + } + + abstract suspend fun startPlaybackAndUpdateNotification() + + fun saveWatchPosition() { + if (isTransitioning || !PlayerHelper.watchPositionsVideo) return + + player?.let { PlayerHelper.saveWatchPosition(it, videoId) } + } + + override fun onDestroy() { + PlayingQueue.resetToDefaults() + + saveWatchPosition() + + nowPlayingNotification?.destroySelf() + nowPlayingNotification = null + watchPositionTimer.destroy() + + handler.removeCallbacksAndMessages(null) + + runCatching { + player?.stop() + player?.release() + } + player = null + + runCatching { + unregisterReceiver(playerActionReceiver) + } + + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + + super.onDestroy() + } + + override fun onBind(intent: Intent): IBinder? { + super.onBind(intent) + return null + } + + /** + * Stop the service when app is removed from the task manager. + */ + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + onDestroy() + } + + abstract fun onPlaybackStateChanged(playbackState: Int) + + fun getCurrentPosition() = player?.currentPosition + + fun getDuration() = player?.duration + + fun seekToPosition(position: Long) = player?.seekTo(position) +} 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 94ed1eed51..669b5e7c7f 100644 --- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt @@ -1,37 +1,18 @@ package com.github.libretube.services -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.IBinder -import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat -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.Player import androidx.media3.common.util.UnstableApi -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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -40,131 +21,41 @@ import kotlin.io.path.exists /** * A service to play downloaded audio in the background */ -class OfflinePlayerService : LifecycleService() { - private var player: ExoPlayer? = null - private var nowPlayingNotification: NowPlayingNotification? = null - private lateinit var videoId: String +@UnstableApi +class OfflinePlayerService : AbstractPlayerService() { private var downloadsWithItems: List = emptyList() - private val watchPositionTimer = PauseableTimer( - onTick = this::saveWatchPosition, - delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS - ) - - private val playerListener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - super.onIsPlayingChanged(isPlaying) - - // Start or pause watch position timer - if (isPlaying) { - watchPositionTimer.resume() - } else { - watchPositionTimer.pause() - } + override suspend fun onServiceCreated(intent: Intent) { + downloadsWithItems = withContext(Dispatchers.IO) { + DatabaseHolder.Database.downloadDao().getAll() } - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - - // 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 - startAudioPlayer(it) - } - } + if (downloadsWithItems.isEmpty()) { + onDestroy() + return } - } - private val playerActionReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val event = intent.serializableExtra(PlayerHelper.CONTROL_TYPE) ?: return - val player = player ?: return + val videoId = intent.getStringExtra(IntentData.videoId) - if (PlayerHelper.handlePlayerAction(player, event)) return - - when (event) { - PlayerEvent.Stop -> onDestroy() - else -> Unit - } + val downloadToPlay = if (videoId == null) { + downloadsWithItems = downloadsWithItems.shuffled() + downloadsWithItems.first() + } else { + downloadsWithItems.first { it.download.videoId == videoId } } - } - - override fun onCreate() { - super.onCreate() - - val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.playingOnBackground)) - .setSmallIcon(R.drawable.ic_launcher_lockscreen) - .build() - - startForeground(NotificationId.PLAYER_PLAYBACK.id, notification) - ContextCompat.registerReceiver( - this, - playerActionReceiver, - IntentFilter(PlayerHelper.getIntentActionName(this)), - ContextCompat.RECEIVER_NOT_EXPORTED - ) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - lifecycleScope.launch { - downloadsWithItems = withContext(Dispatchers.IO) { - DatabaseHolder.Database.downloadDao().getAll() - } - if (downloadsWithItems.isEmpty()) { - onDestroy() - return@launch - } - - 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 - - createPlayerAndNotification() - - // destroy the service if there was no success playing the selected audio/video - if (!startAudioPlayer(downloadToPlay)) onDestroy() - } - - return super.onStartCommand(intent, flags, startId) - } - - @OptIn(UnstableApi::class) - private fun createPlayerAndNotification() { - val trackSelector = DefaultTrackSelector(this@OfflinePlayerService) - trackSelector.updateParameters { - setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) - } - - player = PlayerHelper.createPlayer(this@OfflinePlayerService, trackSelector, true) - // prevent android from putting LibreTube to sleep when locked - player!!.setWakeMode(C.WAKE_MODE_LOCAL) - player!!.addListener(playerListener) - - nowPlayingNotification = NowPlayingNotification( - this, - player!!, - NowPlayingNotification.Companion.NowPlayingNotificationType.AUDIO_OFFLINE - ) + this@OfflinePlayerService.videoId = downloadToPlay.download.videoId } /** * Attempt to start an audio player with the given download items - * @param downloadWithItems The database download to play from - * @return whether starting the audio player succeeded */ - private fun startAudioPlayer(downloadWithItems: DownloadWithItems): Boolean { + override suspend fun startPlaybackAndUpdateNotification() { + val downloadWithItems = downloadsWithItems.firstOrNull { it.download.videoId == videoId } + if (downloadWithItems == null) { + stopSelf() + return + } + val notificationData = PlayerNotificationData( title = downloadWithItems.download.title, uploaderName = downloadWithItems.download.uploader, @@ -176,7 +67,11 @@ class OfflinePlayerService : LifecycleService() { .firstOrNull { it.type == FileType.AUDIO } ?: // in some rare cases, video files can contain audio downloadWithItems.downloadItems.firstOrNull { it.type == FileType.VIDEO } - ?: return false + + if (audioItem == null) { + stopSelf() + return + } val mediaItem = MediaItem.Builder() .setUri(audioItem.path.toAndroidUri()) @@ -191,37 +86,6 @@ class OfflinePlayerService : LifecycleService() { player?.seekTo(it) } } - - return true - } - - private fun saveWatchPosition() { - if (!PlayerHelper.watchPositionsVideo) return - - player?.let { PlayerHelper.saveWatchPosition(it, videoId) } - } - - override fun onDestroy() { - saveWatchPosition() - - nowPlayingNotification?.destroySelf() - nowPlayingNotification = null - watchPositionTimer.destroy() - - runCatching { - player?.stop() - player?.release() - } - player = null - - runCatching { - unregisterReceiver(playerActionReceiver) - } - - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - stopSelf() - - super.onDestroy() } override fun onBind(intent: Intent): IBinder? { @@ -236,4 +100,18 @@ class OfflinePlayerService : LifecycleService() { super.onTaskRemoved(rootIntent) onDestroy() } + + 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() + } + } + } + } } 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 9e73ea40dc..8db5458b51 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -39,6 +39,7 @@ 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 @@ -57,17 +58,13 @@ import kotlinx.serialization.encodeToString * Loads the selected videos audio in background mode with a notification area. */ @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) -class OnlinePlayerService : LifecycleService() { - /** - * VideoId of the video - */ - private lateinit var videoId: String - +class OnlinePlayerService : AbstractPlayerService() { /** * PlaylistId/ChannelId for autoplay */ private var playlistId: String? = null private var channelId: String? = null + private var startTimestamp: Long? = null /** * The response that gets when called the Api. @@ -75,29 +72,12 @@ class OnlinePlayerService : LifecycleService() { var streams: Streams? = null private set - /** - * The [ExoPlayer] player. Followed tutorial [here](https://developer.android.com/codelabs/exoplayer-intro) - */ - var player: ExoPlayer? = null - private var trackSelector: DefaultTrackSelector? = null - private var isTransitioning = true - /** * SponsorBlock Segment data */ private var segments = listOf() private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() - /** - * [Notification] for the player - */ - private lateinit var nowPlayingNotification: NowPlayingNotification - - /** - * Autoplay Preference - */ - private val handler = Handler(Looper.getMainLooper()) - /** * Used for connecting to the AudioPlayerFragment */ @@ -109,181 +89,66 @@ class OnlinePlayerService : LifecycleService() { var onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null - private val watchPositionTimer = PauseableTimer( - onTick = this::saveWatchPosition, - delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS - ) - - private val playerListener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - super.onIsPlayingChanged(isPlaying) - onStateOrPlayingChanged?.invoke(isPlaying) - - // Start or pause watch position timer - if (isPlaying) { - watchPositionTimer.resume() - } else { - watchPositionTimer.pause() - } - } - - override fun onPlaybackStateChanged(state: Int) { - onStateOrPlayingChanged?.invoke(player?.isPlaying ?: false) - - when (state) { - Player.STATE_ENDED -> { - if (!isTransitioning) playNextVideo() - } - - Player.STATE_IDLE -> { - onDestroy() - } - - Player.STATE_BUFFERING -> {} - Player.STATE_READY -> { - isTransitioning = false - - // save video to watch history when the video starts playing or is being resumed - // waiting for the player to be ready since the video can't be claimed to be watched - // while it did not yet start actually, but did buffer only so far - lifecycleScope.launch(Dispatchers.IO) { - streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) } - } - } - } - } - - override fun onPlayerError(error: PlaybackException) { - // show a toast on errors - Handler(Looper.getMainLooper()).post { - Toast.makeText( - this@OnlinePlayerService.applicationContext, - error.localizedMessage, - Toast.LENGTH_SHORT - ).show() - } - } - - override fun onEvents(player: Player, events: Player.Events) { - super.onEvents(player, events) - - if (events.contains(Player.EVENT_TRACKS_CHANGED)) { - PlayerHelper.setPreferredAudioQuality(this@OnlinePlayerService, player, trackSelector ?: return) - } - } - } - - private val playerActionReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val event = intent.serializableExtra(PlayerHelper.CONTROL_TYPE) ?: return - val player = player ?: return - - if (PlayerHelper.handlePlayerAction(player, event)) return - - when (event) { - PlayerEvent.Next -> { - PlayingQueue.navigateNext() - } - PlayerEvent.Prev -> { - PlayingQueue.navigatePrev() - } - PlayerEvent.Stop -> { - onDestroy() - } - else -> Unit - } - } - } - - /** - * Setting the required [Notification] for running as a foreground service - */ - override fun onCreate() { - super.onCreate() - - val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.playingOnBackground)) - .setSmallIcon(R.drawable.ic_launcher_lockscreen) - .build() - - startForeground(NotificationId.PLAYER_PLAYBACK.id, notification) - - ContextCompat.registerReceiver( - this, - playerActionReceiver, - IntentFilter(PlayerHelper.getIntentActionName(this)), - ContextCompat.RECEIVER_NOT_EXPORTED - ) - } - - /** - * Initializes the [player] with the [MediaItem]. - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override suspend fun onServiceCreated(intent: Intent) { // reset the playing queue listeners PlayingQueue.resetToDefaults() - intent?.parcelableExtra(IntentData.playerData)?.let { playerData -> - // get the intent arguments - videoId = playerData.videoId - playlistId = playerData.playlistId + val playerData = intent.parcelableExtra(IntentData.playerData) + if (playerData == null) { + stopSelf() + return + } - // play the audio in the background - loadAudio(playerData) + // get the intent arguments + videoId = playerData.videoId + playlistId = playerData.playlistId + startTimestamp = playerData.timestamp - PlayingQueue.setOnQueueTapListener { streamItem -> - streamItem.url?.toID()?.let { playNextVideo(it) } - } + if (!playerData.keepQueue) PlayingQueue.clear() + + PlayingQueue.setOnQueueTapListener { streamItem -> + streamItem.url?.toID()?.let { playNextVideo(it) } } - return super.onStartCommand(intent, flags, startId) } - private fun saveWatchPosition() { - if (isTransitioning || !PlayerHelper.watchPositionsAudio) return - - player?.let { PlayerHelper.saveWatchPosition(it, videoId) } - } + override suspend fun startPlaybackAndUpdateNotification() { + val timestamp = startTimestamp ?: 0L + startTimestamp = null - /** - * Gets the video data and prepares the [player]. - */ - private fun loadAudio(playerData: PlayerData) { - val (videoId, _, _, keepQueue, timestamp) = playerData isTransitioning = true - lifecycleScope.launch(Dispatchers.IO) { - streams = runCatching { + streams = withContext(Dispatchers.IO) { + try { StreamsExtractor.extractStreams(videoId) - }.getOrNull() ?: return@launch - - // clear the queue if it shouldn't be kept explicitly - if (!keepQueue) PlayingQueue.clear() - - if (PlayingQueue.isEmpty()) { - PlayingQueue.updateQueue( - streams!!.toStreamItem(videoId), - playlistId, - channelId, - streams!!.relatedStreams - ) - } else if (PlayingQueue.isLast() && playlistId == null && channelId == null) { - PlayingQueue.insertRelatedStreams(streams!!.relatedStreams) + } catch (e: Exception) { + val errorMessage = StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e) + this@OnlinePlayerService.toastFromMainDispatcher(errorMessage) + return@withContext null } + } ?: return + + if (PlayingQueue.isEmpty()) { + PlayingQueue.updateQueue( + streams!!.toStreamItem(videoId), + playlistId, + channelId, + streams!!.relatedStreams + ) + } else if (PlayingQueue.isLast() && playlistId == null && channelId == null) { + PlayingQueue.insertRelatedStreams(streams!!.relatedStreams) + } - // save the current stream to the queue - streams?.toStreamItem(videoId)?.let { - PlayingQueue.updateCurrent(it) - } + // save the current stream to the queue + streams?.toStreamItem(videoId)?.let { + PlayingQueue.updateCurrent(it) + } - withContext(Dispatchers.Main) { - playAudio(timestamp) - } + withContext(Dispatchers.Main) { + playAudio(timestamp) } } private fun playAudio(seekToPosition: Long) { - initializePlayer() lifecycleScope.launch(Dispatchers.IO) { setMediaItem() @@ -299,20 +164,12 @@ class OnlinePlayerService : LifecycleService() { } } - // create the notification - if (!this@OnlinePlayerService::nowPlayingNotification.isInitialized) { - nowPlayingNotification = NowPlayingNotification( - this@OnlinePlayerService, - player!!, - NowPlayingNotification.Companion.NowPlayingNotificationType.AUDIO_ONLINE - ) - } val playerNotificationData = PlayerNotificationData( streams?.title, streams?.uploader, streams?.thumbnailUrl ) - nowPlayingNotification.updatePlayerNotification(videoId, playerNotificationData) + nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData) streams?.let { onNewVideo?.invoke(it, videoId) } player?.apply { @@ -323,28 +180,6 @@ class OnlinePlayerService : LifecycleService() { if (PlayerHelper.sponsorBlockEnabled) fetchSponsorBlockSegments() } - /** - * create the player - */ - private fun initializePlayer() { - if (player != null) return - - trackSelector = DefaultTrackSelector(this) - trackSelector!!.updateParameters { - setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) - } - - player = PlayerHelper.createPlayer(this, trackSelector!!, true) - // prevent android from putting LibreTube to sleep when locked - player!!.setWakeMode(WAKE_MODE_NETWORK) - - // Listens for changed playbackStates (e.g. pause, end) - // Plays the next video when the current one ended - player?.addListener(playerListener) - - PlayerHelper.setPreferredCodecs(trackSelector!!) - } - /** * Plays the next video from the queue */ @@ -364,7 +199,10 @@ class OnlinePlayerService : LifecycleService() { this.videoId = nextVideo this.streams = null this.segments = emptyList() - loadAudio(PlayerData(videoId, keepQueue = true)) + + lifecycleScope.launch { + startPlaybackAndUpdateNotification() + } } /** @@ -414,43 +252,6 @@ class OnlinePlayerService : LifecycleService() { player?.checkForSegments(this, segments, sponsorBlockConfig) } - /** - * Stop the service when app is removed from the task manager. - */ - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - onDestroy() - } - - /** - * destroy the [OnlinePlayerService] foreground service - */ - override fun onDestroy() { - // reset the playing queue - PlayingQueue.resetToDefaults() - - if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroySelf() - watchPositionTimer.destroy() - handler.removeCallbacksAndMessages(null) - - runCatching { - player?.stop() - player?.release() - } - - runCatching { - unregisterReceiver(playerActionReceiver) - } - - // called when the user pressed stop in the notification - // stop the service from being in the foreground and remove the notification - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - // destroy the service - stopSelf() - - super.onDestroy() - } - inner class LocalBinder : Binder() { // Return this instance of [BackgroundMode] so clients can call public methods fun getService(): OnlinePlayerService = this@OnlinePlayerService @@ -461,9 +262,29 @@ class OnlinePlayerService : LifecycleService() { return binder } - fun getCurrentPosition() = player?.currentPosition + override fun onPlaybackStateChanged(playbackState: Int) { + onStateOrPlayingChanged?.invoke(player?.isPlaying ?: false) - fun getDuration() = player?.duration + when (playbackState) { + Player.STATE_ENDED -> { + if (!isTransitioning) playNextVideo() + } - fun seekToPosition(position: Long) = player?.seekTo(position) + Player.STATE_IDLE -> { + onDestroy() + } + + Player.STATE_BUFFERING -> {} + Player.STATE_READY -> { + isTransitioning = false + + // save video to watch history when the video starts playing or is being resumed + // waiting for the player to be ready since the video can't be claimed to be watched + // while it did not yet start actually, but did buffer only so far + lifecycleScope.launch(Dispatchers.IO) { + streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) } + } + } + } + } } From 9030a6e871db3a1e56539a1743c340cbbf5e2b31 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 6 Oct 2024 13:43:28 +0200 Subject: [PATCH 2/6] feat: playing queue support for downloaded videos --- .../com/github/libretube/db/obj/Download.kt | 13 ++- .../services/AbstractPlayerService.kt | 12 +-- .../services/OfflinePlayerService.kt | 62 ++++++++------- .../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, 127 insertions(+), 113 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 e1a4862b30..faff940b14 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 7214615be8..25902b5d2d 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 669b5e7c7f..7140574479 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 - val videoId = intent.getStringExtra(IntentData.videoId) + PlayingQueue.clear() - val downloadToPlay = if (videoId == null) { - downloadsWithItems = downloadsWithItems.shuffled() - downloadsWithItems.first() - } else { - downloadsWithItems.first { it.download.videoId == videoId } + PlayingQueue.setOnQueueTapListener { streamItem -> + streamItem.url?.toID()?.let { playNextVideo(it) } } - 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 8db5458b51..1ac334f970 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 52dfb3a3ce..6d0fa50eeb 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 eb4ebad166..b5de74d1b8 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 8fcfa42c7e..1c426fe205 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 a0879918ee..5b34c94736 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 e6256650e8..122db418ab 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 From db0dc4c4fce2255fbacbcb55bb53442002a49f44 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 6 Oct 2024 13:52:05 +0200 Subject: [PATCH 3/6] refactor: use Coil exclusively for image loading --- .../github/libretube/helpers/ImageHelper.kt | 21 ++++---- .../libretube/ui/adapters/DownloadsAdapter.kt | 2 +- .../libretube/util/NowPlayingNotification.kt | 53 +++++++++---------- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/github/libretube/helpers/ImageHelper.kt b/app/src/main/java/com/github/libretube/helpers/ImageHelper.kt index 7103bd0972..55b380f6ce 100644 --- a/app/src/main/java/com/github/libretube/helpers/ImageHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/ImageHelper.kt @@ -2,10 +2,9 @@ package com.github.libretube.helpers import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Color -import android.net.Uri import android.widget.ImageView +import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmapOrNull import coil.ImageLoader import coil.disk.DiskCache @@ -15,7 +14,6 @@ import com.github.libretube.BuildConfig import com.github.libretube.api.CronetHelper import com.github.libretube.constants.PreferenceKeys import com.github.libretube.extensions.toAndroidUri -import com.github.libretube.extensions.toAndroidUriOrNull import com.github.libretube.util.DataSaverMode import com.google.net.cronet.okhttptransport.CronetInterceptor import kotlinx.coroutines.Dispatchers @@ -26,7 +24,7 @@ import java.io.File import java.nio.file.Path object ImageHelper { - lateinit var imageLoader: ImageLoader + private lateinit var imageLoader: ImageLoader private val Context.coilFile get() = cacheDir.resolve("coil") @@ -119,14 +117,15 @@ object ImageHelper { return imageLoader.execute(request).drawable?.toBitmapOrNull() } - fun getDownloadedImage(context: Context, path: Path): Bitmap? { - return path.toAndroidUriOrNull()?.let { getImage(context, it) } - } + fun getImageWithCallback(context: Context, url: String?, onBitmap: (Bitmap) -> Unit) { + val request = ImageRequest.Builder(context) + .data(url) + .target { drawable -> + onBitmap(drawable.toBitmap()) + } + .build() - private fun getImage(context: Context, imagePath: Uri): Bitmap? { - return context.contentResolver.openInputStream(imagePath)?.use { - BitmapFactory.decodeStream(it) - } + imageLoader.enqueue(request) } /** diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt index 6c1a1b0a10..4e617345ad 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt @@ -82,7 +82,7 @@ class DownloadsAdapter( } download.thumbnailPath?.let { path -> - thumbnailImage.setImageBitmap(ImageHelper.getDownloadedImage(context, path)) + ImageHelper.loadImage(path.toString(), thumbnailImage) } progressBar.setOnClickListener { diff --git a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt index 7312d797e7..59ad7b8533 100644 --- a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt +++ b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt @@ -96,26 +96,13 @@ class NowPlayingNotification( } private fun enqueueThumbnailRequest(callback: (Bitmap) -> Unit) { - // If playing a downloaded file, show the downloaded thumbnail instead of loading an - // online image - notificationData?.thumbnailPath?.let { path -> - ImageHelper.getDownloadedImage(context, path)?.let { - notificationBitmap = processBitmap(it) - callback.invoke(notificationBitmap!!) - } - return + ImageHelper.getImageWithCallback( + context, + notificationData?.thumbnailPath?.toString() ?: notificationData?.thumbnailUrl + ) { + notificationBitmap = processBitmap(it) + callback.invoke(notificationBitmap!!) } - - val request = ImageRequest.Builder(context) - .data(notificationData?.thumbnailUrl) - .target { - notificationBitmap = processBitmap(it.toBitmap()) - callback.invoke(notificationBitmap!!) - } - .build() - - // enqueue the thumbnail loading request - ImageHelper.imageLoader.enqueue(request) } private fun processBitmap(bitmap: Bitmap): Bitmap { @@ -257,18 +244,28 @@ class NowPlayingNotification( private fun createPlaybackState(@PlaybackStateCompat.State state: Int): PlaybackStateCompat { val stateActions = PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_REWIND or - PlaybackStateCompat.ACTION_FAST_FORWARD or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_SEEK_TO + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_REWIND or + PlaybackStateCompat.ACTION_FAST_FORWARD or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_SEEK_TO return PlaybackStateCompat.Builder() .setActions(stateActions) - .addCustomAction(createMediaSessionAction(R.drawable.ic_rewind_md, PlayerEvent.Rewind.name)) - .addCustomAction(createMediaSessionAction(R.drawable.ic_forward_md, PlayerEvent.Forward.name)) + .addCustomAction( + createMediaSessionAction( + R.drawable.ic_rewind_md, + PlayerEvent.Rewind.name + ) + ) + .addCustomAction( + createMediaSessionAction( + R.drawable.ic_forward_md, + PlayerEvent.Forward.name + ) + ) .setState(state, player.currentPosition, player.playbackParameters.speed) .build() } From f0fb359b5d01f6ec29065da16b729f98d9ceeccc Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 6 Oct 2024 15:03:53 +0200 Subject: [PATCH 4/6] feat: split downloads fragment into audio and video category --- .../services/OfflinePlayerService.kt | 1 - .../libretube/ui/adapters/DownloadsAdapter.kt | 13 +- .../ui/fragments/DownloadsFragment.kt | 226 ++++++++++++------ .../res/layout/fragment_download_content.xml | 79 ++++++ .../main/res/layout/fragment_downloads.xml | 76 +----- 5 files changed, 257 insertions(+), 138 deletions(-) create mode 100644 app/src/main/res/layout/fragment_download_content.xml 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 7140574479..f3f8cfe983 100644 --- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt @@ -7,7 +7,6 @@ import androidx.media3.common.MediaItem 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.DatabaseHolder.Database import com.github.libretube.enums.FileType import com.github.libretube.extensions.toAndroidUri diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt index 4e617345ad..2d3f5876b2 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt @@ -16,9 +16,11 @@ import com.github.libretube.databinding.DownloadedMediaRowBinding import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.obj.DownloadWithItems import com.github.libretube.extensions.formatAsFileSize +import com.github.libretube.helpers.BackgroundHelper import com.github.libretube.helpers.ImageHelper import com.github.libretube.ui.activities.OfflinePlayerActivity import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.sheets.DownloadOptionsBottomSheet import com.github.libretube.ui.sheets.DownloadOptionsBottomSheet.Companion.DELETE_DOWNLOAD_REQUEST_KEY import com.github.libretube.ui.viewholders.DownloadsViewHolder @@ -32,6 +34,7 @@ import kotlin.io.path.fileSize class DownloadsAdapter( private val context: Context, + private val downloadTab: DownloadTab, private val downloads: MutableList, private val toggleDownload: (DownloadWithItems) -> Boolean ) : RecyclerView.Adapter() { @@ -98,9 +101,13 @@ class DownloadsAdapter( } root.setOnClickListener { - val intent = Intent(root.context, OfflinePlayerActivity::class.java) - intent.putExtra(IntentData.videoId, download.videoId) - root.context.startActivity(intent) + if (downloadTab == DownloadTab.VIDEO) { + val intent = Intent(root.context, OfflinePlayerActivity::class.java) + intent.putExtra(IntentData.videoId, download.videoId) + root.context.startActivity(intent) + } else { + BackgroundHelper.playOnBackgroundOffline(root.context, download.videoId) + } } root.setOnLongClickListener { 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 b5de74d1b8..cd18db8a8b 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 @@ -11,20 +11,26 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter import com.github.libretube.R +import com.github.libretube.constants.IntentData import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.databinding.FragmentDownloadContentBinding import com.github.libretube.databinding.FragmentDownloadsBinding import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.enums.FileType import com.github.libretube.extensions.ceilHalf import com.github.libretube.extensions.formatAsFileSize -import com.github.libretube.helpers.BackgroundHelper +import com.github.libretube.extensions.serializable import com.github.libretube.helpers.DownloadHelper import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.obj.DownloadStatus @@ -35,20 +41,77 @@ import com.github.libretube.ui.base.DynamicLayoutManagerFragment import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlin.io.path.fileSize -class DownloadsFragment : DynamicLayoutManagerFragment() { +enum class DownloadTab { + VIDEO, + AUDIO +} + +class DownloadsFragment : Fragment() { private var _binding: FragmentDownloadsBinding? = null private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentDownloadsBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.downloadsPager.adapter = DownloadsFragmentAdapter(this) + + TabLayoutMediator(binding.tabLayout, binding.downloadsPager) { tab, position -> + tab.text = when (position) { + DownloadTab.VIDEO.ordinal -> getString(R.string.video) + DownloadTab.AUDIO.ordinal -> getString(R.string.audio) + else -> throw IllegalArgumentException() + } + }.attach() + } + + fun bindDownloadService() { + childFragmentManager.fragments.filterIsInstance().forEach { + it.bindDownloadService() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +class DownloadsFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { + override fun getItemCount() = DownloadTab.entries.size + + override fun createFragment(position: Int): Fragment { + return DownloadsFragmentPage().apply { + arguments = bundleOf(IntentData.currentPosition to DownloadTab.entries[position]) + } + } +} + +class DownloadsFragmentPage : DynamicLayoutManagerFragment() { private lateinit var adapter: DownloadsAdapter + private var _binding: FragmentDownloadContentBinding? = null + private val binding get() = _binding!! + private var binder: DownloadService.LocalBinder? = null private val downloads = mutableListOf() private val downloadReceiver = DownloadReceiver() + private lateinit var downloadTabSelf: DownloadTab private val serviceConnection = object : ServiceConnection { var isBound = false @@ -71,21 +134,28 @@ class DownloadsFragment : DynamicLayoutManagerFragment() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + this.downloadTabSelf = requireArguments().serializable(IntentData.currentPosition)!! + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentDownloadsBinding.inflate(inflater) + _binding = FragmentDownloadContentBinding.inflate(layoutInflater) return binding.root } override fun setLayoutManagers(gridItems: Int) { - _binding?.downloads?.layoutManager = GridLayoutManager(context, gridItems.ceilHalf()) + _binding?.downloadsRecView?.layoutManager = GridLayoutManager(context, gridItems.ceilHalf()) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + var selectedSortType = PreferenceHelper.getInt(PreferenceKeys.SELECTED_DOWNLOAD_SORT_TYPE, 0) val filterOptions = resources.getStringArray(R.array.downloadSortOptions) @@ -95,6 +165,7 @@ class DownloadsFragment : DynamicLayoutManagerFragment() { binding.sortType.text = filterOptions[index] if (::adapter.isInitialized) { sortDownloadList(index, selectedSortType) + adapter.notifyDataSetChanged() } selectedSortType = index PreferenceHelper.putInt( @@ -104,96 +175,111 @@ class DownloadsFragment : DynamicLayoutManagerFragment() { }.show(childFragmentManager) } - val dbDownloads = runBlocking(Dispatchers.IO) { - Database.downloadDao().getAll() - }.takeIf { it.isNotEmpty() } ?: return - - downloads.clear() - downloads.addAll(dbDownloads) - binding.downloadsEmpty.isGone = true - binding.downloads.isVisible = true - adapter = DownloadsAdapter(requireContext(), downloads) { - var isDownloading = false - val ids = it.downloadItems - .filter { item -> item.path.fileSize() < item.downloadSize } - .map { item -> item.id } - - if (!serviceConnection.isBound) { - DownloadHelper.startDownloadService(requireContext()) - bindDownloadService(ids.toIntArray()) - return@DownloadsAdapter true + lifecycleScope.launch { + val dbDownloads = withContext(Dispatchers.IO) { + Database.downloadDao().getAll() } - binder?.getService()?.let { service -> - isDownloading = ids.any { id -> service.isDownloading(id) } + downloads.clear() + downloads.addAll(dbDownloads.filter { dl -> + when (downloadTabSelf) { + DownloadTab.AUDIO -> { + dl.downloadItems.any { it.type == FileType.AUDIO } && dl.downloadItems.none { it.type == FileType.VIDEO } + } - ids.forEach { id -> - if (isDownloading) { - service.pause(id) - } else { - service.resume(id) + DownloadTab.VIDEO -> { + dl.downloadItems.any { it.type == FileType.VIDEO } } } - } - return@DownloadsAdapter isDownloading.not() - } - sortDownloadList(selectedSortType) - binding.downloads.adapter = adapter - - val itemTouchCallback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int = makeMovementFlags(0, ItemTouchHelper.LEFT) - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean = false - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - adapter.showDeleteDialog(requireContext(), viewHolder.absoluteAdapterPosition) - // put the item back to the center, as it's currently out of the screen - adapter.restoreItem(viewHolder.absoluteAdapterPosition) - } - } - ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.downloads) + }) - binding.downloads.adapter?.registerAdapterDataObserver( - object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { - super.onItemRangeRemoved(positionStart, itemCount) - toggleButtonsVisibility() + if (downloads.isEmpty()) return@launch + + sortDownloadList(selectedSortType) + + adapter = DownloadsAdapter(requireContext(), downloadTabSelf, downloads) { + var isDownloading = false + val ids = it.downloadItems + .filter { item -> item.path.fileSize() < item.downloadSize } + .map { item -> item.id } + + if (!serviceConnection.isBound) { + DownloadHelper.startDownloadService(requireContext()) + bindDownloadService(ids.toIntArray()) + return@DownloadsAdapter true + } + + binder?.getService()?.let { service -> + isDownloading = ids.any { id -> service.isDownloading(id) } + + ids.forEach { id -> + if (isDownloading) { + service.pause(id) + } else { + service.resume(id) + } + } } + return@DownloadsAdapter isDownloading.not() } - ) + binding.downloadsRecView.adapter = adapter + + val itemTouchCallback = + object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int = makeMovementFlags(0, ItemTouchHelper.LEFT) + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + adapter.showDeleteDialog( + requireContext(), + viewHolder.absoluteAdapterPosition + ) + // put the item back to the center, as it's currently out of the screen + adapter.restoreItem(viewHolder.absoluteAdapterPosition) + } + } + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.downloadsRecView) - toggleButtonsVisibility() + binding.downloadsRecView.adapter?.registerAdapterDataObserver( + object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + super.onItemRangeRemoved(positionStart, itemCount) + toggleVisibilities() + } + } + ) + + toggleVisibilities() + } binding.deleteAll.setOnClickListener { showDeleteAllDialog(binding.root.context, adapter) } } - private fun toggleButtonsVisibility() { + private fun toggleVisibilities() { val binding = _binding ?: return - val isEmpty = binding.downloads.adapter?.itemCount == 0 + val isEmpty = downloads.isEmpty() binding.downloadsEmpty.isVisible = isEmpty - binding.downloads.isGone = isEmpty - binding.sortType.isGone = isEmpty + binding.downloadsContainer.isGone = isEmpty binding.deleteAll.isGone = isEmpty } private fun sortDownloadList(sortType: Int, previousSortType: Int? = null) { if (previousSortType == null && sortType == 1) { downloads.reverse() - adapter.notifyDataSetChanged() } if (previousSortType != null && sortType != previousSortType) { downloads.reverse() - adapter.notifyDataSetChanged() } } @@ -246,7 +332,7 @@ class DownloadsFragment : DynamicLayoutManagerFragment() { it.downloadItems.any { item -> item.id == id } } val view = - _binding?.downloads?.findViewHolderForAdapterPosition(index) as? DownloadsViewHolder + _binding?.downloadsRecView?.findViewHolderForAdapterPosition(index) as? DownloadsViewHolder view?.binding?.apply { when (status) { @@ -293,4 +379,4 @@ class DownloadsFragment : DynamicLayoutManagerFragment() { super.onDestroyView() _binding = null } -} +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_download_content.xml b/app/src/main/res/layout/fragment_download_content.xml new file mode 100644 index 0000000000..d8993f8da9 --- /dev/null +++ b/app/src/main/res/layout/fragment_download_content.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml index c1a9119c4d..0fa7a99d79 100644 --- a/app/src/main/res/layout/fragment_downloads.xml +++ b/app/src/main/res/layout/fragment_downloads.xml @@ -1,79 +1,27 @@ - - - - + android:layout_height="match_parent" + android:orientation="vertical"> - - + app:tabMode="scrollable" /> - + - + \ No newline at end of file From 9b68e4faea198aa47c27c795df33c8e5dcacb209 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 6 Oct 2024 16:03:00 +0200 Subject: [PATCH 5/6] feat: audio player UI for downloads --- .../github/libretube/constants/IntentData.kt | 1 + .../libretube/helpers/BackgroundHelper.kt | 13 +++---- .../libretube/helpers/NavigationHelper.kt | 7 ++-- .../services/AbstractPlayerService.kt | 36 +++++++++++++++---- .../services/OfflinePlayerService.kt | 17 +++++---- .../libretube/services/OnlinePlayerService.kt | 30 +++------------- .../ui/activities/OfflinePlayerActivity.kt | 3 +- .../libretube/ui/adapters/DownloadsAdapter.kt | 7 ++++ .../ui/fragments/AudioPlayerFragment.kt | 35 ++++++++++++------ .../libretube/ui/fragments/PlayerFragment.kt | 3 +- .../ui/sheets/VideoOptionsBottomSheet.kt | 2 +- .../libretube/util/NowPlayingNotification.kt | 20 ++--------- 12 files changed, 93 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index 3ca4ee38cb..6e81ff13a5 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -48,4 +48,5 @@ object IntentData { const val videoList = "videoList" const val nextPage = "nextPage" const val videoInfo = "videoInfo" + const val offlinePlayer = "offlinePlayer" } diff --git a/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt b/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt index cfbf75c953..667d2ee8d6 100644 --- a/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt @@ -50,13 +50,9 @@ object BackgroundHelper { /** * Stop the [OnlinePlayerService] service if it is running. */ - fun stopBackgroundPlay( - context: Context, - serviceClass: Class<*> = OnlinePlayerService::class.java - ) { - if (isBackgroundServiceRunning(context, serviceClass)) { - // Intent to stop background mode service - val intent = Intent(context, serviceClass) + fun stopBackgroundPlay(context: Context) { + arrayOf(OnlinePlayerService::class.java, OfflinePlayerService::class.java).forEach { + val intent = Intent(context, it) context.stopService(intent) } } @@ -80,10 +76,11 @@ object BackgroundHelper { * @param videoId the videoId of the video or null if all available downloads should be shuffled */ fun playOnBackgroundOffline(context: Context, videoId: String?) { + stopBackgroundPlay(context) + val playerIntent = Intent(context, OfflinePlayerService::class.java) .putExtra(IntentData.videoId, videoId) - context.stopService(playerIntent) ContextCompat.startForegroundService(context, playerIntent) } } diff --git a/app/src/main/java/com/github/libretube/helpers/NavigationHelper.kt b/app/src/main/java/com/github/libretube/helpers/NavigationHelper.kt index a7bf920485..0212ec407e 100644 --- a/app/src/main/java/com/github/libretube/helpers/NavigationHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/NavigationHelper.kt @@ -96,10 +96,13 @@ object NavigationHelper { /** * Start the audio player fragment */ - fun startAudioPlayer(context: Context, minimizeByDefault: Boolean = false) { + fun startAudioPlayer(context: Context, offlinePlayer: Boolean = false, minimizeByDefault: Boolean = false) { val activity = ContextHelper.unwrapActivity(context) activity.supportFragmentManager.commitNow { - val args = bundleOf(IntentData.minimizeByDefault to minimizeByDefault) + val args = bundleOf( + IntentData.minimizeByDefault to minimizeByDefault, + IntentData.offlinePlayer to offlinePlayer + ) replace(R.id.container, args = args) } } 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 25902b5d2d..92a71da2c1 100644 --- a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt @@ -4,9 +4,11 @@ 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.util.Log import android.widget.Toast import androidx.annotation.OptIn import androidx.core.app.NotificationCompat @@ -22,6 +24,8 @@ 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.obj.ChapterSegment +import com.github.libretube.api.obj.StreamItem import com.github.libretube.enums.NotificationId import com.github.libretube.enums.PlayerEvent import com.github.libretube.extensions.serializableExtra @@ -43,6 +47,14 @@ abstract class AbstractPlayerService : LifecycleService() { val handler = Handler(Looper.getMainLooper()) + private val binder = LocalBinder() + + /** + * Listener for passing playback state changes to the AudioPlayerFragment + */ + var onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null + var onNewVideoStarted: ((streamItem: StreamItem) -> Unit)? = null + private val watchPositionTimer = PauseableTimer( onTick = ::saveWatchPosition, delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS @@ -58,11 +70,15 @@ abstract class AbstractPlayerService : LifecycleService() { } else { watchPositionTimer.pause() } + + onStateOrPlayingChanged?.let { it(isPlaying) } } override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) + onStateOrPlayingChanged?.let { it(player?.isPlaying ?: false) } + this@AbstractPlayerService.onPlaybackStateChanged(playbackState) } @@ -162,8 +178,7 @@ abstract class AbstractPlayerService : LifecycleService() { nowPlayingNotification = NowPlayingNotification( this, - player!!, - NowPlayingNotification.Companion.NowPlayingNotificationType.AUDIO_OFFLINE + player!! ) } @@ -202,11 +217,6 @@ abstract class AbstractPlayerService : LifecycleService() { super.onDestroy() } - override fun onBind(intent: Intent): IBinder? { - super.onBind(intent) - return null - } - /** * Stop the service when app is removed from the task manager. */ @@ -217,9 +227,21 @@ abstract class AbstractPlayerService : LifecycleService() { abstract fun onPlaybackStateChanged(playbackState: Int) + abstract fun getChapters(): List + fun getCurrentPosition() = player?.currentPosition fun getDuration() = player?.duration fun seekToPosition(position: Long) = player?.seekTo(position) + + inner class LocalBinder : Binder() { + // Return this instance of [AbstractPlayerService] so clients can call public methods + fun getService(): AbstractPlayerService = this@AbstractPlayerService + } + + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) + return binder + } } 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 f3f8cfe983..68472e07fa 100644 --- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt @@ -1,13 +1,16 @@ package com.github.libretube.services import android.content.Intent -import android.os.IBinder +import android.util.Log import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.constants.IntentData import com.github.libretube.db.DatabaseHolder.Database +import com.github.libretube.db.obj.DownloadChapter +import com.github.libretube.db.obj.DownloadWithItems import com.github.libretube.enums.FileType import com.github.libretube.extensions.toAndroidUri import com.github.libretube.extensions.toID @@ -24,6 +27,8 @@ import kotlin.io.path.exists */ @UnstableApi class OfflinePlayerService : AbstractPlayerService() { + private var downloadWithItems: DownloadWithItems? = null + override suspend fun onServiceCreated(intent: Intent) { videoId = intent.getStringExtra(IntentData.videoId) ?: return @@ -43,6 +48,8 @@ class OfflinePlayerService : AbstractPlayerService() { val downloadWithItems = withContext(Dispatchers.IO) { Database.downloadDao().findById(videoId) } + this.downloadWithItems = downloadWithItems + onNewVideoStarted?.let { it(downloadWithItems.download.toStreamItem()) } PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem()) @@ -96,11 +103,6 @@ class OfflinePlayerService : AbstractPlayerService() { } } - override fun onBind(intent: Intent): IBinder? { - super.onBind(intent) - return null - } - /** * Stop the service when app is removed from the task manager. */ @@ -115,4 +117,7 @@ class OfflinePlayerService : AbstractPlayerService() { playNextVideo(PlayingQueue.getNext() ?: return) } } + + override fun getChapters(): List = + downloadWithItems?.downloadChapters.orEmpty().map(DownloadChapter::toChapterSegment) } 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 1ac334f970..f08613650a 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -1,8 +1,6 @@ package com.github.libretube.services import android.content.Intent -import android.os.Binder -import android.os.IBinder import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem @@ -11,6 +9,7 @@ import androidx.media3.common.Player import com.github.libretube.api.JsonHelper import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.StreamsExtractor +import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Streams import com.github.libretube.constants.IntentData @@ -54,17 +53,6 @@ class OnlinePlayerService : AbstractPlayerService() { private var segments = listOf() private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() - /** - * Used for connecting to the AudioPlayerFragment - */ - private val binder = LocalBinder() - - /** - * Listener for passing playback state changes to the AudioPlayerFragment - */ - var onStateOrPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null - var onNewVideo: ((streams: Streams, videoId: String) -> Unit)? = null - override suspend fun onServiceCreated(intent: Intent) { val playerData = intent.parcelableExtra(IntentData.playerData) if (playerData == null) { @@ -143,7 +131,7 @@ class OnlinePlayerService : AbstractPlayerService() { streams?.thumbnailUrl ) nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData) - streams?.let { onNewVideo?.invoke(it, videoId) } + streams?.let { onNewVideoStarted?.invoke(it.toStreamItem(videoId)) } player?.apply { playWhenReady = PlayerHelper.playAutomatically @@ -225,19 +213,7 @@ class OnlinePlayerService : AbstractPlayerService() { player?.checkForSegments(this, segments, sponsorBlockConfig) } - inner class LocalBinder : Binder() { - // Return this instance of [BackgroundMode] so clients can call public methods - fun getService(): OnlinePlayerService = this@OnlinePlayerService - } - - override fun onBind(intent: Intent): IBinder { - super.onBind(intent) - return binder - } - override fun onPlaybackStateChanged(playbackState: Int) { - onStateOrPlayingChanged?.invoke(player?.isPlaying ?: false) - when (playbackState) { Player.STATE_ENDED -> { if (!isTransitioning) playNextVideo() @@ -260,4 +236,6 @@ class OnlinePlayerService : AbstractPlayerService() { } } } + + override fun getChapters(): List = streams?.chapters.orEmpty() } 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 6d0fa50eeb..0d98357094 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 @@ -217,8 +217,7 @@ class OfflinePlayerActivity : BaseActivity() { nowPlayingNotification = NowPlayingNotification( this, - viewModel.player, - NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE + viewModel.player ) } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt index 2d3f5876b2..7d3fff2e4a 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt @@ -3,10 +3,13 @@ package com.github.libretube.ui.adapters import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.os.Handler +import android.os.Looper import android.text.format.DateUtils import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.os.bundleOf +import androidx.core.os.postDelayed import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView @@ -18,6 +21,7 @@ import com.github.libretube.db.obj.DownloadWithItems import com.github.libretube.extensions.formatAsFileSize import com.github.libretube.helpers.BackgroundHelper import com.github.libretube.helpers.ImageHelper +import com.github.libretube.helpers.NavigationHelper import com.github.libretube.ui.activities.OfflinePlayerActivity import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.fragments.DownloadTab @@ -38,6 +42,8 @@ class DownloadsAdapter( private val downloads: MutableList, private val toggleDownload: (DownloadWithItems) -> Boolean ) : RecyclerView.Adapter() { + private val handler = Handler(Looper.getMainLooper()) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder { val binding = DownloadedMediaRowBinding.inflate( LayoutInflater.from(parent.context), @@ -107,6 +113,7 @@ class DownloadsAdapter( root.context.startActivity(intent) } else { BackgroundHelper.playOnBackgroundOffline(root.context, download.videoId) + NavigationHelper.startAudioPlayer(root.context, offlinePlayer = true) } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt index 051450e572..270d8a165b 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt @@ -2,7 +2,6 @@ package com.github.libretube.ui.fragments import android.annotation.SuppressLint import android.content.ComponentName -import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.graphics.Color @@ -11,6 +10,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.text.format.DateUtils +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -23,6 +23,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi import com.github.libretube.R import com.github.libretube.api.obj.StreamItem import com.github.libretube.constants.IntentData @@ -40,6 +41,8 @@ import com.github.libretube.helpers.NavBarHelper import com.github.libretube.helpers.NavigationHelper import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.ThemeHelper +import com.github.libretube.services.AbstractPlayerService +import com.github.libretube.services.OfflinePlayerService import com.github.libretube.services.OnlinePlayerService import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.interfaces.AudioPlayerOptions @@ -56,6 +59,7 @@ import com.github.libretube.util.PlayingQueue import kotlinx.coroutines.launch import kotlin.math.abs +@UnstableApi class AudioPlayerFragment : Fragment(), AudioPlayerOptions { private var _binding: FragmentAudioPlayerBinding? = null val binding get() = _binding!! @@ -72,13 +76,13 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { private var handler = Handler(Looper.getMainLooper()) private var isPaused = !PlayerHelper.playAutomatically - private var playerService: OnlinePlayerService? = null + private var playerService: AbstractPlayerService? = null /** Defines callbacks for service binding, passed to bindService() */ private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { // We've bound to LocalService, cast the IBinder and get LocalService instance - val binder = service as OnlinePlayerService.LocalBinder + val binder = service as AbstractPlayerService.LocalBinder playerService = binder.getService() handleServiceConnection() } @@ -90,8 +94,14 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { super.onCreate(savedInstanceState) audioHelper = AudioHelper(requireContext()) - Intent(activity, OnlinePlayerService::class.java).also { intent -> - activity?.bindService(intent, connection, Context.BIND_AUTO_CREATE) + + val isOffline = requireArguments().getBoolean(IntentData.offlinePlayer) + + val serviceClass = + if (isOffline) OfflinePlayerService::class.java else OnlinePlayerService::class.java + Log.e("class", serviceClass.name.toString()) + Intent(activity, serviceClass).also { intent -> + activity?.bindService(intent, connection, 0) } } @@ -188,7 +198,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { binding.openChapters.setOnClickListener { val playerService = playerService ?: return@setOnClickListener - chaptersModel.chaptersLiveData.value = playerService.streams?.chapters.orEmpty() + chaptersModel.chaptersLiveData.value = playerService.getChapters() ChaptersBottomSheet() .apply { @@ -380,11 +390,15 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { updatePlayPauseButton() isPaused = !isPlaying } - playerService?.onNewVideo = { streams, videoId -> - updateStreamInfo(streams.toStreamItem(videoId)) - _binding?.openChapters?.isVisible = streams.chapters.isNotEmpty() + playerService?.onNewVideoStarted = { streamItem -> + updateStreamInfo(streamItem) + _binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty() } initializeSeekBar() + + if (playerService is OfflinePlayerService) { + binding.openVideo.isGone = true + } } override fun onDestroyView() { @@ -462,7 +476,8 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { val player = playerService?.player ?: return - val currentIndex = PlayerHelper.getCurrentChapterIndex(player.currentPosition, chaptersModel.chapters) + val currentIndex = + PlayerHelper.getCurrentChapterIndex(player.currentPosition, chaptersModel.chapters) chaptersModel.currentChapterIndex.updateIfChanged(currentIndex ?: return) } } 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 c33a657fa1..757cc17084 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 @@ -1358,8 +1358,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (viewModel.nowPlayingNotification == null) { viewModel.nowPlayingNotification = NowPlayingNotification( requireContext(), - viewModel.player, - NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_ONLINE + viewModel.player ) } val playerNotificationData = PlayerNotificationData( diff --git a/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt index 896d0bbfb7..c13d4231a9 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt @@ -58,7 +58,7 @@ class VideoOptionsBottomSheet : BaseBottomSheet() { // Start the background mode R.string.playOnBackground -> { BackgroundHelper.playOnBackground(requireContext(), videoId) - NavigationHelper.startAudioPlayer(requireContext(), true) + NavigationHelper.startAudioPlayer(requireContext(), minimizeByDefault = true) } // Add Video to Playlist Dialog R.string.addToPlaylist -> { diff --git a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt index 59ad7b8533..b1b15edd80 100644 --- a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt +++ b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt @@ -13,12 +13,10 @@ import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.getSystemService -import androidx.core.graphics.drawable.toBitmap import androidx.media.app.NotificationCompat.MediaStyle import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer -import coil.request.ImageRequest import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME import com.github.libretube.R import com.github.libretube.constants.IntentData @@ -35,8 +33,7 @@ import java.util.UUID @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class NowPlayingNotification( private val context: Context, - private val player: ExoPlayer, - private val notificationType: NowPlayingNotificationType + private val player: ExoPlayer ) { private var videoId: String? = null private val nManager = context.getSystemService()!! @@ -77,10 +74,8 @@ class NowPlayingNotification( // is set to "singleTop" in the AndroidManifest (important!!!) // that's the only way to launch back into the previous activity (e.g. the player view val intent = Intent(context, MainActivity::class.java).apply { - if (notificationType == NowPlayingNotificationType.AUDIO_ONLINE) { - putExtra(IntentData.openAudioPlayer, true) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } + putExtra(IntentData.openAudioPlayer, true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } return PendingIntentCompat @@ -355,13 +350,4 @@ class NowPlayingNotification( fun refreshNotification() { createOrUpdateNotification() } - - companion object { - enum class NowPlayingNotificationType { - VIDEO_ONLINE, - VIDEO_OFFLINE, - AUDIO_ONLINE, - AUDIO_OFFLINE - } - } } From 56330118a6457cd96979798bcc5e213a6ff294f4 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Sun, 6 Oct 2024 16:12:52 +0200 Subject: [PATCH 6/6] fix: only add downloads of same type (audio/video) to queue --- .../github/libretube/constants/IntentData.kt | 1 + .../libretube/db/obj/DownloadWithItems.kt | 14 +++++++++++++ .../libretube/helpers/BackgroundHelper.kt | 4 +++- .../services/OfflinePlayerService.kt | 8 ++++++-- .../ui/activities/OfflinePlayerActivity.kt | 4 +++- .../libretube/ui/adapters/DownloadsAdapter.kt | 10 +++------- .../ui/fragments/AudioPlayerFragment.kt | 2 -- .../ui/fragments/DownloadsFragment.kt | 20 +++++-------------- .../ui/sheets/DownloadOptionsBottomSheet.kt | 5 ++++- 9 files changed, 39 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index 6e81ff13a5..24626b00e4 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -49,4 +49,5 @@ object IntentData { const val nextPage = "nextPage" const val videoInfo = "videoInfo" const val offlinePlayer = "offlinePlayer" + const val downloadTab = "downloadTab" } 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 9031b60ddb..ae390b7ea7 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 @@ -2,6 +2,8 @@ package com.github.libretube.db.obj import androidx.room.Embedded import androidx.room.Relation +import com.github.libretube.enums.FileType +import com.github.libretube.ui.fragments.DownloadTab data class DownloadWithItems( @Embedded val download: Download, @@ -16,3 +18,15 @@ data class DownloadWithItems( ) val downloadChapters: List = emptyList() ) + +fun List.filterByTab(tab: DownloadTab) = filter { dl -> + when (tab) { + DownloadTab.AUDIO -> { + dl.downloadItems.any { it.type == FileType.AUDIO } && dl.downloadItems.none { it.type == FileType.VIDEO } + } + + DownloadTab.VIDEO -> { + dl.downloadItems.any { it.type == FileType.VIDEO } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt b/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt index 667d2ee8d6..9c9a862d7c 100644 --- a/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt @@ -10,6 +10,7 @@ import com.github.libretube.constants.IntentData import com.github.libretube.parcelable.PlayerData import com.github.libretube.services.OfflinePlayerService import com.github.libretube.services.OnlinePlayerService +import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.fragments.PlayerFragment /** @@ -75,11 +76,12 @@ object BackgroundHelper { * @param context the current context * @param videoId the videoId of the video or null if all available downloads should be shuffled */ - fun playOnBackgroundOffline(context: Context, videoId: String?) { + fun playOnBackgroundOffline(context: Context, videoId: String?, downloadTab: DownloadTab) { stopBackgroundPlay(context) val playerIntent = Intent(context, OfflinePlayerService::class.java) .putExtra(IntentData.videoId, videoId) + .putExtra(IntentData.downloadTab, downloadTab) ContextCompat.startForegroundService(context, playerIntent) } 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 68472e07fa..91003c5e50 100644 --- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt @@ -1,7 +1,6 @@ package com.github.libretube.services import android.content.Intent -import android.util.Log import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player @@ -11,11 +10,14 @@ import com.github.libretube.constants.IntentData import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.obj.DownloadChapter import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.db.obj.filterByTab import com.github.libretube.enums.FileType +import com.github.libretube.extensions.serializableExtra 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.ui.fragments.DownloadTab import com.github.libretube.util.PlayingQueue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -28,9 +30,11 @@ import kotlin.io.path.exists @UnstableApi class OfflinePlayerService : AbstractPlayerService() { private var downloadWithItems: DownloadWithItems? = null + private lateinit var downloadTab: DownloadTab override suspend fun onServiceCreated(intent: Intent) { videoId = intent.getStringExtra(IntentData.videoId) ?: return + downloadTab = intent.serializableExtra(IntentData.downloadTab)!! PlayingQueue.clear() @@ -88,7 +92,7 @@ class OfflinePlayerService : AbstractPlayerService() { private suspend fun fillQueue() { val downloads = withContext(Dispatchers.IO) { Database.downloadDao().getAll() - } + }.filterByTab(downloadTab) PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() }) } 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 0d98357094..4e51f5cc77 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 @@ -32,6 +32,7 @@ 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.db.obj.filterByTab import com.github.libretube.enums.FileType import com.github.libretube.enums.PlayerEvent import com.github.libretube.extensions.serializableExtra @@ -41,6 +42,7 @@ import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.WindowHelper import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.interfaces.TimeFrameReceiver import com.github.libretube.ui.listeners.SeekbarPreviewListener import com.github.libretube.ui.models.ChaptersViewModel @@ -324,7 +326,7 @@ class OfflinePlayerActivity : BaseActivity() { private suspend fun fillQueue() { val downloads = withContext(Dispatchers.IO) { Database.downloadDao().getAll() - } + }.filterByTab(DownloadTab.VIDEO) PlayingQueue.insertRelatedStreams(downloads.map { it.download.toStreamItem() }) } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt index 7d3fff2e4a..dbe718a90a 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt @@ -3,13 +3,10 @@ package com.github.libretube.ui.adapters import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.os.Handler -import android.os.Looper import android.text.format.DateUtils import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.os.bundleOf -import androidx.core.os.postDelayed import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView @@ -42,8 +39,6 @@ class DownloadsAdapter( private val downloads: MutableList, private val toggleDownload: (DownloadWithItems) -> Boolean ) : RecyclerView.Adapter() { - private val handler = Handler(Looper.getMainLooper()) - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder { val binding = DownloadedMediaRowBinding.inflate( LayoutInflater.from(parent.context), @@ -112,7 +107,7 @@ class DownloadsAdapter( intent.putExtra(IntentData.videoId, download.videoId) root.context.startActivity(intent) } else { - BackgroundHelper.playOnBackgroundOffline(root.context, download.videoId) + BackgroundHelper.playOnBackgroundOffline(root.context, download.videoId, downloadTab) NavigationHelper.startAudioPlayer(root.context, offlinePlayer = true) } } @@ -130,7 +125,8 @@ class DownloadsAdapter( .apply { arguments = bundleOf( IntentData.videoId to download.videoId, - IntentData.channelName to download.uploader + IntentData.channelName to download.uploader, + IntentData.downloadTab to downloadTab ) } .show(fragmentManager) diff --git a/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt index 270d8a165b..dbfb6a4dd5 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt @@ -10,7 +10,6 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.text.format.DateUtils -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -99,7 +98,6 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { val serviceClass = if (isOffline) OfflinePlayerService::class.java else OnlinePlayerService::class.java - Log.e("class", serviceClass.name.toString()) Intent(activity, serviceClass).also { intent -> activity?.bindService(intent, connection, 0) } 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 cd18db8a8b..e42e0d69b8 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 @@ -27,7 +27,7 @@ import com.github.libretube.databinding.FragmentDownloadContentBinding import com.github.libretube.databinding.FragmentDownloadsBinding import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.obj.DownloadWithItems -import com.github.libretube.enums.FileType +import com.github.libretube.db.obj.filterByTab import com.github.libretube.extensions.ceilHalf import com.github.libretube.extensions.formatAsFileSize import com.github.libretube.extensions.serializable @@ -111,7 +111,7 @@ class DownloadsFragmentPage : DynamicLayoutManagerFragment() { private var binder: DownloadService.LocalBinder? = null private val downloads = mutableListOf() private val downloadReceiver = DownloadReceiver() - private lateinit var downloadTabSelf: DownloadTab + private lateinit var downloadTab: DownloadTab private val serviceConnection = object : ServiceConnection { var isBound = false @@ -137,7 +137,7 @@ class DownloadsFragmentPage : DynamicLayoutManagerFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - this.downloadTabSelf = requireArguments().serializable(IntentData.currentPosition)!! + this.downloadTab = requireArguments().serializable(IntentData.currentPosition)!! } override fun onCreateView( @@ -181,23 +181,13 @@ class DownloadsFragmentPage : DynamicLayoutManagerFragment() { } downloads.clear() - downloads.addAll(dbDownloads.filter { dl -> - when (downloadTabSelf) { - DownloadTab.AUDIO -> { - dl.downloadItems.any { it.type == FileType.AUDIO } && dl.downloadItems.none { it.type == FileType.VIDEO } - } - - DownloadTab.VIDEO -> { - dl.downloadItems.any { it.type == FileType.VIDEO } - } - } - }) + downloads.addAll(dbDownloads.filterByTab(downloadTab)) if (downloads.isEmpty()) return@launch sortDownloadList(selectedSortType) - adapter = DownloadsAdapter(requireContext(), downloadTabSelf, downloads) { + adapter = DownloadsAdapter(requireContext(), downloadTab, downloads) { var isDownloading = false val ids = it.downloadItems .filter { item -> item.path.fileSize() < item.downloadSize } diff --git a/app/src/main/java/com/github/libretube/ui/sheets/DownloadOptionsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/DownloadOptionsBottomSheet.kt index 66095580fc..b61bbfe657 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/DownloadOptionsBottomSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/DownloadOptionsBottomSheet.kt @@ -6,14 +6,17 @@ import androidx.fragment.app.setFragmentResult import com.github.libretube.R import com.github.libretube.constants.IntentData import com.github.libretube.enums.ShareObjectType +import com.github.libretube.extensions.serializable import com.github.libretube.helpers.BackgroundHelper import com.github.libretube.helpers.NavigationHelper import com.github.libretube.obj.ShareData import com.github.libretube.ui.dialogs.ShareDialog +import com.github.libretube.ui.fragments.DownloadTab class DownloadOptionsBottomSheet : BaseBottomSheet() { override fun onCreate(savedInstanceState: Bundle?) { val videoId = arguments?.getString(IntentData.videoId)!! + val downloadTab = arguments?.serializable(IntentData.downloadTab)!! val options = listOf( R.string.playOnBackground, @@ -25,7 +28,7 @@ class DownloadOptionsBottomSheet : BaseBottomSheet() { setSimpleItems(options.map { getString(it) }) { which -> when (options[which]) { R.string.playOnBackground -> { - BackgroundHelper.playOnBackgroundOffline(requireContext(), videoId) + BackgroundHelper.playOnBackgroundOffline(requireContext(), videoId, downloadTab) } R.string.go_to_video -> {