refactor: use MediaController instead of ServiceBinder in AudioPlayerFragment

This commit is contained in:
Bnyro 2024-11-17 23:02:50 +01:00
parent 9ab76ba47b
commit f4a8d7b6b1
15 changed files with 147 additions and 288 deletions

View File

@ -56,4 +56,5 @@ object IntentData {
const val isPlayingOffline = "isPlayingOffline" const val isPlayingOffline = "isPlayingOffline"
const val downloadInfo = "downloadInfo" const val downloadInfo = "downloadInfo"
const val streams = "streams" const val streams = "streams"
const val chapters = "chapters"
} }

View File

@ -9,6 +9,10 @@ inline fun <reified T : Parcelable> Bundle.parcelable(key: String?): T? {
return BundleCompat.getParcelable(this, key, T::class.java) return BundleCompat.getParcelable(this, key, T::class.java)
} }
inline fun <reified T : Parcelable> Bundle.parcelableList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? { inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java) return BundleCompat.getParcelableArrayList(this, key, T::class.java)
} }

View File

@ -1,58 +0,0 @@
package com.github.libretube.extensions
import android.graphics.Bitmap
import android.support.v4.media.MediaMetadataCompat
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
import androidx.media3.session.MediaConstants
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun MediaMetadata.toMediaMetadataCompat(duration: Long, thumbnail: Bitmap?): MediaMetadataCompat {
val builder = MediaMetadataCompat.Builder()
title?.let {
builder.putText(MediaMetadataCompat.METADATA_KEY_TITLE, it)
builder.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, it)
}
subtitle?.let {
builder.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, it)
}
description?.let {
builder.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, it)
}
artist?.let {
builder.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, it)
}
albumTitle?.let {
builder.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, it)
}
albumArtist?.let {
builder.putText(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, it)
}
recordingYear?.toLong()?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_YEAR, it)
}
artworkUri?.toString()?.let {
builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, it)
builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, it)
}
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, thumbnail)
if (duration != C.TIME_UNSET) {
builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration)
}
mediaType?.toLong()?.let {
builder.putLong(MediaConstants.EXTRAS_KEY_MEDIA_TYPE_COMPAT, it)
}
return builder.build()
}

View File

@ -1,37 +1,50 @@
package com.github.libretube.extensions package com.github.libretube.extensions
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.annotation.OptIn
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Streams
import com.github.libretube.db.obj.Download import com.github.libretube.constants.IntentData
import com.github.libretube.db.obj.DownloadChapter
import com.github.libretube.db.obj.DownloadWithItems
@OptIn(UnstableApi::class)
fun MediaItem.Builder.setMetadata(streams: Streams) = apply { fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
val extras = bundleOf( val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title, MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader,
IntentData.chapters to streams.chapters
) )
setMediaMetadata( setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(streams.title) .setTitle(streams.title)
.setArtist(streams.uploader) .setArtist(streams.uploader)
.setDurationMs(streams.duration.times(1000))
.setArtworkUri(streams.thumbnailUrl.toUri()) .setArtworkUri(streams.thumbnailUrl.toUri())
.setComposer(streams.uploaderUrl.toID())
.setExtras(extras) .setExtras(extras)
.build() .build()
) )
} }
fun MediaItem.Builder.setMetadata(download: Download) = apply { @OptIn(UnstableApi::class)
fun MediaItem.Builder.setMetadata(downloadWithItems: DownloadWithItems) = apply {
val (download, _, chapters) = downloadWithItems
val extras = bundleOf( val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_TITLE to download.title, MediaMetadataCompat.METADATA_KEY_TITLE to download.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader,
IntentData.chapters to chapters.map(DownloadChapter::toChapterSegment)
) )
setMediaMetadata( setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(download.title) .setTitle(download.title)
.setArtist(download.uploader) .setArtist(download.uploader)
.setDurationMs(download.duration?.times(1000))
.setArtworkUri(download.thumbnailPath?.toAndroidUri()) .setArtworkUri(download.thumbnailPath?.toAndroidUri())
.setExtras(extras) .setExtras(extras)
.build() .build()

View File

@ -53,10 +53,11 @@ object BackgroundHelper {
val playerData = PlayerData(videoId, playlistId, channelId, keepQueue, position) val playerData = PlayerData(videoId, playlistId, channelId, keepQueue, position)
val sessionToken = startMediaService(
SessionToken(context, ComponentName(context, OnlinePlayerService::class.java)) context,
OnlinePlayerService::class.java,
startMediaService(context, sessionToken, bundleOf(IntentData.playerData to playerData)) bundleOf(IntentData.playerData to playerData)
)
} }
/** /**
@ -98,8 +99,6 @@ object BackgroundHelper {
downloadTab: DownloadTab, downloadTab: DownloadTab,
shuffle: Boolean = false shuffle: Boolean = false
) { ) {
stopBackgroundPlay(context)
// whether the service is started from the MainActivity or NoInternetActivity // whether the service is started from the MainActivity or NoInternetActivity
val noInternet = ContextHelper.tryUnwrapActivity<NoInternetActivity>(context) != null val noInternet = ContextHelper.tryUnwrapActivity<NoInternetActivity>(context) != null
@ -110,19 +109,21 @@ object BackgroundHelper {
IntentData.noInternet to noInternet IntentData.noInternet to noInternet
) )
val sessionToken = startMediaService(context, OfflinePlayerService::class.java, arguments)
SessionToken(context, ComponentName(context, OfflinePlayerService::class.java))
startMediaService(context, sessionToken, arguments)
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun startMediaService( fun startMediaService(
context: Context, context: Context,
sessionToken: SessionToken, serviceClass: Class<*>,
arguments: Bundle, arguments: Bundle,
onController: (MediaController) -> Unit = {} onController: (MediaController) -> Unit = {}
) { ) {
stopBackgroundPlay(context)
val sessionToken =
SessionToken(context, ComponentName(context, serviceClass))
val controllerFuture = val controllerFuture =
MediaController.Builder(context, sessionToken).buildAsync() MediaController.Builder(context, sessionToken).buildAsync()
controllerFuture.addListener({ controllerFuture.addListener({

View File

@ -1,10 +0,0 @@
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

@ -1,12 +1,9 @@
package com.github.libretube.services package com.github.libretube.services
import android.content.Intent import android.content.Intent
import android.os.Binder
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.media3.common.C import androidx.media3.common.C
@ -22,10 +19,9 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.enums.PlayerCommand import com.github.libretube.enums.PlayerCommand
import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.extensions.updateParameters import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.NowPlayingNotification
@ -49,14 +45,6 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
val handler = Handler(Looper.getMainLooper()) 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( private val watchPositionTimer = PauseableTimer(
onTick = ::saveWatchPosition, onTick = ::saveWatchPosition,
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
@ -72,27 +60,11 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
} else { } else {
watchPositionTimer.pause() watchPositionTimer.pause()
} }
onStateOrPlayingChanged?.let { it(isPlaying) }
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
onStateOrPlayingChanged?.let { it(exoPlayer?.isPlaying ?: false) }
this@AbstractPlayerService.onPlaybackStateChanged(playbackState)
} }
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
// show a toast on errors // show a toast on errors
Handler(Looper.getMainLooper()).post { toastFromMainThread(error.localizedMessage)
Toast.makeText(
applicationContext,
error.localizedMessage,
Toast.LENGTH_SHORT
).show()
}
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
@ -291,26 +263,6 @@ abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySessio
onDestroy() onDestroy()
} }
abstract fun onPlaybackStateChanged(playbackState: Int)
abstract fun getChapters(): List<ChapterSegment>
fun getCurrentPosition() = exoPlayer?.currentPosition
fun getDuration() = exoPlayer?.duration
fun seekToPosition(position: Long) = exoPlayer?.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 {
// attempt to return [MediaLibraryServiceBinder] first if matched
return super.onBind(intent) ?: binder
}
companion object { companion object {
private const val START_SERVICE_ACTION = "start_service_action" private const val START_SERVICE_ACTION = "start_service_action"
private const val RUN_PLAYER_COMMAND_ACTION = "run_player_command_action" private const val RUN_PLAYER_COMMAND_ACTION = "run_player_command_action"

View File

@ -6,10 +6,8 @@ import androidx.annotation.OptIn
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder.Database 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.DownloadWithItems
import com.github.libretube.db.obj.filterByTab import com.github.libretube.db.obj.filterByTab
import com.github.libretube.enums.FileType import com.github.libretube.enums.FileType
@ -46,6 +44,14 @@ open class OfflinePlayerService : AbstractPlayerService() {
private val scope = CoroutineScope(Dispatchers.Main) private val scope = CoroutineScope(Dispatchers.Main)
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) {
playNextVideo(PlayingQueue.getNext() ?: return)
}
}
}
override suspend fun onServiceCreated(args: Bundle) { override suspend fun onServiceCreated(args: Bundle) {
downloadTab = args.serializable(IntentData.downloadTab)!! downloadTab = args.serializable(IntentData.downloadTab)!!
shuffle = args.getBoolean(IntentData.shuffle, false) shuffle = args.getBoolean(IntentData.shuffle, false)
@ -65,6 +71,8 @@ open class OfflinePlayerService : AbstractPlayerService() {
streamItem.url?.toID()?.let { playNextVideo(it) } streamItem.url?.toID()?.let { playNextVideo(it) }
} }
exoPlayer?.addListener(playerListener)
fillQueue() fillQueue()
} }
@ -76,7 +84,6 @@ open class OfflinePlayerService : AbstractPlayerService() {
Database.downloadDao().findById(videoId) Database.downloadDao().findById(videoId)
}!! }!!
this.downloadWithItems = downloadWithItems this.downloadWithItems = downloadWithItems
onNewVideoStarted?.let { it(downloadWithItems.download.toStreamItem()) }
PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem()) PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem())
@ -106,7 +113,7 @@ open class OfflinePlayerService : AbstractPlayerService() {
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()
.setUri(audioItem.path.toAndroidUri()) .setUri(audioItem.path.toAndroidUri())
.setMetadata(downloadWithItems.download) .setMetadata(downloadWithItems)
.build() .build()
exoPlayer?.setMediaItem(mediaItem) exoPlayer?.setMediaItem(mediaItem)
@ -141,14 +148,4 @@ open class OfflinePlayerService : AbstractPlayerService() {
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
onDestroy() onDestroy()
} }
override fun onPlaybackStateChanged(playbackState: Int) {
// automatically go to the next video/audio when the current one ended
if (playbackState == Player.STATE_ENDED && PlayerHelper.isAutoPlayEnabled()) {
playNextVideo(PlayingQueue.getNext() ?: return)
}
}
override fun getChapters(): List<ChapterSegment> =
downloadWithItems?.downloadChapters.orEmpty().map(DownloadChapter::toChapterSegment)
} }

View File

@ -8,7 +8,6 @@ import androidx.media3.common.Player
import com.github.libretube.api.JsonHelper import com.github.libretube.api.JsonHelper
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.StreamsExtractor 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.Segment
import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
@ -55,6 +54,32 @@ class OnlinePlayerService : AbstractPlayerService() {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
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
scope.launch(Dispatchers.IO) {
streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) }
}
}
}
}
}
override suspend fun onServiceCreated(args: Bundle) { override suspend fun onServiceCreated(args: Bundle) {
val playerData = args.parcelable<PlayerData>(IntentData.playerData) val playerData = args.parcelable<PlayerData>(IntentData.playerData)
if (playerData == null) { if (playerData == null) {
@ -72,6 +97,8 @@ class OnlinePlayerService : AbstractPlayerService() {
PlayingQueue.setOnQueueTapListener { streamItem -> PlayingQueue.setOnQueueTapListener { streamItem ->
streamItem.url?.toID()?.let { playNextVideo(it) } streamItem.url?.toID()?.let { playNextVideo(it) }
} }
exoPlayer?.addListener(playerListener)
} }
override suspend fun startPlayback() { override suspend fun startPlayback() {
@ -127,8 +154,6 @@ class OnlinePlayerService : AbstractPlayerService() {
} }
} }
streams?.let { onNewVideoStarted?.invoke(it.toStreamItem(videoId)) }
exoPlayer?.apply { exoPlayer?.apply {
playWhenReady = PlayerHelper.playAutomatically playWhenReady = PlayerHelper.playAutomatically
prepare() prepare()
@ -208,30 +233,4 @@ class OnlinePlayerService : AbstractPlayerService() {
exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig) exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig)
} }
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
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
scope.launch(Dispatchers.IO) {
streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) }
}
}
}
}
override fun getChapters(): List<ChapterSegment> = streams?.chapters.orEmpty()
} }

View File

@ -39,7 +39,7 @@ class VideoOfflinePlayerService: OfflinePlayerService() {
videoUri != null && audioUri != null -> { videoUri != null && audioUri != null -> {
val videoItem = MediaItem.Builder() val videoItem = MediaItem.Builder()
.setUri(videoUri) .setUri(videoUri)
.setMetadata(downloadWithItems.download) .setMetadata(downloadWithItems)
.setSubtitleConfigurations(listOfNotNull(subtitle)) .setSubtitleConfigurations(listOfNotNull(subtitle))
.build() .build()
@ -63,7 +63,7 @@ class VideoOfflinePlayerService: OfflinePlayerService() {
videoUri != null -> exoPlayer?.setMediaItem( videoUri != null -> exoPlayer?.setMediaItem(
MediaItem.Builder() MediaItem.Builder()
.setUri(videoUri) .setUri(videoUri)
.setMetadata(downloadWithItems.download) .setMetadata(downloadWithItems)
.setSubtitleConfigurations(listOfNotNull(subtitle)) .setSubtitleConfigurations(listOfNotNull(subtitle))
.build() .build()
) )
@ -71,7 +71,7 @@ class VideoOfflinePlayerService: OfflinePlayerService() {
audioUri != null -> exoPlayer?.setMediaItem( audioUri != null -> exoPlayer?.setMediaItem(
MediaItem.Builder() MediaItem.Builder()
.setUri(audioUri) .setUri(audioUri)
.setMetadata(downloadWithItems.download) .setMetadata(downloadWithItems)
.setSubtitleConfigurations(listOfNotNull(subtitle)) .setSubtitleConfigurations(listOfNotNull(subtitle))
.build() .build()
) )

View File

@ -13,7 +13,6 @@ import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.hls.HlsMediaSource
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.CronetHelper import com.github.libretube.api.CronetHelper
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle import com.github.libretube.api.obj.Subtitle
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
@ -172,8 +171,4 @@ class VideoOnlinePlayerService : AbstractPlayerService() {
setPreferredTextRoleFlags(roleFlags) setPreferredTextRoleFlags(roleFlags)
setPreferredTextLanguage(subtitle?.code) setPreferredTextLanguage(subtitle?.code)
} }
override fun onPlaybackStateChanged(playbackState: Int) = Unit
override fun getChapters(): List<ChapterSegment> = emptyList()
} }

View File

@ -1,7 +1,6 @@
package com.github.libretube.ui.activities package com.github.libretube.ui.activities
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@ -17,7 +16,6 @@ import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.github.libretube.compat.PictureInPictureCompat import com.github.libretube.compat.PictureInPictureCompat
import com.github.libretube.compat.PictureInPictureParamsCompat import com.github.libretube.compat.PictureInPictureParamsCompat
@ -60,7 +58,7 @@ class OfflinePlayerActivity : BaseActivity() {
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels() private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
private val chaptersViewModel: ChaptersViewModel by viewModels() private val chaptersViewModel: ChaptersViewModel by viewModels()
private val watchPositionTimer = PauseableTimer( private val watchPositionTimer = PauseableTimer(
onTick = this::saveWatchPosition, onTick = this::saveWatchPosition,
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
@ -137,7 +135,7 @@ class OfflinePlayerActivity : BaseActivity() {
val isPlaying = ::playerController.isInitialized && playerController.isPlaying val isPlaying = ::playerController.isInitialized && playerController.isPlaying
PictureInPictureParamsCompat.Builder() PictureInPictureParamsCompat.Builder()
.setActions(PlayerHelper.getPiPModeActions(this,isPlaying)) .setActions(PlayerHelper.getPiPModeActions(this, isPlaying))
.setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying) .setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying)
.apply { .apply {
if (isPlaying) { if (isPlaying) {
@ -164,15 +162,11 @@ class OfflinePlayerActivity : BaseActivity() {
playNextVideo(streamItem.url ?: return@setOnQueueTapListener) playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
} }
val sessionToken = SessionToken(
this,
ComponentName(this, VideoOfflinePlayerService::class.java)
)
val arguments = bundleOf( val arguments = bundleOf(
IntentData.downloadTab to DownloadTab.VIDEO, IntentData.downloadTab to DownloadTab.VIDEO,
IntentData.videoId to videoId IntentData.videoId to videoId
) )
BackgroundHelper.startMediaService(this, sessionToken, arguments) { BackgroundHelper.startMediaService(this, VideoOfflinePlayerService::class.java, arguments) {
playerController = it playerController = it
playerController.addListener(playerListener) playerController.addListener(playerListener)
initializePlayerView() initializePlayerView()
@ -281,7 +275,7 @@ class OfflinePlayerActivity : BaseActivity() {
override fun onDestroy() { override fun onDestroy() {
saveWatchPosition() saveWatchPosition()
watchPositionTimer.destroy() watchPositionTimer.destroy()
runCatching { runCatching {

View File

@ -1,13 +1,9 @@
package com.github.libretube.ui.fragments package com.github.libretube.ui.fragments
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
@ -22,14 +18,18 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.obj.StreamItem import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.FragmentAudioPlayerBinding import com.github.libretube.databinding.FragmentAudioPlayerBinding
import com.github.libretube.extensions.normalize import com.github.libretube.extensions.normalize
import com.github.libretube.extensions.parcelableList
import com.github.libretube.extensions.seekBy import com.github.libretube.extensions.seekBy
import com.github.libretube.extensions.toID import com.github.libretube.extensions.serializable
import com.github.libretube.extensions.togglePlayPauseState import com.github.libretube.extensions.togglePlayPauseState
import com.github.libretube.extensions.updateIfChanged import com.github.libretube.extensions.updateIfChanged
import com.github.libretube.helpers.AudioHelper import com.github.libretube.helpers.AudioHelper
@ -40,7 +40,6 @@ import com.github.libretube.helpers.NavBarHelper
import com.github.libretube.helpers.NavigationHelper import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.ThemeHelper import com.github.libretube.helpers.ThemeHelper
import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.services.OfflinePlayerService import com.github.libretube.services.OfflinePlayerService
import com.github.libretube.services.OnlinePlayerService import com.github.libretube.services.OnlinePlayerService
import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.activities.MainActivity
@ -77,31 +76,23 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
private var handler = Handler(Looper.getMainLooper()) private var handler = Handler(Looper.getMainLooper())
private var isPaused = !PlayerHelper.playAutomatically private var isPaused = !PlayerHelper.playAutomatically
private var playerService: AbstractPlayerService? = null private var isOffline: Boolean = false
private var playerController: MediaController? = 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 AbstractPlayerService.LocalBinder
playerService = binder.getService()
handleServiceConnection()
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
audioHelper = AudioHelper(requireContext()) audioHelper = AudioHelper(requireContext())
val isOffline = requireArguments().getBoolean(IntentData.offlinePlayer) isOffline = requireArguments().getBoolean(IntentData.offlinePlayer)
val serviceClass = BackgroundHelper.startMediaService(
if (isOffline) OfflinePlayerService::class.java else OnlinePlayerService::class.java requireContext(),
Intent(activity, serviceClass).also { intent -> if (isOffline) OfflinePlayerService::class.java else OnlinePlayerService::class.java,
activity.bindService(intent, connection, 0) bundleOf()
) {
playerController = it
handleServiceConnection()
} }
} }
@ -155,10 +146,10 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
it.text = (PlayerHelper.seekIncrement / 1000).toString() it.text = (PlayerHelper.seekIncrement / 1000).toString()
} }
binding.rewindFL.setOnClickListener { binding.rewindFL.setOnClickListener {
playerService?.exoPlayer?.seekBy(-PlayerHelper.seekIncrement) playerController?.seekBy(-PlayerHelper.seekIncrement)
} }
binding.forwardFL.setOnClickListener { binding.forwardFL.setOnClickListener {
playerService?.exoPlayer?.seekBy(PlayerHelper.seekIncrement) playerController?.seekBy(PlayerHelper.seekIncrement)
} }
binding.openQueue.setOnClickListener { binding.openQueue.setOnClickListener {
@ -166,7 +157,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
} }
binding.playbackOptions.setOnClickListener { binding.playbackOptions.setOnClickListener {
playerService?.exoPlayer?.let { playerController?.let {
PlaybackOptionsSheet(it) PlaybackOptionsSheet(it)
.show(childFragmentManager) .show(childFragmentManager)
} }
@ -182,7 +173,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
NavigationHelper.navigateVideo( NavigationHelper.navigateVideo(
context = requireContext(), context = requireContext(),
videoUrlOrId = PlayingQueue.getCurrent()?.url, videoUrlOrId = PlayingQueue.getCurrent()?.url,
timestamp = playerService?.exoPlayer?.currentPosition?.div(1000) ?: 0, timestamp = playerController?.currentPosition?.div(1000) ?: 0,
keepQueue = true, keepQueue = true,
forceVideo = true forceVideo = true
) )
@ -192,24 +183,24 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
ChaptersBottomSheet.SEEK_TO_POSITION_REQUEST_KEY, ChaptersBottomSheet.SEEK_TO_POSITION_REQUEST_KEY,
viewLifecycleOwner viewLifecycleOwner
) { _, bundle -> ) { _, bundle ->
playerService?.exoPlayer?.seekTo(bundle.getLong(IntentData.currentPosition)) playerController?.seekTo(bundle.getLong(IntentData.currentPosition))
} }
binding.openChapters.setOnClickListener { binding.openChapters.setOnClickListener {
val playerService = playerService ?: return@setOnClickListener chaptersModel.chaptersLiveData.value =
chaptersModel.chaptersLiveData.value = playerService.getChapters() playerController?.mediaMetadata?.extras?.serializable(IntentData.chapters)
ChaptersBottomSheet() ChaptersBottomSheet()
.apply { .apply {
arguments = bundleOf( arguments = bundleOf(
IntentData.duration to playerService.exoPlayer?.duration?.div(1000) IntentData.duration to playerController?.duration?.div(1000)
) )
} }
.show(childFragmentManager) .show(childFragmentManager)
} }
binding.miniPlayerClose.setOnClickListener { binding.miniPlayerClose.setOnClickListener {
activity.unbindService(connection) playerController?.release()
BackgroundHelper.stopBackgroundPlay(requireContext()) BackgroundHelper.stopBackgroundPlay(requireContext())
killFragment() killFragment()
} }
@ -218,20 +209,17 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
binding.thumbnail.setOnTouchListener(listener) binding.thumbnail.setOnTouchListener(listener)
binding.playPause.setOnClickListener { binding.playPause.setOnClickListener {
playerService?.exoPlayer?.togglePlayPauseState() playerController?.togglePlayPauseState()
} }
binding.miniPlayerPause.setOnClickListener { binding.miniPlayerPause.setOnClickListener {
playerService?.exoPlayer?.togglePlayPauseState() playerController?.togglePlayPauseState()
} }
binding.showMore.setOnClickListener { binding.showMore.setOnClickListener {
onLongTap() onLongTap()
} }
// load the stream info into the UI
updateStreamInfo()
// update the currently shown volume // update the currently shown volume
binding.volumeProgressBar.let { bar -> binding.volumeProgressBar.let { bar ->
bar.progress = audioHelper.getVolumeWithScale(bar.max) bar.progress = audioHelper.getVolumeWithScale(bar.max)
@ -297,20 +285,19 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
/** /**
* Load the information from a new stream into the UI * Load the information from a new stream into the UI
*/ */
private fun updateStreamInfo(stream: StreamItem? = null) { private fun updateStreamInfo(metadata: MediaMetadata) {
val binding = _binding ?: return val binding = _binding ?: return
val current = stream ?: PlayingQueue.getCurrent() ?: return binding.title.text = metadata.title
binding.miniPlayerTitle.text = metadata.title
binding.title.text = current.title binding.uploader.text = metadata.artist
binding.miniPlayerTitle.text = current.title
binding.uploader.text = current.uploaderName
binding.uploader.setOnClickListener { binding.uploader.setOnClickListener {
NavigationHelper.navigateChannel(requireContext(), current.uploaderUrl?.toID()) val uploaderId = metadata.composer?.toString() ?: return@setOnClickListener
NavigationHelper.navigateChannel(requireContext(), uploaderId)
} }
current.thumbnail?.let { updateThumbnailAsync(it) } metadata.artworkUri?.let { updateThumbnailAsync(it.toString()) }
initializeSeekBar() initializeSeekBar()
} }
@ -344,7 +331,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
private fun initializeSeekBar() { private fun initializeSeekBar() {
binding.timeBar.addOnChangeListener { _, value, fromUser -> binding.timeBar.addOnChangeListener { _, value, fromUser ->
if (fromUser) playerService?.seekToPosition(value.toLong() * 1000) if (fromUser) playerController?.seekTo(value.toLong() * 1000)
} }
updateSeekBar() updateSeekBar()
} }
@ -354,7 +341,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
*/ */
private fun updateSeekBar() { private fun updateSeekBar() {
val binding = _binding ?: return val binding = _binding ?: return
val duration = playerService?.getDuration()?.takeIf { it > 0 } ?: let { val duration = playerController?.duration?.takeIf { it > 0 } ?: let {
// if there's no duration available, clear everything // if there's no duration available, clear everything
binding.timeBar.value = 0f binding.timeBar.value = 0f
binding.duration.text = "" binding.duration.text = ""
@ -362,7 +349,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
handler.postDelayed(this::updateSeekBar, 100) handler.postDelayed(this::updateSeekBar, 100)
return return
} }
val currentPosition = playerService?.getCurrentPosition()?.toFloat() ?: 0f val currentPosition = playerController?.currentPosition?.toFloat() ?: 0f
// set the text for the indicators // set the text for the indicators
binding.duration.text = DateUtils.formatElapsedTime(duration / 1000) binding.duration.text = DateUtils.formatElapsedTime(duration / 1000)
@ -381,7 +368,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
} }
private fun updatePlayPauseButton() { private fun updatePlayPauseButton() {
playerService?.exoPlayer?.let { playerController?.let {
val binding = _binding ?: return val binding = _binding ?: return
val iconRes = PlayerHelper.getPlayPauseActionIcon(it) val iconRes = PlayerHelper.getPlayPauseActionIcon(it)
@ -391,19 +378,25 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
} }
private fun handleServiceConnection() { private fun handleServiceConnection() {
playerService?.onStateOrPlayingChanged = { isPlaying -> playerController?.addListener(object : Player.Listener {
updatePlayPauseButton() override fun onIsPlayingChanged(isPlaying: Boolean) {
isPaused = !isPlaying super.onIsPlayingChanged(isPlaying)
}
playerService?.onNewVideoStarted = { streamItem -> updatePlayPauseButton()
handler.post { isPaused = !isPlaying
updateStreamInfo(streamItem)
_binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty()
} }
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
super.onMediaMetadataChanged(mediaMetadata)
updateStreamInfo(mediaMetadata)
val chapters: List<ChapterSegment>? = mediaMetadata.extras?.parcelableList(IntentData.chapters)
_binding?.openChapters?.isVisible = !chapters.isNullOrEmpty()
}
})
initializeSeekBar() initializeSeekBar()
if (playerService is OfflinePlayerService) { if (isOffline) {
binding.openVideo.isGone = true binding.openVideo.isGone = true
} }
} }
@ -413,18 +406,8 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
_binding = null _binding = null
} }
override fun onDestroy() {
// unregister all listeners and the connected [playerService]
playerService?.onStateOrPlayingChanged = null
runCatching {
activity.unbindService(connection)
}
super.onDestroy()
}
override fun onSingleTap() { override fun onSingleTap() {
playerService?.exoPlayer?.togglePlayPauseState() playerController?.togglePlayPauseState()
} }
override fun onLongTap() { override fun onLongTap() {
@ -481,10 +464,11 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
if (_binding == null) return if (_binding == null) return
handler.postDelayed(this::updateChapterIndex, 100) handler.postDelayed(this::updateChapterIndex, 100)
val player = playerService?.exoPlayer ?: return
val currentIndex = val currentIndex =
PlayerHelper.getCurrentChapterIndex(player.currentPosition, chaptersModel.chapters) PlayerHelper.getCurrentChapterIndex(
playerController?.currentPosition ?: return,
chaptersModel.chapters
)
chaptersModel.currentChapterIndex.updateIfChanged(currentIndex ?: return) chaptersModel.currentChapterIndex.updateIfChanged(currentIndex ?: return)
} }
} }

View File

@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@ -48,7 +47,6 @@ import androidx.media3.common.C
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.api.obj.ChapterSegment
@ -389,11 +387,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true) fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true)
noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false) noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false)
val sessionToken = SessionToken( BackgroundHelper.startMediaService(requireContext(), VideoOnlinePlayerService::class.java, bundleOf()) {
requireContext(),
ComponentName(requireContext(), VideoOnlinePlayerService::class.java)
)
BackgroundHelper.startMediaService(requireContext(), sessionToken, bundleOf()) {
playerController = it playerController = it
playerController.addListener(playerListener) playerController.addListener(playerListener)
} }

View File

@ -6,8 +6,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
@ -19,7 +17,7 @@ import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.ui.adapters.SliderLabelsAdapter import com.github.libretube.ui.adapters.SliderLabelsAdapter
class PlaybackOptionsSheet( class PlaybackOptionsSheet(
private val player: Player private val player: MediaController
) : ExpandedBottomSheet() { ) : ExpandedBottomSheet() {
private var _binding: PlaybackBottomSheetBinding? = null private var _binding: PlaybackBottomSheetBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
@ -64,15 +62,10 @@ class PlaybackOptionsSheet(
} }
binding.skipSilence.setOnCheckedChangeListener { _, isChecked -> binding.skipSilence.setOnCheckedChangeListener { _, isChecked ->
// TODO: unify the skip silence handling player.sendCustomCommand(
if (player is ExoPlayer) { AbstractPlayerService.runPlayerActionCommand,
player.skipSilenceEnabled = isChecked bundleOf(PlayerCommand.SKIP_SILENCE.name to isChecked)
} else if (player is MediaController) { )
player.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand,
bundleOf(PlayerCommand.SKIP_SILENCE.name to isChecked)
)
}
PreferenceHelper.putBoolean(PreferenceKeys.SKIP_SILENCE, isChecked) PreferenceHelper.putBoolean(PreferenceKeys.SKIP_SILENCE, isChecked)
} }
} }