Audio/background player for downloads

This commit is contained in:
Bnyro 2023-04-10 15:54:09 +02:00
parent 57b7dfdda9
commit dcdd1af176
12 changed files with 277 additions and 80 deletions

View File

@ -339,7 +339,12 @@
android:exported="false" />
<service
android:name=".services.BackgroundMode"
android:name=".services.OnlinePlayerService"
android:enabled="true"
android:exported="false" />
<service
android:name=".services.OfflinePlayerService"
android:enabled="true"
android:exported="false" />

View File

@ -7,17 +7,17 @@ import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.commit
import com.github.libretube.constants.IntentData
import com.github.libretube.services.BackgroundMode
import com.github.libretube.services.OnlinePlayerService
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.fragments.PlayerFragment
/**
* Helper for starting a new Instance of the [BackgroundMode]
* Helper for starting a new Instance of the [OnlinePlayerService]
*/
object BackgroundHelper {
/**
* Start the foreground service [BackgroundMode] to play in background. [position]
* Start the foreground service [OnlinePlayerService] to play in background. [position]
* is seek to position specified in milliseconds in the current [videoId].
*/
fun playOnBackground(
@ -39,7 +39,7 @@ object BackgroundHelper {
}
// create an intent for the background mode service
val intent = Intent(context, BackgroundMode::class.java)
val intent = Intent(context, OnlinePlayerService::class.java)
intent.putExtra(IntentData.videoId, videoId)
intent.putExtra(IntentData.playlistId, playlistId)
intent.putExtra(IntentData.channelId, channelId)
@ -51,22 +51,22 @@ object BackgroundHelper {
}
/**
* Stop the [BackgroundMode] service if it is running.
* Stop the [OnlinePlayerService] service if it is running.
*/
fun stopBackgroundPlay(context: Context) {
if (isBackgroundServiceRunning(context)) {
// Intent to stop background mode service
val intent = Intent(context, BackgroundMode::class.java)
val intent = Intent(context, OnlinePlayerService::class.java)
context.stopService(intent)
}
}
/**
* Check if the [BackgroundMode] service is currently running.
* Check if the [OnlinePlayerService] service is currently running.
*/
fun isBackgroundServiceRunning(context: Context): Boolean {
@Suppress("DEPRECATION")
return context.getSystemService<ActivityManager>()!!.getRunningServices(Int.MAX_VALUE)
.any { BackgroundMode::class.java.name == it.service.className }
.any { OnlinePlayerService::class.java.name == it.service.className }
}
}

View File

@ -93,8 +93,7 @@ object ImageHelper {
* Get a squared bitmap with the same width and height from a bitmap
* @param bitmap The bitmap to resize
*/
fun getSquareBitmap(bitmap: Bitmap?): Bitmap? {
bitmap ?: return null
fun getSquareBitmap(bitmap: Bitmap): Bitmap {
val newSize = minOf(bitmap.width, bitmap.height)
return Bitmap.createBitmap(
bitmap,

View File

@ -0,0 +1,10 @@
package com.github.libretube.obj
import java.nio.file.Path
data class PlayerNotificationData(
val title: String? = null,
val uploaderName: String? = null,
val thumbnailUrl: String? = null,
val thumbnailPath: Path? = null
)

View File

@ -0,0 +1,114 @@
package com.github.libretube.services
import android.app.Service
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.github.libretube.R
import com.github.libretube.constants.BACKGROUND_CHANNEL_ID
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PLAYER_NOTIFICATION_ID
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.DownloadWithItems
import com.github.libretube.enums.FileType
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.loadPlaybackParams
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.util.NowPlayingNotification
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A service to play downloaded audio in the background
*/
class OfflinePlayerService : Service() {
private var player: ExoPlayer? = null
private var nowPlayingNotification: NowPlayingNotification? = null
override fun onCreate() {
super.onCreate()
val notification = NotificationCompat.Builder(this, BACKGROUND_CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.playingOnBackground))
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
.build()
startForeground(PLAYER_NOTIFICATION_ID, notification)
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val videoId = intent.getStringExtra(IntentData.videoId)
CoroutineScope(Dispatchers.IO).launch {
val downloadWithItems = DatabaseHolder.Database.downloadDao().findById(videoId!!)
withContext(Dispatchers.Main) {
if (startAudioPlayer(downloadWithItems)) {
nowPlayingNotification = NowPlayingNotification(
this@OfflinePlayerService,
player!!,
true
)
val notificationData = PlayerNotificationData(
title = downloadWithItems.download.title,
uploaderName = downloadWithItems.download.uploader,
thumbnailPath = downloadWithItems.download.thumbnailPath
)
nowPlayingNotification?.updatePlayerNotification(videoId, notificationData)
} else {
onDestroy()
}
}
}
return super.onStartCommand(intent, flags, startId)
}
/**
* Attempt to start an audio player with the given download items
* @param downloadWithItem The database download to play from
* @return whether starting the audio player succeeded
*/
private fun startAudioPlayer(downloadWithItem: DownloadWithItems): Boolean {
player = ExoPlayer.Builder(this)
.setUsePlatformDiagnostics(false)
.setHandleAudioBecomingNoisy(true)
.setAudioAttributes(PlayerHelper.getAudioAttributes(), true)
.setLoadControl(PlayerHelper.getLoadControl())
.build()
.loadPlaybackParams(isBackgroundMode = true).apply {
playWhenReady = true
}
val audioItem = downloadWithItem.downloadItems.firstOrNull { it.type == FileType.AUDIO }
?: // in some rare cases, video files can contain audio
downloadWithItem.downloadItems.firstOrNull { it.type == FileType.VIDEO } ?: return false
val mediaItem = MediaItem.Builder()
.setUri(audioItem.path.toAndroidUri())
.build()
player?.setMediaItem(mediaItem)
player?.prepare()
return true
}
override fun onDestroy() {
nowPlayingNotification?.destroySelfAndPlayer()
player = null
nowPlayingNotification = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
super.onDestroy()
}
override fun onBind(intent: Intent?) = null
}

View File

@ -3,12 +3,12 @@ package com.github.libretube.services
import android.app.Notification
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
@ -28,6 +28,7 @@ import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.PlayerHelper.loadPlaybackParams
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PlayingQueue
import com.google.android.exoplayer2.ExoPlayer
@ -44,7 +45,7 @@ import kotlinx.serialization.encodeToString
/**
* Loads the selected videos audio in background mode with a notification area.
*/
class BackgroundMode : LifecycleService() {
class OnlinePlayerService : LifecycleService() {
/**
* VideoId of the video
*/
@ -97,16 +98,14 @@ class BackgroundMode : LifecycleService() {
*/
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// see https://developer.android.com/reference/android/app/Service#startForeground(int,%20android.app.Notification)
val notification = Notification.Builder(this, BACKGROUND_CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.playingOnBackground))
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
.build()
startForeground(PLAYER_NOTIFICATION_ID, notification)
}
val notification = NotificationCompat.Builder(this, BACKGROUND_CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.playingOnBackground))
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
.build()
startForeground(PLAYER_NOTIFICATION_ID, notification)
}
/**
@ -189,10 +188,19 @@ class BackgroundMode : LifecycleService() {
setMediaItem()
// create the notification
if (!this@BackgroundMode::nowPlayingNotification.isInitialized) {
nowPlayingNotification = NowPlayingNotification(this@BackgroundMode, player!!, true)
if (!this@OnlinePlayerService::nowPlayingNotification.isInitialized) {
nowPlayingNotification = NowPlayingNotification(
this@OnlinePlayerService,
player!!,
true
)
}
nowPlayingNotification.updatePlayerNotification(videoId, streams!!)
val playerNotificationData = PlayerNotificationData(
streams?.title,
streams?.uploader,
streams?.thumbnailUrl
)
nowPlayingNotification.updatePlayerNotification(videoId, playerNotificationData)
player?.apply {
playWhenReady = playWhenReadyPlayer
@ -259,7 +267,7 @@ class BackgroundMode : LifecycleService() {
// show a toast on errors
Handler(Looper.getMainLooper()).post {
Toast.makeText(
this@BackgroundMode.applicationContext,
this@OnlinePlayerService.applicationContext,
error.localizedMessage,
Toast.LENGTH_SHORT
).show()
@ -351,7 +359,7 @@ class BackgroundMode : LifecycleService() {
}
/**
* destroy the [BackgroundMode] foreground service
* destroy the [OnlinePlayerService] foreground service
*/
override fun onDestroy() {
// clear and reset the playing queue
@ -371,7 +379,7 @@ class BackgroundMode : LifecycleService() {
inner class LocalBinder : Binder() {
// Return this instance of [BackgroundMode] so clients can call public methods
fun getService(): BackgroundMode = this@BackgroundMode
fun getService(): OnlinePlayerService = this@OnlinePlayerService
}
override fun onBind(intent: Intent): IBinder {

View File

@ -77,6 +77,7 @@ class OfflinePlayerActivity : BaseActivity() {
.setTrackSelector(trackSelector)
.setLoadControl(PlayerHelper.getLoadControl())
.setAudioAttributes(PlayerHelper.getAudioAttributes(), true)
.setUsePlatformDiagnostics(false)
.build().apply {
addListener(object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {

View File

@ -6,27 +6,24 @@ import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.constants.IntentData
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.ImageHelper
import com.github.libretube.ui.activities.OfflinePlayerActivity
import com.github.libretube.ui.sheets.DownloadOptionsBottomSheet
import com.github.libretube.ui.viewholders.DownloadsViewHolder
import com.github.libretube.util.TextUtils
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.io.path.deleteIfExists
import kotlin.io.path.fileSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
class DownloadsAdapter(
private val context: Context,
private val downloads: MutableList<DownloadWithItems>,
private val toogleDownload: (DownloadWithItems) -> Boolean
private val toggleDownload: (DownloadWithItems) -> Boolean
) : RecyclerView.Adapter<DownloadsViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder {
val binding = DownloadedMediaRowBinding.inflate(
@ -75,7 +72,7 @@ class DownloadsAdapter(
}
progressBar.setOnClickListener {
val isDownloading = toogleDownload(downloads[position])
val isDownloading = toggleDownload(downloads[position])
resumePauseBtn.setImageResource(
if (isDownloading) {
@ -93,24 +90,13 @@ class DownloadsAdapter(
}
root.setOnLongClickListener {
MaterialAlertDialogBuilder(root.context)
.setTitle(R.string.delete)
.setMessage(R.string.irreversible)
.setPositiveButton(R.string.okay) { _, _ ->
items.forEach {
it.path.deleteIfExists()
}
download.thumbnailPath?.deleteIfExists()
runBlocking(Dispatchers.IO) {
DatabaseHolder.Database.downloadDao().deleteDownload(download)
}
downloads.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount)
}
.setNegativeButton(R.string.cancel, null)
.show()
DownloadOptionsBottomSheet(download, items) {
downloads.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount)
}.show(
(root.context as AppCompatActivity).supportFragmentManager
)
true
}
}

View File

@ -29,7 +29,7 @@ import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.obj.ShareData
import com.github.libretube.services.BackgroundMode
import com.github.libretube.services.OnlinePlayerService
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.interfaces.AudioPlayerOptions
@ -59,13 +59,13 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
private var handler = Handler(Looper.getMainLooper())
private var isPaused: Boolean = false
private var playerService: BackgroundMode? = null
private var playerService: OnlinePlayerService? = 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 BackgroundMode.LocalBinder
val binder = service as OnlinePlayerService.LocalBinder
playerService = binder.getService()
handleServiceConnection()
}
@ -77,7 +77,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
super.onCreate(savedInstanceState)
audioHelper = AudioHelper(requireContext())
Intent(activity, BackgroundMode::class.java).also { intent ->
Intent(activity, OnlinePlayerService::class.java).also { intent ->
activity?.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}

View File

@ -73,6 +73,7 @@ import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.PlayerHelper.loadPlaybackParams
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.obj.ShareData
import com.github.libretube.obj.VideoResolution
import com.github.libretube.services.DownloadService
@ -1365,7 +1366,12 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
if (!this::nowPlayingNotification.isInitialized) {
nowPlayingNotification = NowPlayingNotification(requireContext(), exoPlayer, false)
}
nowPlayingNotification.updatePlayerNotification(videoId!!, streams)
val playerNotificationData = PlayerNotificationData(
streams.title,
streams.uploader,
streams.thumbnailUrl
)
nowPlayingNotification.updatePlayerNotification(videoId!!, playerNotificationData)
}
/**

View File

@ -0,0 +1,55 @@
package com.github.libretube.ui.sheets
import android.content.Intent
import android.os.Bundle
import androidx.core.content.ContextCompat
import com.github.libretube.R
import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.Download
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.services.OfflinePlayerService
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.io.path.deleteIfExists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
class DownloadOptionsBottomSheet(
private val download: Download,
private val items: List<DownloadItem>,
private val onDelete: () -> Unit
) : BaseBottomSheet() {
override fun onCreate(savedInstanceState: Bundle?) {
val options = listOf(R.string.playOnBackground, R.string.delete).map { getString(it) }
setSimpleItems(options) { selectedIndex ->
when (selectedIndex) {
0 -> {
val playerIntent = Intent(requireContext(), OfflinePlayerService::class.java)
.putExtra(IntentData.videoId, download.videoId)
ContextCompat.startForegroundService(requireContext(), playerIntent)
}
1 -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.delete)
.setMessage(R.string.irreversible)
.setPositiveButton(R.string.okay) { _, _ ->
items.forEach {
it.path.deleteIfExists()
}
download.thumbnailPath?.deleteIfExists()
runBlocking(Dispatchers.IO) {
DatabaseHolder.Database.downloadDao().deleteDownload(download)
}
onDelete.invoke()
dialog?.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
}
super.onCreate(savedInstanceState)
}
}

View File

@ -21,12 +21,12 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.core.os.bundleOf
import coil.request.ImageRequest
import com.github.libretube.R
import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.BACKGROUND_CHANNEL_ID
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PLAYER_NOTIFICATION_ID
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.ui.activities.MainActivity
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
@ -41,11 +41,11 @@ class NowPlayingNotification(
private val isBackgroundPlayerNotification: Boolean
) {
private var videoId: String? = null
private var streams: Streams? = null
private var notificationData: PlayerNotificationData? = null
private var bitmap: Bitmap? = null
/**
* The [MediaSessionCompat] for the [streams].
* The [MediaSessionCompat] for the [notificationData].
*/
private lateinit var mediaSession: MediaSessionCompat
@ -68,7 +68,7 @@ class NowPlayingNotification(
* sets the title of the notification
*/
override fun getCurrentContentTitle(player: Player): CharSequence {
return streams?.title!!
return notificationData?.title.orEmpty()
}
/**
@ -92,7 +92,7 @@ class NowPlayingNotification(
* the description of the notification (below the title)
*/
override fun getCurrentContentText(player: Player): CharSequence? {
return streams?.uploader
return notificationData?.uploaderName
}
/**
@ -110,21 +110,25 @@ class NowPlayingNotification(
}
override fun getCurrentSubText(player: Player): CharSequence? {
return streams?.uploader
return notificationData?.uploaderName
}
}
private fun enqueueThumbnailRequest(callback: PlayerNotificationManager.BitmapCallback) {
// If playing a downloaded file, show the downloaded thumbnail instead of loading an
// online image
notificationData?.thumbnailPath?.let { path ->
ImageHelper.getDownloadedImage(context, path)?.let {
bitmap = processThumbnailBitmap(it)
callback.onBitmap(bitmap!!)
}
return
}
val request = ImageRequest.Builder(context)
.data(streams?.thumbnailUrl)
.data(notificationData?.thumbnailUrl)
.target {
val bm = it.toBitmap()
// returns the bitmap on Android 13+, for everything below scaled down to a square
bitmap = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
ImageHelper.getSquareBitmap(bm)
} else {
bm
}
bitmap = processThumbnailBitmap(it.toBitmap())
callback.onBitmap(bitmap!!)
}
.build()
@ -155,6 +159,17 @@ class NowPlayingNotification(
}
}
/**
* Returns the bitmap on Android 13+, for everything below scaled down to a square
*/
private fun processThumbnailBitmap(bitmap: Bitmap): Bitmap {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
ImageHelper.getSquareBitmap(bitmap)
} else {
bitmap
}
}
private fun createNotificationAction(drawableRes: Int, actionName: String, instanceId: Int): NotificationCompat.Action {
val intent = Intent(actionName).setPackage(context.packageName)
val pendingIntent = PendingIntentCompat
@ -194,16 +209,14 @@ class NowPlayingNotification(
context.resources,
R.drawable.ic_launcher_monochrome
)
val title = streams?.title!!
val uploader = streams?.uploader
val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON to appIcon,
MediaMetadataCompat.METADATA_KEY_TITLE to title,
MediaMetadataCompat.METADATA_KEY_ARTIST to uploader
MediaMetadataCompat.METADATA_KEY_TITLE to notificationData?.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to notificationData?.uploaderName
)
return MediaDescriptionCompat.Builder()
.setTitle(title)
.setSubtitle(uploader)
.setTitle(notificationData?.title)
.setSubtitle(notificationData?.uploaderName)
.setIconBitmap(appIcon)
.setExtras(extras)
.build()
@ -252,10 +265,10 @@ class NowPlayingNotification(
*/
fun updatePlayerNotification(
videoId: String,
streams: Streams
data: PlayerNotificationData
) {
this.videoId = videoId
this.streams = streams
this.notificationData = data
// reset the thumbnail bitmap in order to become reloaded for the new video
this.bitmap = null