Merge pull request #2678 from Bnyro/audio-player

Integrated audio player UI
This commit is contained in:
Bnyro 2023-01-13 19:46:21 +01:00 committed by GitHub
commit 0632ce05d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 393 additions and 44 deletions

View File

@ -8,7 +8,7 @@ object IntentData {
const val timeStamp = "timeStamp" const val timeStamp = "timeStamp"
const val position = "position" const val position = "position"
const val fileName = "fileName" const val fileName = "fileName"
const val openQueueOnce = "openQueue"
const val keepQueue = "keepQueue" const val keepQueue = "keepQueue"
const val playlistType = "playlistType" const val playlistType = "playlistType"
const val openAudioPlayer = "openAudioPlayer"
} }

View File

@ -93,7 +93,6 @@ object PreferenceKeys {
* Background mode * Background mode
*/ */
const val BACKGROUND_PLAYBACK_SPEED = "background_playback_speed" const val BACKGROUND_PLAYBACK_SPEED = "background_playback_speed"
const val NOTIFICATION_OPEN_QUEUE = "notification_open_queue"
/** /**
* Notifications * Notifications

View File

@ -5,6 +5,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Binder
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
@ -87,6 +88,16 @@ class BackgroundMode : Service() {
*/ */
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
/**
* Used for connecting to the AudioPlayerFragment
*/
private val binder = LocalBinder()
/**
* Listener for passing playback state changes to the AudioPlayerFragment
*/
var onIsPlayingChanged: ((isPlaying: Boolean) -> Unit)? = null
/** /**
* Setting the required [Notification] for running as a foreground service * Setting the required [Notification] for running as a foreground service
*/ */
@ -251,6 +262,11 @@ class BackgroundMode : Service() {
* Plays the next video when the current one ended * Plays the next video when the current one ended
*/ */
player?.addListener(object : Player.Listener { player?.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
onIsPlayingChanged?.invoke(isPlaying)
}
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
when (state) { when (state) {
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
@ -381,7 +397,26 @@ class BackgroundMode : Service() {
super.onDestroy() super.onDestroy()
} }
override fun onBind(p0: Intent?): IBinder? { inner class LocalBinder : Binder() {
return null // Return this instance of [BackgroundMode] so clients can call public methods
fun getService(): BackgroundMode = this@BackgroundMode
}
override fun onBind(p0: Intent?): IBinder {
return binder
}
fun getCurrentPosition() = player?.currentPosition
fun getDuration() = player?.duration
fun seekToPosition(position: Long) = player?.seekTo(position)
fun pause() {
player?.pause()
}
fun play() {
player?.play()
} }
} }

View File

@ -39,11 +39,9 @@ import com.github.libretube.ui.fragments.PlayerFragment
import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.models.SearchViewModel import com.github.libretube.ui.models.SearchViewModel
import com.github.libretube.ui.models.SubscriptionsViewModel import com.github.libretube.ui.models.SubscriptionsViewModel
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.ui.tools.BreakReminder import com.github.libretube.ui.tools.BreakReminder
import com.github.libretube.util.NavBarHelper import com.github.libretube.util.NavBarHelper
import com.github.libretube.util.NetworkHelper import com.github.libretube.util.NetworkHelper
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
import com.github.libretube.util.ThemeHelper import com.github.libretube.util.ThemeHelper
import com.google.android.material.elevation.SurfaceColors import com.google.android.material.elevation.SurfaceColors
@ -221,11 +219,6 @@ class MainActivity : BaseActivity() {
} }
} }
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
menu?.findItem(R.id.action_queue)?.isVisible = PlayingQueue.isNotEmpty()
return super.onPrepareOptionsMenu(menu)
}
/** /**
* Initialize the notification badge showing the amount of new videos * Initialize the notification badge showing the amount of new videos
*/ */
@ -373,10 +366,6 @@ class MainActivity : BaseActivity() {
startActivity(communityIntent) startActivity(communityIntent)
true true
} }
R.id.action_queue -> {
PlayingQueueSheet().show(supportFragmentManager, null)
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
@ -391,6 +380,11 @@ class MainActivity : BaseActivity() {
startActivity(intent) startActivity(intent)
} }
if (intent?.getBooleanExtra(IntentData.openAudioPlayer, false) == true) {
navController.navigate(R.id.audioPlayerFragment)
return
}
intent?.getStringExtra(IntentData.channelId)?.let { intent?.getStringExtra(IntentData.channelId)?.let {
navController.navigate( navController.navigate(
R.id.channelFragment, R.id.channelFragment,
@ -412,6 +406,7 @@ class MainActivity : BaseActivity() {
intent?.getStringExtra(IntentData.videoId)?.let { intent?.getStringExtra(IntentData.videoId)?.let {
loadVideo(it, intent?.getLongExtra(IntentData.timeStamp, 0L)) loadVideo(it, intent?.getLongExtra(IntentData.timeStamp, 0L))
} }
when (intent?.getStringExtra("fragmentToOpen")) { when (intent?.getStringExtra("fragmentToOpen")) {
"home" -> "home" ->
navController.navigate(R.id.homeFragment) navController.navigate(R.id.homeFragment)
@ -422,10 +417,6 @@ class MainActivity : BaseActivity() {
"library" -> "library" ->
navController.navigate(R.id.libraryFragment) navController.navigate(R.id.libraryFragment)
} }
if (intent?.getBooleanExtra(IntentData.openQueueOnce, false) == true) {
PlayingQueueSheet()
.show(supportFragmentManager)
}
} }
private fun loadVideo(videoId: String, timeStamp: Long?) { private fun loadVideo(videoId: String, timeStamp: Long?) {

View File

@ -0,0 +1,181 @@
package com.github.libretube.ui.fragments
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.github.libretube.R
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.databinding.FragmentAudioPlayerBinding
import com.github.libretube.extensions.toID
import com.github.libretube.services.BackgroundMode
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.base.BaseFragment
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper
import com.github.libretube.util.PlayingQueue
class AudioPlayerFragment : BaseFragment() {
private lateinit var binding: FragmentAudioPlayerBinding
private val onTrackChangeListener: (StreamItem) -> Unit = {
updateStreamInfo()
}
private var handler = Handler(Looper.getMainLooper())
private var isPaused: Boolean = false
private lateinit var playerService: BackgroundMode
/** 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
playerService = binder.getService()
handleServiceConnection()
}
override fun onServiceDisconnected(arg0: ComponentName) {
val mainActivity = activity as MainActivity
if (mainActivity.navController.currentDestination?.id == R.id.audioPlayerFragment) {
mainActivity.navController.popBackStack()
} else {
mainActivity.navController.backQueue.removeAll {
it.destination.id == R.id.audioPlayerFragment
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Intent(activity, BackgroundMode::class.java).also { intent ->
activity?.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentAudioPlayerBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.prev.setOnClickListener {
val currentIndex = PlayingQueue.currentIndex()
if (!PlayingQueue.hasPrev()) return@setOnClickListener
PlayingQueue.onQueueItemSelected(currentIndex - 1)
}
binding.next.setOnClickListener {
val currentIndex = PlayingQueue.currentIndex()
if (!PlayingQueue.hasNext()) return@setOnClickListener
PlayingQueue.onQueueItemSelected(currentIndex + 1)
}
binding.thumbnail.setOnClickListener {
PlayingQueueSheet().show(childFragmentManager)
}
// Listen for track changes due to autoplay or the notification
PlayingQueue.addOnTrackChangedListener(onTrackChangeListener)
binding.playPause.setOnClickListener {
if (!this::playerService.isInitialized) return@setOnClickListener
if (isPaused) playerService.play() else playerService.pause()
}
// load the stream info into the UI
updateStreamInfo()
}
/**
* Load the information from a new stream into the UI
*/
private fun updateStreamInfo() {
val current = PlayingQueue.getCurrent()
current ?: return
binding.title.text = current.title
binding.uploader.text = current.uploaderName
binding.uploader.setOnClickListener {
NavigationHelper.navigateChannel(requireContext(), current.uploaderUrl?.toID())
}
ImageHelper.loadImage(current.thumbnail, binding.thumbnail)
initializeSeekBar()
}
private fun initializeSeekBar() {
if (!this::playerService.isInitialized) return
binding.timeBar.addOnChangeListener { _, value, fromUser ->
if (fromUser) playerService.seekToPosition(value.toLong() * 1000)
}
updateSeekBar()
}
/**
* Update the position, duration and text views belonging to the seek bar
*/
private fun updateSeekBar() {
val duration = playerService.getDuration()?.toFloat() ?: return
// when the video is not loaded yet, retry in 100 ms
if (duration <= 0) {
handler.postDelayed(this::updateSeekBar, 100)
return
}
// get the current position from the player service
val currentPosition = playerService.getCurrentPosition()?.toFloat() ?: 0f
// set the text for the indicators
binding.duration.text = DateUtils.formatElapsedTime((duration / 1000).toLong())
binding.currentPosition.text = DateUtils.formatElapsedTime(
(currentPosition / 1000).toLong()
)
// update the time bar current value and maximum value
binding.timeBar.valueTo = duration / 1000
binding.timeBar.value = minOf(
currentPosition / 1000,
binding.timeBar.valueTo
)
handler.postDelayed(this::updateSeekBar, 200)
}
private fun handleServiceConnection() {
playerService.onIsPlayingChanged = { isPlaying ->
binding.playPause.setIconResource(
if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play
)
isPaused = !isPlaying
}
initializeSeekBar()
}
override fun onDestroy() {
// unregister all listeners and the connected [playerService]
playerService.onIsPlayingChanged = null
activity?.unbindService(connection)
PlayingQueue.removeOnTrackChangedListener(onTrackChangeListener)
super.onDestroy()
}
}

View File

@ -350,13 +350,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
BackgroundHelper.stopBackgroundPlay(requireContext()) BackgroundHelper.stopBackgroundPlay(requireContext())
} }
playerBinding.closeImageButton.setOnClickListener { playerBinding.closeImageButton.setOnClickListener {
viewModel.isFullscreen.value = false killFragment()
binding.playerMotionLayout.transitionToEnd()
val mainActivity = activity as MainActivity
mainActivity.supportFragmentManager.beginTransaction()
.remove(this)
.commit()
BackgroundHelper.stopBackgroundPlay(requireContext())
} }
playerBinding.autoPlay.visibility = View.VISIBLE playerBinding.autoPlay.visibility = View.VISIBLE
@ -473,6 +467,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
} }
private fun playOnBackground() { private fun playOnBackground() {
BackgroundHelper.stopBackgroundPlay(requireContext())
BackgroundHelper.playOnBackground( BackgroundHelper.playOnBackground(
requireContext(), requireContext(),
videoId!!, videoId!!,
@ -480,6 +475,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
playlistId, playlistId,
channelId channelId
) )
handler.postDelayed({
(activity as MainActivity).navController.navigate(R.id.audioPlayerFragment)
killFragment()
}, 500)
} }
private fun setFullscreen() { private fun setFullscreen() {
@ -1520,6 +1519,15 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
return exoPlayer.isPlaying && !backgroundModeRunning return exoPlayer.isPlaying && !backgroundModeRunning
} }
private fun killFragment() {
viewModel.isFullscreen.value = false
binding.playerMotionLayout.transitionToEnd()
val mainActivity = activity as MainActivity
mainActivity.supportFragmentManager.beginTransaction()
.remove(this)
.commit()
}
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)

View File

@ -20,7 +20,6 @@ import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.BACKGROUND_CHANNEL_ID import com.github.libretube.constants.BACKGROUND_CHANNEL_ID
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PLAYER_NOTIFICATION_ID import com.github.libretube.constants.PLAYER_NOTIFICATION_ID
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.activities.MainActivity
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
@ -75,11 +74,7 @@ class NowPlayingNotification(
// that's the only way to launch back into the previous activity (e.g. the player view // 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 { val intent = Intent(context, MainActivity::class.java).apply {
if (isBackgroundPlayerNotification) { if (isBackgroundPlayerNotification) {
if (PreferenceHelper.getBoolean(PreferenceKeys.NOTIFICATION_OPEN_QUEUE, true)) { putExtra(IntentData.openAudioPlayer, true)
putExtra(IntentData.openQueueOnce, true)
} else {
putExtra(IntentData.videoId, videoId)
}
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
} }
} }

View File

@ -13,7 +13,16 @@ import kotlinx.coroutines.launch
object PlayingQueue { object PlayingQueue {
private val queue = mutableListOf<StreamItem>() private val queue = mutableListOf<StreamItem>()
private var currentStream: StreamItem? = null private var currentStream: StreamItem? = null
/**
* Listener that gets called when the user selects an item from the queue
*/
private var onQueueTapListener: (StreamItem) -> Unit = {} private var onQueueTapListener: (StreamItem) -> Unit = {}
/**
* Listener that gets called when the current playing video changes
*/
private val onTrackChangedListeners: MutableList<(StreamItem) -> Unit> = mutableListOf()
var repeatQueue: Boolean = false var repeatQueue: Boolean = false
fun add(vararg streamItem: StreamItem) { fun add(vararg streamItem: StreamItem) {
@ -58,6 +67,11 @@ object PlayingQueue {
fun updateCurrent(streamItem: StreamItem) { fun updateCurrent(streamItem: StreamItem) {
currentStream = streamItem currentStream = streamItem
onTrackChangedListeners.forEach {
runCatching {
it.invoke(streamItem)
}
}
if (!contains(streamItem)) queue.add(streamItem) if (!contains(streamItem)) queue.add(streamItem)
} }
@ -77,6 +91,8 @@ object PlayingQueue {
} }
} }
fun getCurrent(): StreamItem? = currentStream
fun contains(streamItem: StreamItem) = queue.any { it.url?.toID() == streamItem.url?.toID() } fun contains(streamItem: StreamItem) = queue.any { it.url?.toID() == streamItem.url?.toID() }
// only returns a copy of the queue, no write access // only returns a copy of the queue, no write access
@ -162,9 +178,18 @@ object PlayingQueue {
onQueueTapListener = listener onQueueTapListener = listener
} }
fun addOnTrackChangedListener(listener: (StreamItem) -> Unit) {
onTrackChangedListeners.add(listener)
}
fun removeOnTrackChangedListener(listener: (StreamItem) -> Unit) {
onTrackChangedListeners.remove(listener)
}
fun resetToDefaults() { fun resetToDefaults() {
repeatQueue = false repeatQueue = false
onQueueTapListener = {} onQueueTapListener = {}
onTrackChangedListeners.clear()
queue.clear() queue.clear()
} }
} }

View File

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="20dp"
android:layout_marginVertical="10dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Small"
tools:src="@tools:sample/backgrounds/scenic" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textSize="24sp" />
<TextView
android:id="@+id/uploader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="18sp"
android:layout_marginTop="10dp" />
</LinearLayout>
<com.google.android.material.slider.Slider
android:id="@+id/time_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:labelBehavior="gone"
android:layout_marginHorizontal="20dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp">
<TextView
android:id="@+id/current_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center"
tools:text="00:00"/>
<TextView
android:id="@+id/duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center"
tools:text="10:15"/>
</FrameLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginVertical="50dp">
<ImageView
android:id="@+id/prev"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:layout_marginHorizontal="20dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_prev" />
<com.google.android.material.button.MaterialButton
android:id="@+id/play_pause"
android:layout_width="72dp"
android:layout_height="72dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
app:icon="@drawable/ic_pause"
app:iconSize="24dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Full" />
<ImageView
android:id="@+id/next"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:layout_marginHorizontal="20dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_next" />
</LinearLayout>
</LinearLayout>

View File

@ -25,10 +25,4 @@
android:title="@string/about" android:title="@string/about"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_queue"
android:title="@string/queue"
android:visible="false"
app:showAsAction="never" />
</menu> </menu>

View File

@ -54,4 +54,9 @@
android:name="com.github.libretube.ui.fragments.DownloadsFragment" android:name="com.github.libretube.ui.fragments.DownloadsFragment"
android:label="@string/downloads" android:label="@string/downloads"
tools:layout="@layout/fragment_downloads" /> tools:layout="@layout/fragment_downloads" />
<fragment
android:id="@+id/audioPlayerFragment"
android:name="com.github.libretube.ui.fragments.AudioPlayerFragment"
android:label="@string/audio_player"
tools:layout="@layout/fragment_audio_player" />
</navigation> </navigation>

View File

@ -430,6 +430,7 @@
<string name="pause">Pause</string> <string name="pause">Pause</string>
<string name="alternative_pip_controls">Alternative PiP controls</string> <string name="alternative_pip_controls">Alternative PiP controls</string>
<string name="alternative_pip_controls_summary">Show audio only and skip controls in PiP instead of forward and rewind</string> <string name="alternative_pip_controls_summary">Show audio only and skip controls in PiP instead of forward and rewind</string>
<string name="audio_player">Audio player</string>
<!-- Notification channel strings --> <!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string> <string name="download_channel_name">Download Service</string>

View File

@ -89,12 +89,6 @@
app:valueFrom="0.2" app:valueFrom="0.2"
app:valueTo="4.0" /> app:valueTo="4.0" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:icon="@drawable/ic_grid"
android:title="@string/open_queue_from_notification"
app:key="notification_open_queue" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>