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 2521915d6..148770945 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -8,7 +8,7 @@ object IntentData { const val timeStamp = "timeStamp" const val position = "position" const val fileName = "fileName" - const val openQueueOnce = "openQueue" const val keepQueue = "keepQueue" const val playlistType = "playlistType" + const val openAudioPlayer = "openAudioPlayer" } diff --git a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt index ef5b472fc..1c4086466 100644 --- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt @@ -93,7 +93,6 @@ object PreferenceKeys { * Background mode */ const val BACKGROUND_PLAYBACK_SPEED = "background_playback_speed" - const val NOTIFICATION_OPEN_QUEUE = "notification_open_queue" /** * Notifications diff --git a/app/src/main/java/com/github/libretube/services/BackgroundMode.kt b/app/src/main/java/com/github/libretube/services/BackgroundMode.kt index 523aad31a..a2bb1b95a 100644 --- a/app/src/main/java/com/github/libretube/services/BackgroundMode.kt +++ b/app/src/main/java/com/github/libretube/services/BackgroundMode.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent +import android.os.Binder import android.os.Build import android.os.Handler import android.os.IBinder @@ -87,6 +88,16 @@ class BackgroundMode : Service() { */ 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 */ @@ -251,6 +262,11 @@ class BackgroundMode : Service() { * Plays the next video when the current one ended */ player?.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + onIsPlayingChanged?.invoke(isPlaying) + } + override fun onPlaybackStateChanged(state: Int) { when (state) { Player.STATE_ENDED -> { @@ -381,7 +397,26 @@ class BackgroundMode : Service() { super.onDestroy() } - override fun onBind(p0: Intent?): IBinder? { - return null + inner class LocalBinder : Binder() { + // 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() } } diff --git a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt index b6fa2bec7..069a51f36 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt @@ -39,11 +39,9 @@ import com.github.libretube.ui.fragments.PlayerFragment import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.SearchViewModel 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.util.NavBarHelper import com.github.libretube.util.NetworkHelper -import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.ThemeHelper 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 */ @@ -373,10 +366,6 @@ class MainActivity : BaseActivity() { startActivity(communityIntent) true } - R.id.action_queue -> { - PlayingQueueSheet().show(supportFragmentManager, null) - true - } else -> super.onOptionsItemSelected(item) } } @@ -391,6 +380,11 @@ class MainActivity : BaseActivity() { startActivity(intent) } + if (intent?.getBooleanExtra(IntentData.openAudioPlayer, false) == true) { + navController.navigate(R.id.audioPlayerFragment) + return + } + intent?.getStringExtra(IntentData.channelId)?.let { navController.navigate( R.id.channelFragment, @@ -412,6 +406,7 @@ class MainActivity : BaseActivity() { intent?.getStringExtra(IntentData.videoId)?.let { loadVideo(it, intent?.getLongExtra(IntentData.timeStamp, 0L)) } + when (intent?.getStringExtra("fragmentToOpen")) { "home" -> navController.navigate(R.id.homeFragment) @@ -422,10 +417,6 @@ class MainActivity : BaseActivity() { "library" -> navController.navigate(R.id.libraryFragment) } - if (intent?.getBooleanExtra(IntentData.openQueueOnce, false) == true) { - PlayingQueueSheet() - .show(supportFragmentManager) - } } private fun loadVideo(videoId: String, timeStamp: Long?) { 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 new file mode 100644 index 000000000..b2b599952 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt @@ -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() + } +} 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 2d34fe4f2..8da805c14 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 @@ -350,13 +350,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { BackgroundHelper.stopBackgroundPlay(requireContext()) } playerBinding.closeImageButton.setOnClickListener { - viewModel.isFullscreen.value = false - binding.playerMotionLayout.transitionToEnd() - val mainActivity = activity as MainActivity - mainActivity.supportFragmentManager.beginTransaction() - .remove(this) - .commit() - BackgroundHelper.stopBackgroundPlay(requireContext()) + killFragment() } playerBinding.autoPlay.visibility = View.VISIBLE @@ -473,6 +467,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { } private fun playOnBackground() { + BackgroundHelper.stopBackgroundPlay(requireContext()) BackgroundHelper.playOnBackground( requireContext(), videoId!!, @@ -480,6 +475,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { playlistId, channelId ) + handler.postDelayed({ + (activity as MainActivity).navController.navigate(R.id.audioPlayerFragment) + killFragment() + }, 500) } private fun setFullscreen() { @@ -1520,6 +1519,15 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { 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) { super.onConfigurationChanged(newConfig) 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 1f05ba6db..471addf10 100644 --- a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt +++ b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt @@ -20,7 +20,6 @@ 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.constants.PreferenceKeys import com.github.libretube.ui.activities.MainActivity import com.google.android.exoplayer2.ExoPlayer 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 val intent = Intent(context, MainActivity::class.java).apply { if (isBackgroundPlayerNotification) { - if (PreferenceHelper.getBoolean(PreferenceKeys.NOTIFICATION_OPEN_QUEUE, true)) { - putExtra(IntentData.openQueueOnce, true) - } else { - putExtra(IntentData.videoId, videoId) - } + putExtra(IntentData.openAudioPlayer, true) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } } diff --git a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt index 82e7d1997..174891393 100644 --- a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt +++ b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt @@ -13,7 +13,16 @@ import kotlinx.coroutines.launch object PlayingQueue { private val queue = mutableListOf() private var currentStream: StreamItem? = null + + /** + * Listener that gets called when the user selects an item from the queue + */ 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 fun add(vararg streamItem: StreamItem) { @@ -58,6 +67,11 @@ object PlayingQueue { fun updateCurrent(streamItem: StreamItem) { currentStream = streamItem + onTrackChangedListeners.forEach { + runCatching { + it.invoke(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() } // only returns a copy of the queue, no write access @@ -162,9 +178,18 @@ object PlayingQueue { onQueueTapListener = listener } + fun addOnTrackChangedListener(listener: (StreamItem) -> Unit) { + onTrackChangedListeners.add(listener) + } + + fun removeOnTrackChangedListener(listener: (StreamItem) -> Unit) { + onTrackChangedListeners.remove(listener) + } + fun resetToDefaults() { repeatQueue = false onQueueTapListener = {} + onTrackChangedListeners.clear() queue.clear() } } diff --git a/app/src/main/res/layout/fragment_audio_player.xml b/app/src/main/res/layout/fragment_audio_player.xml new file mode 100644 index 000000000..67ec3bd23 --- /dev/null +++ b/app/src/main/res/layout/fragment_audio_player.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/action_bar.xml b/app/src/main/res/menu/action_bar.xml index bf8c2a4ab..9d47ac612 100644 --- a/app/src/main/res/menu/action_bar.xml +++ b/app/src/main/res/menu/action_bar.xml @@ -25,10 +25,4 @@ android:title="@string/about" app:showAsAction="never" /> - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav.xml b/app/src/main/res/navigation/nav.xml index d076b0d5a..3d64e6c50 100644 --- a/app/src/main/res/navigation/nav.xml +++ b/app/src/main/res/navigation/nav.xml @@ -54,4 +54,9 @@ android:name="com.github.libretube.ui.fragments.DownloadsFragment" android:label="@string/downloads" tools:layout="@layout/fragment_downloads" /> + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58a016faf..7da95f877 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -430,6 +430,7 @@ Pause Alternative PiP controls Show audio only and skip controls in PiP instead of forward and rewind + Audio player Download Service diff --git a/app/src/main/res/xml/audio_video_settings.xml b/app/src/main/res/xml/audio_video_settings.xml index f3533a560..9ea6e0d77 100644 --- a/app/src/main/res/xml/audio_video_settings.xml +++ b/app/src/main/res/xml/audio_video_settings.xml @@ -89,12 +89,6 @@ app:valueFrom="0.2" app:valueTo="4.0" /> - - \ No newline at end of file