feat: audio player UI for downloads

This commit is contained in:
Bnyro 2024-10-06 16:03:00 +02:00
parent f0fb359b5d
commit 9b68e4faea
12 changed files with 93 additions and 81 deletions

View File

@ -48,4 +48,5 @@ object IntentData {
const val videoList = "videoList"
const val nextPage = "nextPage"
const val videoInfo = "videoInfo"
const val offlinePlayer = "offlinePlayer"
}

View File

@ -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)
}
}

View File

@ -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<AudioPlayerFragment>(R.id.container, args = args)
}
}

View File

@ -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<ChapterSegment>
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
}
}

View File

@ -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<ChapterSegment> =
downloadWithItems?.downloadChapters.orEmpty().map(DownloadChapter::toChapterSegment)
}

View File

@ -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<Segment>()
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<PlayerData>(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<ChapterSegment> = streams?.chapters.orEmpty()
}

View File

@ -217,8 +217,7 @@ class OfflinePlayerActivity : BaseActivity() {
nowPlayingNotification = NowPlayingNotification(
this,
viewModel.player,
NowPlayingNotification.Companion.NowPlayingNotificationType.VIDEO_OFFLINE
viewModel.player
)
}

View File

@ -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<DownloadWithItems>,
private val toggleDownload: (DownloadWithItems) -> Boolean
) : RecyclerView.Adapter<DownloadsViewHolder>() {
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)
}
}

View File

@ -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)
}
}

View File

@ -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(

View File

@ -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 -> {

View File

@ -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<NotificationManager>()!!
@ -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
}
}
}