diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f9d088555..30cba5243 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -404,13 +404,49 @@ android:name=".services.OnlinePlayerService" android:enabled="true" android:exported="false" - android:foregroundServiceType="mediaPlayback" /> + android:foregroundServiceType="mediaPlayback"> + + + + + + + android:foregroundServiceType="mediaPlayback"> + + + + + + + + + + + + + + + + + + + + + + , val urlTexts: List -) +): Parcelable diff --git a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt index 663c9692c..0b37fbf69 100644 --- a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt +++ b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt @@ -1,11 +1,14 @@ package com.github.libretube.api.obj +import android.os.Parcelable import com.github.libretube.db.obj.DownloadItem import com.github.libretube.enums.FileType +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlin.io.path.Path @Serializable +@Parcelize data class PipedStream( var url: String? = null, val format: String? = null, @@ -26,7 +29,7 @@ data class PipedStream( val contentLength: Long = -1, val audioTrackType: String? = null, val audioTrackLocale: String? = null -) { +): Parcelable { private fun getQualityString(fileName: String): String { return "${fileName}_${quality?.replace(" ", "_")}_$format." + mimeType?.split("/")?.last() diff --git a/app/src/main/java/com/github/libretube/api/obj/PreviewFrames.kt b/app/src/main/java/com/github/libretube/api/obj/PreviewFrames.kt index ad4371960..8ad44bac5 100644 --- a/app/src/main/java/com/github/libretube/api/obj/PreviewFrames.kt +++ b/app/src/main/java/com/github/libretube/api/obj/PreviewFrames.kt @@ -1,8 +1,11 @@ package com.github.libretube.api.obj +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Serializable +@Parcelize data class PreviewFrames( val urls: List, val frameWidth: Int, @@ -11,4 +14,4 @@ data class PreviewFrames( val durationPerFrame: Long, val framesPerPageX: Int, val framesPerPageY: Int -) +): Parcelable diff --git a/app/src/main/java/com/github/libretube/api/obj/Streams.kt b/app/src/main/java/com/github/libretube/api/obj/Streams.kt index d4d50cd17..aa8fd0d16 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Streams.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Streams.kt @@ -1,5 +1,6 @@ package com.github.libretube.api.obj +import android.os.Parcelable import com.github.libretube.db.obj.DownloadItem import com.github.libretube.enums.FileType import com.github.libretube.helpers.ProxyHelper @@ -8,18 +9,22 @@ import com.github.libretube.parcelable.DownloadData import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.io.path.Path @Serializable +@Parcelize data class Streams( var title: String, val description: String, @Serializable(SafeInstantSerializer::class) @SerialName("uploadDate") - val uploadTimestamp: Instant?, + @IgnoredOnParcel + val uploadTimestamp: Instant? = null, val uploaded: Long? = null, val uploader: String, @@ -48,7 +53,8 @@ data class Streams( val chapters: List = emptyList(), val uploaderSubscriberCount: Long = 0, val previewFrames: List = emptyList() -) { +): Parcelable { + @IgnoredOnParcel val isLive = livestream || duration <= 0 fun toDownloadItems(downloadData: DownloadData): List { diff --git a/app/src/main/java/com/github/libretube/api/obj/Subtitle.kt b/app/src/main/java/com/github/libretube/api/obj/Subtitle.kt index 8e1b349f2..aad87c203 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Subtitle.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Subtitle.kt @@ -1,17 +1,20 @@ package com.github.libretube.api.obj import android.content.Context +import android.os.Parcelable import com.github.libretube.R +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Serializable +@Parcelize data class Subtitle( val url: String? = null, val mimeType: String? = null, val name: String? = null, val code: String? = null, val autoGenerated: Boolean? = null -) { +): Parcelable { fun getDisplayName(context: Context) = if (autoGenerated != true) { name!! } else { 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 432f03ddc..781a144ed 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -55,4 +55,5 @@ object IntentData { const val noInternet = "noInternet" const val isPlayingOffline = "isPlayingOffline" const val downloadInfo = "downloadInfo" + const val streams = "streams" } diff --git a/app/src/main/java/com/github/libretube/enums/PlayerCommand.kt b/app/src/main/java/com/github/libretube/enums/PlayerCommand.kt new file mode 100644 index 000000000..fbc95b975 --- /dev/null +++ b/app/src/main/java/com/github/libretube/enums/PlayerCommand.kt @@ -0,0 +1,11 @@ +package com.github.libretube.enums + +enum class PlayerCommand { + START_PLAYBACK, + SKIP_SILENCE, + SET_VIDEO_TRACK_TYPE_DISABLED, + SET_AUDIO_ROLE_FLAGS, + SET_RESOLUTION, + SET_AUDIO_LANGUAGE, + SET_SUBTITLE +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt b/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt index a5b7d387a..98d8e6761 100644 --- a/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt +++ b/app/src/main/java/com/github/libretube/extensions/SetMetadata.kt @@ -1,22 +1,15 @@ package com.github.libretube.extensions -import android.content.res.Resources -import android.graphics.BitmapFactory import android.support.v4.media.MediaMetadataCompat import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import com.github.libretube.R import com.github.libretube.api.obj.Streams +import com.github.libretube.db.obj.Download fun MediaItem.Builder.setMetadata(streams: Streams) = apply { - val appIcon = BitmapFactory.decodeResource( - Resources.getSystem(), - R.drawable.ic_launcher_monochrome - ) val extras = bundleOf( - MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON to appIcon, MediaMetadataCompat.METADATA_KEY_TITLE to streams.title, MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader ) @@ -29,3 +22,18 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply { .build() ) } + +fun MediaItem.Builder.setMetadata(download: Download) = apply { + val extras = bundleOf( + MediaMetadataCompat.METADATA_KEY_TITLE to download.title, + MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader + ) + setMediaMetadata( + MediaMetadata.Builder() + .setTitle(download.title) + .setArtist(download.uploader) + .setArtworkUri(download.thumbnailPath?.toAndroidUri()) + .setExtras(extras) + .build() + ) +} diff --git a/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt b/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt index 7b4927674..daed87eed 100644 --- a/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/BackgroundHelper.kt @@ -1,25 +1,34 @@ package com.github.libretube.helpers import android.app.ActivityManager +import android.content.ComponentName import android.content.Context import android.content.Intent -import androidx.core.content.ContextCompat +import android.os.Bundle +import androidx.annotation.OptIn import androidx.core.content.getSystemService +import androidx.core.os.bundleOf import androidx.fragment.app.commit +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import com.github.libretube.constants.IntentData import com.github.libretube.parcelable.PlayerData +import com.github.libretube.services.AbstractPlayerService import com.github.libretube.services.OfflinePlayerService import com.github.libretube.services.OnlinePlayerService +import com.github.libretube.services.VideoOfflinePlayerService +import com.github.libretube.services.VideoOnlinePlayerService import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.activities.NoInternetActivity import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.fragments.PlayerFragment +import com.google.common.util.concurrent.MoreExecutors /** * Helper for starting a new Instance of the [OnlinePlayerService] */ object BackgroundHelper { - /** * Start the foreground service [OnlinePlayerService] to play in background. [position] * is seek to position specified in milliseconds in the current [videoId]. @@ -35,26 +44,31 @@ object BackgroundHelper { ) { // close the previous video player if open if (!keepVideoPlayerAlive) { - val fragmentManager = ContextHelper.unwrapActivity(context).supportFragmentManager + val fragmentManager = + ContextHelper.unwrapActivity(context).supportFragmentManager fragmentManager.fragments.firstOrNull { it is PlayerFragment }?.let { fragmentManager.commit { remove(it) } } } - // create an intent for the background mode service val playerData = PlayerData(videoId, playlistId, channelId, keepQueue, position) - val intent = Intent(context, OnlinePlayerService::class.java) - .putExtra(IntentData.playerData, playerData) - // start the background mode as foreground service - ContextCompat.startForegroundService(context, intent) + val sessionToken = + SessionToken(context, ComponentName(context, OnlinePlayerService::class.java)) + + startMediaService(context, sessionToken, bundleOf(IntentData.playerData to playerData)) } /** * Stop the [OnlinePlayerService] service if it is running. */ fun stopBackgroundPlay(context: Context) { - arrayOf(OnlinePlayerService::class.java, OfflinePlayerService::class.java).forEach { + arrayOf( + OnlinePlayerService::class.java, + OfflinePlayerService::class.java, + VideoOfflinePlayerService::class.java, + VideoOnlinePlayerService::class.java + ).forEach { val intent = Intent(context, it) context.stopService(intent) } @@ -78,18 +92,46 @@ object BackgroundHelper { * @param context the current context * @param videoId the videoId of the video or null if all available downloads should be shuffled */ - fun playOnBackgroundOffline(context: Context, videoId: String?, downloadTab: DownloadTab, shuffle: Boolean = false) { + fun playOnBackgroundOffline( + context: Context, + videoId: String?, + downloadTab: DownloadTab, + shuffle: Boolean = false + ) { stopBackgroundPlay(context) // whether the service is started from the MainActivity or NoInternetActivity val noInternet = ContextHelper.tryUnwrapActivity(context) != null - val playerIntent = Intent(context, OfflinePlayerService::class.java) - .putExtra(IntentData.videoId, videoId) - .putExtra(IntentData.shuffle, shuffle) - .putExtra(IntentData.downloadTab, downloadTab) - .putExtra(IntentData.noInternet, noInternet) + val arguments = bundleOf( + IntentData.videoId to videoId, + IntentData.shuffle to shuffle, + IntentData.downloadTab to downloadTab, + IntentData.noInternet to noInternet + ) - ContextCompat.startForegroundService(context, playerIntent) + val sessionToken = + SessionToken(context, ComponentName(context, OfflinePlayerService::class.java)) + + startMediaService(context, sessionToken, arguments) + } + + @OptIn(UnstableApi::class) + fun startMediaService( + context: Context, + sessionToken: SessionToken, + arguments: Bundle, + onController: (MediaController) -> Unit = {} + ) { + val controllerFuture = + MediaController.Builder(context, sessionToken).buildAsync() + controllerFuture.addListener({ + val controller = controllerFuture.get() + controller.sendCustomCommand( + AbstractPlayerService.startServiceCommand, + arguments + ) + onController(controller) + }, MoreExecutors.directExecutor()) } } diff --git a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt index f106c81f2..9a1e012c8 100644 --- a/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt @@ -598,7 +598,7 @@ object PlayerHelper { * @param segments List of the SponsorBlock segments * @return If segment found and should skip manually, the end position of the segment in ms, otherwise null */ - fun ExoPlayer.checkForSegments( + fun Player.checkForSegments( context: Context, segments: List, sponsorBlockConfig: MutableMap @@ -633,7 +633,7 @@ object PlayerHelper { return null } - fun ExoPlayer.isInSegment(segments: List): Boolean { + fun Player.isInSegment(segments: List): Boolean { return segments.any { val (start, end) = it.segmentStartAndEnd val (segmentStart, segmentEnd) = (start * 1000f).toLong() to (end * 1000f).toLong() @@ -835,7 +835,7 @@ object PlayerHelper { else -> R.drawable.ic_play } - fun saveWatchPosition(player: ExoPlayer, videoId: String) { + fun saveWatchPosition(player: Player, videoId: String) { if (player.duration == C.TIME_UNSET || player.currentPosition in listOf(0L, C.TIME_UNSET)) { return } diff --git a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt index 9402e0247..16b6e0a2c 100644 --- a/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/AbstractPlayerService.kt @@ -1,44 +1,47 @@ package com.github.libretube.services -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Binder +import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper import android.widget.Toast import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleService -import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult import com.github.libretube.R import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.api.obj.StreamItem -import com.github.libretube.enums.NotificationId +import com.github.libretube.enums.PlayerCommand import com.github.libretube.enums.PlayerEvent -import com.github.libretube.extensions.serializableExtra import com.github.libretube.extensions.updateParameters import com.github.libretube.helpers.PlayerHelper import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PlayingQueue +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @UnstableApi -abstract class AbstractPlayerService : LifecycleService() { - var player: ExoPlayer? = null - var nowPlayingNotification: NowPlayingNotification? = null +abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySession.Callback { + private var mediaLibrarySession: MediaLibrarySession? = null + var exoPlayer: ExoPlayer? = null + + private var nowPlayingNotification: NowPlayingNotification? = null var trackSelector: DefaultTrackSelector? = null lateinit var videoId: String @@ -76,7 +79,7 @@ abstract class AbstractPlayerService : LifecycleService() { override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) - onStateOrPlayingChanged?.let { it(player?.isPlaying ?: false) } + onStateOrPlayingChanged?.let { it(exoPlayer?.isPlaying ?: false) } this@AbstractPlayerService.onPlaybackStateChanged(playbackState) } @@ -96,103 +99,162 @@ abstract class AbstractPlayerService : LifecycleService() { super.onEvents(player, events) if (events.contains(Player.EVENT_TRACKS_CHANGED)) { - PlayerHelper.setPreferredAudioQuality(this@AbstractPlayerService, player, trackSelector ?: return) + PlayerHelper.setPreferredAudioQuality( + this@AbstractPlayerService, + player, + trackSelector ?: return + ) } } } - private val playerActionReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val event = intent.serializableExtra(PlayerHelper.CONTROL_TYPE) ?: return - val player = player ?: return + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + if (customCommand.customAction == START_SERVICE_ACTION) { + PlayingQueue.resetToDefaults() - if (PlayerHelper.handlePlayerAction(player, event)) return - - when (event) { - PlayerEvent.Next -> { - PlayingQueue.navigateNext() - } - PlayerEvent.Prev -> { - PlayingQueue.navigatePrev() - } - PlayerEvent.Stop -> { - onDestroy() - } - else -> Unit + CoroutineScope(Dispatchers.IO).launch { + onServiceCreated(args) + startPlayback() } + + return super.onCustomCommand(session, controller, customCommand, args) + } + + if (customCommand.customAction == RUN_PLAYER_COMMAND_ACTION) { + runPlayerCommand(args) + + return super.onCustomCommand(session, controller, customCommand, args) + } + + handlePlayerAction(PlayerEvent.valueOf(customCommand.customAction)) + + return super.onCustomCommand(session, controller, customCommand, args) + } + + open fun runPlayerCommand(args: Bundle) { + when { + args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> + exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name) + } + } + + private fun handlePlayerAction(event: PlayerEvent) { + if (PlayerHelper.handlePlayerAction(exoPlayer ?: return, event)) return + + when (event) { + PlayerEvent.Next -> { + PlayingQueue.navigateNext() + } + + PlayerEvent.Prev -> { + PlayingQueue.navigatePrev() + } + + PlayerEvent.Stop -> { + onDestroy() + } + + else -> Unit } } abstract val isOfflinePlayer: Boolean + abstract val isAudioOnlyPlayer: Boolean abstract val intentActivity: Class<*> + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = + mediaLibrarySession + override fun onCreate() { super.onCreate() - val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.playingOnBackground)) - .setSmallIcon(R.drawable.ic_launcher_lockscreen) - .build() - - startForeground(NotificationId.PLAYER_PLAYBACK.id, notification) - - ContextCompat.registerReceiver( + val notificationProvider = NowPlayingNotification( this, - playerActionReceiver, - IntentFilter(PlayerHelper.getIntentActionName(this)), - ContextCompat.RECEIVER_NOT_EXPORTED - ) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - PlayingQueue.resetToDefaults() - - lifecycleScope.launch { - if (intent != null) { - onServiceCreated(intent) - createPlayerAndNotification() - startPlaybackAndUpdateNotification() - } - else stopSelf() - } - - return super.onStartCommand(intent, flags, startId) - } - - abstract suspend fun onServiceCreated(intent: Intent) - - @OptIn(UnstableApi::class) - private fun createPlayerAndNotification() { - val trackSelector = DefaultTrackSelector(this) - this.trackSelector = trackSelector - - trackSelector.updateParameters { - setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) - } - - player = PlayerHelper.createPlayer(this, trackSelector, true) - // prevent android from putting LibreTube to sleep when locked - player!!.setWakeMode(C.WAKE_MODE_LOCAL) - player!!.addListener(playerListener) - - PlayerHelper.setPreferredCodecs(trackSelector) - - nowPlayingNotification = NowPlayingNotification( - this, - player!!, backgroundOnly = true, offlinePlayer = isOfflinePlayer, intentActivity = intentActivity ) + setMediaNotificationProvider(notificationProvider) + + createPlayerAndMediaSession() } - abstract suspend fun startPlaybackAndUpdateNotification() + abstract suspend fun onServiceCreated(args: Bundle) + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + + // Select the button to display. + val customLayout = listOf( + CommandButton.Builder() + .setDisplayName(getString(R.string.rewind)) + .setSessionCommand(SessionCommand(PlayerEvent.Prev.name, Bundle.EMPTY)) + .setIconResId(R.drawable.ic_prev_outlined) + .build(), + CommandButton.Builder() + .setDisplayName(getString(R.string.play_next)) + .setSessionCommand(SessionCommand(PlayerEvent.Next.name, Bundle.EMPTY)) + .setIconResId(R.drawable.ic_next_outlined) + .build(), + ) + val mediaNotificationSessionCommands = + connectionResult.availableSessionCommands.buildUpon() + .also { builder -> + builder.add(startServiceCommand) + builder.add(runPlayerActionCommand) + customLayout.forEach { commandButton -> + commandButton.sessionCommand?.let { builder.add(it) } + } + } + .build() + + val playerCommands = connectionResult.availablePlayerCommands.buildUpon() + .remove(Player.COMMAND_SEEK_TO_PREVIOUS) + .build() + + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(mediaNotificationSessionCommands) + .setAvailablePlayerCommands(playerCommands) + .setCustomLayout(customLayout) + .build() + } + + @OptIn(UnstableApi::class) + private fun createPlayerAndMediaSession() { + val trackSelector = DefaultTrackSelector(this) + this.trackSelector = trackSelector + + if (isAudioOnlyPlayer) { + trackSelector.updateParameters { + setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) + } + } + + val player = PlayerHelper.createPlayer(this, trackSelector, true) + // prevent android from putting LibreTube to sleep when locked + player.setWakeMode(if (isOfflinePlayer) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NETWORK) + player.addListener(playerListener) + this.exoPlayer = player + + PlayerHelper.setPreferredCodecs(trackSelector) + + mediaLibrarySession = MediaLibrarySession.Builder(this, player, this).build() + } + + abstract suspend fun startPlayback() fun saveWatchPosition() { if (isTransitioning || !PlayerHelper.watchPositionsVideo) return - player?.let { PlayerHelper.saveWatchPosition(it, videoId) } + exoPlayer?.let { PlayerHelper.saveWatchPosition(it, videoId) } } override fun onDestroy() { @@ -200,20 +262,19 @@ abstract class AbstractPlayerService : LifecycleService() { saveWatchPosition() - nowPlayingNotification?.destroySelf() nowPlayingNotification = null watchPositionTimer.destroy() handler.removeCallbacksAndMessages(null) runCatching { - player?.stop() - player?.release() + exoPlayer?.stop() + exoPlayer?.release() } - player = null - runCatching { - unregisterReceiver(playerActionReceiver) + kotlin.runCatching { + mediaLibrarySession?.release() + mediaLibrarySession = null } ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) @@ -234,19 +295,27 @@ abstract class AbstractPlayerService : LifecycleService() { abstract fun getChapters(): List - fun getCurrentPosition() = player?.currentPosition + fun getCurrentPosition() = exoPlayer?.currentPosition - fun getDuration() = player?.duration + fun getDuration() = exoPlayer?.duration - fun seekToPosition(position: Long) = player?.seekTo(position) + 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 { - super.onBind(intent) - return binder + override fun onBind(intent: Intent?): IBinder { + // attempt to return [MediaLibraryServiceBinder] first if matched + return super.onBind(intent) ?: binder + } + + companion object { + private const val START_SERVICE_ACTION = "start_service_action" + private const val RUN_PLAYER_COMMAND_ACTION = "run_player_command_action" + + val startServiceCommand = SessionCommand(START_SERVICE_ACTION, Bundle.EMPTY) + val runPlayerActionCommand = SessionCommand(RUN_PLAYER_COMMAND_ACTION, Bundle.EMPTY) } } diff --git a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt index 91a752d42..dd7fa3c31 100644 --- a/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OfflinePlayerService.kt @@ -1,7 +1,8 @@ package com.github.libretube.services import android.content.Intent -import androidx.lifecycle.lifecycleScope +import android.os.Bundle +import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -12,15 +13,16 @@ import com.github.libretube.db.obj.DownloadChapter import com.github.libretube.db.obj.DownloadWithItems import com.github.libretube.db.obj.filterByTab import com.github.libretube.enums.FileType -import com.github.libretube.extensions.serializableExtra +import com.github.libretube.extensions.serializable +import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.toAndroidUri import com.github.libretube.extensions.toID import com.github.libretube.helpers.PlayerHelper -import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.activities.NoInternetActivity import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.util.PlayingQueue +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -30,9 +32,10 @@ import kotlin.io.path.exists /** * A service to play downloaded audio in the background */ -@UnstableApi -class OfflinePlayerService : AbstractPlayerService() { +@OptIn(UnstableApi::class) +open class OfflinePlayerService : AbstractPlayerService() { override val isOfflinePlayer: Boolean = true + override val isAudioOnlyPlayer: Boolean = true private var noInternetService: Boolean = false override val intentActivity: Class<*> get() = if (noInternetService) NoInternetActivity::class.java else MainActivity::class.java @@ -41,17 +44,19 @@ class OfflinePlayerService : AbstractPlayerService() { private lateinit var downloadTab: DownloadTab private var shuffle: Boolean = false - override suspend fun onServiceCreated(intent: Intent) { - downloadTab = intent.serializableExtra(IntentData.downloadTab)!! - shuffle = intent.getBooleanExtra(IntentData.shuffle, false) - noInternetService = intent.getBooleanExtra(IntentData.noInternet, false) + private val scope = CoroutineScope(Dispatchers.Main) + + override suspend fun onServiceCreated(args: Bundle) { + downloadTab = args.serializable(IntentData.downloadTab)!! + shuffle = args.getBoolean(IntentData.shuffle, false) + noInternetService = args.getBoolean(IntentData.noInternet, false) videoId = if (shuffle) { runBlocking(Dispatchers.IO) { Database.downloadDao().getRandomVideoIdByFileType(FileType.AUDIO) } } else { - intent.getStringExtra(IntentData.videoId) + args.getString(IntentData.videoId) } ?: return PlayingQueue.clear() @@ -66,7 +71,7 @@ class OfflinePlayerService : AbstractPlayerService() { /** * Attempt to start an audio player with the given download items */ - override suspend fun startPlaybackAndUpdateNotification() { + override suspend fun startPlayback() { val downloadWithItems = withContext(Dispatchers.IO) { Database.downloadDao().findById(videoId) }!! @@ -75,13 +80,20 @@ class OfflinePlayerService : AbstractPlayerService() { PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem()) - val notificationData = PlayerNotificationData( - title = downloadWithItems.download.title, - uploaderName = downloadWithItems.download.uploader, - thumbnailPath = downloadWithItems.download.thumbnailPath - ) - nowPlayingNotification?.updatePlayerNotification(videoId, notificationData) + withContext(Dispatchers.Main) { + setMediaItem(downloadWithItems) + exoPlayer?.playWhenReady = PlayerHelper.playAutomatically + exoPlayer?.prepare() + if (PlayerHelper.watchPositionsAudio) { + PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let { + exoPlayer?.seekTo(it) + } + } + } + } + + open fun setMediaItem(downloadWithItems: DownloadWithItems) { val audioItem = downloadWithItems.downloadItems.filter { it.path.exists() } .firstOrNull { it.type == FileType.AUDIO } ?: // in some rare cases, video files can contain audio @@ -94,17 +106,10 @@ class OfflinePlayerService : AbstractPlayerService() { val mediaItem = MediaItem.Builder() .setUri(audioItem.path.toAndroidUri()) + .setMetadata(downloadWithItems.download) .build() - player?.setMediaItem(mediaItem) - player?.playWhenReady = PlayerHelper.playAutomatically - player?.prepare() - - if (PlayerHelper.watchPositionsAudio) { - PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let { - player?.seekTo(it) - } - } + exoPlayer?.setMediaItem(mediaItem) } private suspend fun fillQueue() { @@ -124,8 +129,8 @@ class OfflinePlayerService : AbstractPlayerService() { this.videoId = videoId - lifecycleScope.launch { - startPlaybackAndUpdateNotification() + scope.launch { + startPlayback() } } diff --git a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt index d2dc1a9ec..f6ddb6460 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -1,8 +1,7 @@ package com.github.libretube.services -import android.content.Intent +import android.os.Bundle import androidx.core.net.toUri -import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.Player @@ -14,17 +13,17 @@ import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Streams import com.github.libretube.constants.IntentData import com.github.libretube.db.DatabaseHelper -import com.github.libretube.extensions.parcelableExtra +import com.github.libretube.extensions.parcelable import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.toID import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper.checkForSegments import com.github.libretube.helpers.ProxyHelper -import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.parcelable.PlayerData import com.github.libretube.ui.activities.MainActivity import com.github.libretube.util.PlayingQueue +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -36,6 +35,7 @@ import kotlinx.serialization.encodeToString @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class OnlinePlayerService : AbstractPlayerService() { override val isOfflinePlayer: Boolean = false + override val isAudioOnlyPlayer: Boolean = true override val intentActivity: Class<*> = MainActivity::class.java // PlaylistId/ChannelId for autoplay @@ -53,8 +53,10 @@ class OnlinePlayerService : AbstractPlayerService() { private var sponsorBlockSegments = listOf() private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() - override suspend fun onServiceCreated(intent: Intent) { - val playerData = intent.parcelableExtra(IntentData.playerData) + private val scope = CoroutineScope(Dispatchers.IO) + + override suspend fun onServiceCreated(args: Bundle) { + val playerData = args.parcelable(IntentData.playerData) if (playerData == null) { stopSelf() return @@ -72,7 +74,7 @@ class OnlinePlayerService : AbstractPlayerService() { } } - override suspend fun startPlaybackAndUpdateNotification() { + override suspend fun startPlayback() { val timestamp = startTimestamp ?: 0L startTimestamp = null @@ -110,30 +112,24 @@ class OnlinePlayerService : AbstractPlayerService() { } private fun playAudio(seekToPosition: Long) { - lifecycleScope.launch(Dispatchers.IO) { + scope.launch { setMediaItem() withContext(Dispatchers.Main) { // seek to the previous position if available if (seekToPosition != 0L) { - player?.seekTo(seekToPosition) + exoPlayer?.seekTo(seekToPosition) } else if (PlayerHelper.watchPositionsAudio) { PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let { - player?.seekTo(it) + exoPlayer?.seekTo(it) } } } } - val playerNotificationData = PlayerNotificationData( - streams?.title, - streams?.uploader, - streams?.thumbnailUrl - ) - nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData) streams?.let { onNewVideoStarted?.invoke(it.toStreamItem(videoId)) } - player?.apply { + exoPlayer?.apply { playWhenReady = PlayerHelper.playAutomatically prepare() } @@ -146,7 +142,7 @@ class OnlinePlayerService : AbstractPlayerService() { */ private fun playNextVideo(nextId: String? = null) { if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) { - player?.seekTo(0) + exoPlayer?.seekTo(0) return } @@ -161,13 +157,13 @@ class OnlinePlayerService : AbstractPlayerService() { this.streams = null this.sponsorBlockSegments = emptyList() - lifecycleScope.launch { - startPlaybackAndUpdateNotification() + scope.launch { + startPlayback() } } /** - * Sets the [MediaItem] with the [streams] into the [player] + * Sets the [MediaItem] with the [streams] into the [exoPlayer] */ private suspend fun setMediaItem() { val streams = streams ?: return @@ -185,14 +181,14 @@ class OnlinePlayerService : AbstractPlayerService() { .setMimeType(mimeType) .setMetadata(streams) .build() - withContext(Dispatchers.Main) { player?.setMediaItem(mediaItem) } + withContext(Dispatchers.Main) { exoPlayer?.setMediaItem(mediaItem) } } /** * fetch the segments for SponsorBlock */ private fun fetchSponsorBlockSegments() { - lifecycleScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { runCatching { if (sponsorBlockConfig.isEmpty()) return@runCatching sponsorBlockSegments = RetrofitInstance.api.getSegments( @@ -210,7 +206,7 @@ class OnlinePlayerService : AbstractPlayerService() { private fun checkForSegments() { handler.postDelayed(this::checkForSegments, 100) - player?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig) + exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig) } override fun onPlaybackStateChanged(playbackState: Int) { @@ -230,7 +226,7 @@ class OnlinePlayerService : AbstractPlayerService() { // 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 - lifecycleScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) } } } diff --git a/app/src/main/java/com/github/libretube/services/VideoOfflinePlayerService.kt b/app/src/main/java/com/github/libretube/services/VideoOfflinePlayerService.kt new file mode 100644 index 000000000..fb160db58 --- /dev/null +++ b/app/src/main/java/com/github/libretube/services/VideoOfflinePlayerService.kt @@ -0,0 +1,85 @@ +package com.github.libretube.services + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.SubtitleConfiguration +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.FileDataSource +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.source.SingleSampleMediaSource +import com.github.libretube.db.obj.DownloadWithItems +import com.github.libretube.enums.FileType +import com.github.libretube.extensions.setMetadata +import com.github.libretube.extensions.toAndroidUri +import com.github.libretube.extensions.updateParameters +import kotlin.io.path.exists + +@OptIn(UnstableApi::class) +class VideoOfflinePlayerService: OfflinePlayerService() { + override val isAudioOnlyPlayer = false + + override fun setMediaItem(downloadWithItems: DownloadWithItems) { + val downloadFiles = downloadWithItems.downloadItems.filter { it.path.exists() } + + val videoUri = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.toAndroidUri() + val audioUri = downloadFiles.firstOrNull { it.type == FileType.AUDIO }?.path?.toAndroidUri() + val subtitleInfo = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE } + + val subtitle = subtitleInfo?.let { + SubtitleConfiguration.Builder(it.path.toAndroidUri()) + .setMimeType(MimeTypes.APPLICATION_TTML) + .setLanguage(it.language ?: "en") + .build() + } + + when { + videoUri != null && audioUri != null -> { + val videoItem = MediaItem.Builder() + .setUri(videoUri) + .setMetadata(downloadWithItems.download) + .setSubtitleConfigurations(listOfNotNull(subtitle)) + .build() + + val videoSource = ProgressiveMediaSource.Factory(FileDataSource.Factory()) + .createMediaSource(videoItem) + + val audioSource = ProgressiveMediaSource.Factory(FileDataSource.Factory()) + .createMediaSource(MediaItem.fromUri(audioUri)) + + var mediaSource = MergingMediaSource(audioSource, videoSource) + if (subtitle != null) { + val subtitleSource = SingleSampleMediaSource.Factory(FileDataSource.Factory()) + .createMediaSource(subtitle, C.TIME_UNSET) + + mediaSource = MergingMediaSource(mediaSource, subtitleSource) + } + + exoPlayer?.setMediaSource(mediaSource) + } + + videoUri != null -> exoPlayer?.setMediaItem( + MediaItem.Builder() + .setUri(videoUri) + .setMetadata(downloadWithItems.download) + .setSubtitleConfigurations(listOfNotNull(subtitle)) + .build() + ) + + audioUri != null -> exoPlayer?.setMediaItem( + MediaItem.Builder() + .setUri(audioUri) + .setMetadata(downloadWithItems.download) + .setSubtitleConfigurations(listOfNotNull(subtitle)) + .build() + ) + } + + trackSelector?.updateParameters { + setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + setPreferredTextLanguage(subtitle?.language) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/services/VideoOnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/VideoOnlinePlayerService.kt new file mode 100644 index 000000000..f6cfa413d --- /dev/null +++ b/app/src/main/java/com/github/libretube/services/VideoOnlinePlayerService.kt @@ -0,0 +1,179 @@ +package com.github.libretube.services + +import android.net.Uri +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.SubtitleConfiguration +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cronet.CronetDataSource +import androidx.media3.exoplayer.hls.HlsMediaSource +import com.github.libretube.R +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.Subtitle +import com.github.libretube.constants.IntentData +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.enums.PlayerCommand +import com.github.libretube.extensions.parcelable +import com.github.libretube.extensions.setMetadata +import com.github.libretube.extensions.toastFromMainThread +import com.github.libretube.extensions.updateParameters +import com.github.libretube.helpers.PlayerHelper +import com.github.libretube.helpers.PreferenceHelper +import com.github.libretube.helpers.ProxyHelper +import com.github.libretube.ui.activities.MainActivity +import com.github.libretube.util.YoutubeHlsPlaylistParser +import java.util.concurrent.Executors + +@OptIn(UnstableApi::class) +class VideoOnlinePlayerService : AbstractPlayerService() { + override val isOfflinePlayer: Boolean = false + override val isAudioOnlyPlayer: Boolean = false + override val intentActivity: Class<*> = MainActivity::class.java + + private val cronetDataSourceFactory = CronetDataSource.Factory( + CronetHelper.cronetEngine, + Executors.newCachedThreadPool() + ) + + private lateinit var streams: Streams + + override suspend fun onServiceCreated(args: Bundle) { + this.streams = args.parcelable(IntentData.streams) ?: return + + startPlayback() + } + + override suspend fun startPlayback() = Unit + + override fun runPlayerCommand(args: Bundle) { + when { + args.containsKey(PlayerCommand.START_PLAYBACK.name) -> setStreamSource() + args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name) + args.containsKey(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) -> trackSelector?.updateParameters { + setTrackTypeDisabled( + C.TRACK_TYPE_VIDEO, + args.getBoolean(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) + ) + } + args.containsKey(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name) -> { + trackSelector?.updateParameters { + setPreferredAudioRoleFlags(args.getInt(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name)) + } + } + args.containsKey(PlayerCommand.SET_AUDIO_LANGUAGE.name) -> { + trackSelector?.updateParameters { + setPreferredAudioLanguage(args.getString(PlayerCommand.SET_AUDIO_LANGUAGE.name)) + } + } + args.containsKey(PlayerCommand.SET_RESOLUTION.name) -> { + trackSelector?.updateParameters { + val resolution = args.getInt(PlayerCommand.SET_RESOLUTION.name) + setMinVideoSize(Int.MIN_VALUE, resolution) + setMaxVideoSize(Int.MAX_VALUE, resolution) + } + } + args.containsKey(PlayerCommand.SET_SUBTITLE.name) -> { + updateCurrentSubtitle(args.parcelable(PlayerCommand.SET_SUBTITLE.name)) + } + } + } + + private fun setStreamSource() { + if (!this::streams.isInitialized) return + + val (uri, mimeType) = when { + // LBRY HLS + PreferenceHelper.getBoolean( + PreferenceKeys.LBRY_HLS, + false + ) && streams.videoStreams.any { + it.quality.orEmpty().contains("LBRY HLS") + } -> { + val lbryHlsUrl = streams.videoStreams.first { + it.quality!!.contains("LBRY HLS") + }.url!! + lbryHlsUrl.toUri() to MimeTypes.APPLICATION_M3U8 + } + // DASH + !PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> { + // only use the dash manifest generated by YT if either it's a livestream or no other source is available + val dashUri = + if (streams.isLive && streams.dash != null) { + ProxyHelper.unwrapStreamUrl( + streams.dash!! + ).toUri() + } else { + // skip LBRY urls when checking whether the stream source is usable + PlayerHelper.createDashSource(streams, this) + } + + dashUri to MimeTypes.APPLICATION_MPD + } + // HLS + streams.hls != null -> { + val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory) + .setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory()) + + val mediaSource = hlsMediaSourceFactory.createMediaSource( + createMediaItem( + ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri(), + MimeTypes.APPLICATION_M3U8 + ) + ) + exoPlayer?.setMediaSource(mediaSource) + return + } + // NO STREAM FOUND + else -> { + toastFromMainThread(R.string.unknown_error) + return + } + } + setMediaSource(uri, mimeType) + } + + private fun getSubtitleConfigs(): List = streams.subtitles.map { + val roleFlags = getSubtitleRoleFlags(it) + SubtitleConfiguration.Builder(it.url!!.toUri()) + .setRoleFlags(roleFlags) + .setLanguage(it.code) + .setMimeType(it.mimeType).build() + } + + private fun createMediaItem(uri: Uri, mimeType: String) = MediaItem.Builder() + .setUri(uri) + .setMimeType(mimeType) + .setSubtitleConfigurations(getSubtitleConfigs()) + .setMetadata(streams) + .build() + + private fun setMediaSource(uri: Uri, mimeType: String) { + val mediaItem = createMediaItem(uri, mimeType) + exoPlayer?.setMediaItem(mediaItem) + } + + private fun getSubtitleRoleFlags(subtitle: Subtitle?): Int { + return if (subtitle?.autoGenerated != true) { + C.ROLE_FLAG_CAPTION + } else { + PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE + } + } + + private fun updateCurrentSubtitle(subtitle: Subtitle?) = + trackSelector?.updateParameters { + val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0 + setPreferredTextRoleFlags(roleFlags) + setPreferredTextLanguage(subtitle?.code) + } + + override fun onPlaybackStateChanged(playbackState: Int) = Unit + + override fun getChapters(): List = emptyList() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt index 59929c70c..ad2cd86bb 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt @@ -1,29 +1,23 @@ package com.github.libretube.ui.activities import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.ActivityInfo import android.content.res.Configuration -import android.net.Uri import android.os.Bundle import android.text.format.DateUtils import android.view.KeyEvent import androidx.activity.viewModels import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaItem.SubtitleConfiguration -import androidx.media3.common.MimeTypes import androidx.media3.common.Player -import androidx.media3.datasource.FileDataSource -import androidx.media3.exoplayer.source.MergingMediaSource -import androidx.media3.exoplayer.source.ProgressiveMediaSource -import androidx.media3.exoplayer.source.SingleSampleMediaSource +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import androidx.media3.ui.PlayerView import com.github.libretube.compat.PictureInPictureCompat import com.github.libretube.compat.PictureInPictureParamsCompat @@ -36,19 +30,16 @@ import com.github.libretube.db.obj.filterByTab import com.github.libretube.enums.FileType import com.github.libretube.enums.PlayerEvent import com.github.libretube.extensions.serializableExtra -import com.github.libretube.extensions.toAndroidUri -import com.github.libretube.extensions.updateParameters +import com.github.libretube.helpers.BackgroundHelper import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.WindowHelper -import com.github.libretube.obj.PlayerNotificationData +import com.github.libretube.services.VideoOfflinePlayerService import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.interfaces.TimeFrameReceiver import com.github.libretube.ui.listeners.SeekbarPreviewListener import com.github.libretube.ui.models.ChaptersViewModel import com.github.libretube.ui.models.CommonPlayerViewModel -import com.github.libretube.ui.models.OfflinePlayerViewModel -import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.OfflineTimeFrameReceiver import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PlayingQueue @@ -61,15 +52,15 @@ import kotlin.io.path.exists class OfflinePlayerActivity : BaseActivity() { private lateinit var binding: ActivityOfflinePlayerBinding private lateinit var videoId: String + + private lateinit var playerController: MediaController private lateinit var playerView: PlayerView private var timeFrameReceiver: TimeFrameReceiver? = null - private var nowPlayingNotification: NowPlayingNotification? = null private lateinit var playerBinding: ExoStyledPlayerControlViewBinding private val commonPlayerViewModel: CommonPlayerViewModel by viewModels() - private val viewModel: OfflinePlayerViewModel by viewModels { OfflinePlayerViewModel.Factory } private val chaptersViewModel: ChaptersViewModel by viewModels() - + private val watchPositionTimer = PauseableTimer( onTick = this::saveWatchPosition, delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS @@ -82,6 +73,13 @@ class OfflinePlayerActivity : BaseActivity() { playerBinding.duration.text = DateUtils.formatElapsedTime( player.duration / 1000 ) + + if (events.contains(Player.EVENT_TRACKS_CHANGED)) { + requestedOrientation = PlayerHelper.getOrientation( + playerController.videoSize.width, + playerController.videoSize.height + ) + } } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -110,7 +108,7 @@ class OfflinePlayerActivity : BaseActivity() { SeekbarPreviewListener( timeFrameReceiver ?: return, binding.player.binding, - viewModel.player.duration + playerController.duration ) ) } @@ -124,7 +122,7 @@ class OfflinePlayerActivity : BaseActivity() { private val playerActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val event = intent.serializableExtra(PlayerHelper.CONTROL_TYPE) ?: return - if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return + if (PlayerHelper.handlePlayerAction(playerController, event)) return when (event) { PlayerEvent.Prev -> playNextVideo(PlayingQueue.getPrev() ?: return) @@ -135,17 +133,23 @@ class OfflinePlayerActivity : BaseActivity() { } private val pipParams - get() = PictureInPictureParamsCompat.Builder() - .setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying)) - .setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying) - .setAspectRatio(viewModel.player.videoSize) - .build() + get() = run { + val isPlaying = ::playerController.isInitialized && playerController.isPlaying + + PictureInPictureParamsCompat.Builder() + .setActions(PlayerHelper.getPiPModeActions(this,isPlaying)) + .setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying) + .apply { + if (isPlaying) { + setAspectRatio(playerController.videoSize) + } + } + .build() + } override fun onCreate(savedInstanceState: Bundle?) { WindowHelper.toggleFullscreen(window, true) - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE - super.onCreate(savedInstanceState) videoId = intent?.getStringExtra(IntentData.videoId)!! @@ -160,13 +164,19 @@ class OfflinePlayerActivity : BaseActivity() { playNextVideo(streamItem.url ?: return@setOnQueueTapListener) } - initializePlayer() - playVideo() - - requestedOrientation = PlayerHelper.getOrientation( - viewModel.player.videoSize.width, - viewModel.player.videoSize.height + val sessionToken = SessionToken( + this, + ComponentName(this, VideoOfflinePlayerService::class.java) ) + val arguments = bundleOf( + IntentData.downloadTab to DownloadTab.VIDEO, + IntentData.videoId to videoId + ) + BackgroundHelper.startMediaService(this, sessionToken, arguments) { + playerController = it + playerController.addListener(playerListener) + initializePlayerView() + } ContextCompat.registerReceiver( this, @@ -188,14 +198,11 @@ class OfflinePlayerActivity : BaseActivity() { playVideo() } - private fun initializePlayer() { - viewModel.player.setWakeMode(C.WAKE_MODE_LOCAL) - viewModel.player.addListener(playerListener) - + private fun initializePlayerView() { playerView = binding.player playerView.setShowSubtitleButton(true) playerView.subtitleView?.isVisible = true - playerView.player = viewModel.player + playerView.player = playerController playerBinding = binding.player.binding playerBinding.fullscreen.isInvisible = true @@ -216,13 +223,6 @@ class OfflinePlayerActivity : BaseActivity() { binding.playerGestureControlsView.binding, chaptersViewModel ) - - nowPlayingNotification = NowPlayingNotification( - this, - viewModel.player, - offlinePlayer = true, - intentActivity = OfflinePlayerActivity::class.java - ) } private fun playVideo() { @@ -230,7 +230,6 @@ class OfflinePlayerActivity : BaseActivity() { val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) { Database.downloadDao().findById(videoId) }!! - PlayingQueue.updateCurrent(downloadInfo.toStreamItem()) val chapters = downloadChapters.map(DownloadChapter::toChapterSegment) chaptersViewModel.chaptersLiveData.value = chapters @@ -240,88 +239,15 @@ class OfflinePlayerActivity : BaseActivity() { playerBinding.exoTitle.text = downloadInfo.title playerBinding.exoTitle.isVisible = true - val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO } - val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO } - val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE } - - val videoUri = video?.path?.toAndroidUri() - val audioUri = audio?.path?.toAndroidUri() - val subtitleUri = subtitle?.path?.toAndroidUri() - - setMediaSource(videoUri, audioUri, subtitleUri) - - viewModel.trackSelector.updateParameters { - setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) - setPreferredTextLanguage("en") - } - - timeFrameReceiver = video?.path?.let { + timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let { OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it) } - viewModel.player.playWhenReady = PlayerHelper.playAutomatically - viewModel.player.prepare() - if (PlayerHelper.watchPositionsVideo) { PlayerHelper.getStoredWatchPosition(videoId, downloadInfo.duration)?.let { - viewModel.player.seekTo(it) + playerController.seekTo(it) } } - - val data = PlayerNotificationData( - downloadInfo.title, - downloadInfo.uploader, - downloadInfo.thumbnailPath.toString() - ) - nowPlayingNotification?.updatePlayerNotification(videoId, data) - } - } - - private fun setMediaSource(videoUri: Uri?, audioUri: Uri?, subtitleUri: Uri?) { - val subtitle = subtitleUri?.let { - SubtitleConfiguration.Builder(it) - .setMimeType(MimeTypes.APPLICATION_TTML) - .setLanguage("en") - .build() - } - - when { - videoUri != null && audioUri != null -> { - val videoItem = MediaItem.Builder() - .setUri(videoUri) - .setSubtitleConfigurations(listOfNotNull(subtitle)) - .build() - - val videoSource = ProgressiveMediaSource.Factory(FileDataSource.Factory()) - .createMediaSource(videoItem) - - val audioSource = ProgressiveMediaSource.Factory(FileDataSource.Factory()) - .createMediaSource(MediaItem.fromUri(audioUri)) - - var mediaSource = MergingMediaSource(audioSource, videoSource) - if (subtitle != null) { - val subtitleSource = SingleSampleMediaSource.Factory(FileDataSource.Factory()) - .createMediaSource(subtitle, C.TIME_UNSET) - - mediaSource = MergingMediaSource(mediaSource, subtitleSource) - } - - viewModel.player.setMediaSource(mediaSource) - } - - videoUri != null -> viewModel.player.setMediaItem( - MediaItem.Builder() - .setUri(videoUri) - .setSubtitleConfigurations(listOfNotNull(subtitle)) - .build() - ) - - audioUri != null -> viewModel.player.setMediaItem( - MediaItem.Builder() - .setUri(audioUri) - .setSubtitleConfigurations(listOfNotNull(subtitle)) - .build() - ) } } @@ -336,7 +262,7 @@ class OfflinePlayerActivity : BaseActivity() { private fun saveWatchPosition() { if (!PlayerHelper.watchPositionsVideo) return - PlayerHelper.saveWatchPosition(viewModel.player, videoId) + PlayerHelper.saveWatchPosition(playerController, videoId) } override fun onResume() { @@ -349,19 +275,17 @@ class OfflinePlayerActivity : BaseActivity() { super.onPause() if (PlayerHelper.pauseOnQuit) { - viewModel.player.pause() + playerController.pause() } } override fun onDestroy() { saveWatchPosition() - - nowPlayingNotification?.destroySelf() - nowPlayingNotification = null + watchPositionTimer.destroy() runCatching { - viewModel.player.stop() + playerController.stop() } runCatching { @@ -372,7 +296,7 @@ class OfflinePlayerActivity : BaseActivity() { } override fun onUserLeaveHint() { - if (PlayerHelper.pipEnabled && viewModel.player.isPlaying) { + if (PlayerHelper.pipEnabled && playerController.isPlaying) { PictureInPictureCompat.enterPictureInPictureMode(this, pipParams) } 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 index 8cc3d19eb..4a55a76ea 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/AudioPlayerFragment.kt @@ -155,10 +155,10 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { it.text = (PlayerHelper.seekIncrement / 1000).toString() } binding.rewindFL.setOnClickListener { - playerService?.player?.seekBy(-PlayerHelper.seekIncrement) + playerService?.exoPlayer?.seekBy(-PlayerHelper.seekIncrement) } binding.forwardFL.setOnClickListener { - playerService?.player?.seekBy(PlayerHelper.seekIncrement) + playerService?.exoPlayer?.seekBy(PlayerHelper.seekIncrement) } binding.openQueue.setOnClickListener { @@ -166,7 +166,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { } binding.playbackOptions.setOnClickListener { - playerService?.player?.let { + playerService?.exoPlayer?.let { PlaybackOptionsSheet(it) .show(childFragmentManager) } @@ -182,7 +182,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { NavigationHelper.navigateVideo( context = requireContext(), videoUrlOrId = PlayingQueue.getCurrent()?.url, - timestamp = playerService?.player?.currentPosition?.div(1000) ?: 0, + timestamp = playerService?.exoPlayer?.currentPosition?.div(1000) ?: 0, keepQueue = true, forceVideo = true ) @@ -192,7 +192,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { ChaptersBottomSheet.SEEK_TO_POSITION_REQUEST_KEY, viewLifecycleOwner ) { _, bundle -> - playerService?.player?.seekTo(bundle.getLong(IntentData.currentPosition)) + playerService?.exoPlayer?.seekTo(bundle.getLong(IntentData.currentPosition)) } binding.openChapters.setOnClickListener { @@ -202,7 +202,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { ChaptersBottomSheet() .apply { arguments = bundleOf( - IntentData.duration to playerService.player?.duration?.div(1000) + IntentData.duration to playerService.exoPlayer?.duration?.div(1000) ) } .show(childFragmentManager) @@ -218,11 +218,11 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { binding.thumbnail.setOnTouchListener(listener) binding.playPause.setOnClickListener { - playerService?.player?.togglePlayPauseState() + playerService?.exoPlayer?.togglePlayPauseState() } binding.miniPlayerPause.setOnClickListener { - playerService?.player?.togglePlayPauseState() + playerService?.exoPlayer?.togglePlayPauseState() } binding.showMore.setOnClickListener { @@ -381,7 +381,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { } private fun updatePlayPauseButton() { - playerService?.player?.let { + playerService?.exoPlayer?.let { val binding = _binding ?: return val iconRes = PlayerHelper.getPlayPauseActionIcon(it) @@ -396,8 +396,10 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { isPaused = !isPlaying } playerService?.onNewVideoStarted = { streamItem -> - updateStreamInfo(streamItem) - _binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty() + handler.post { + updateStreamInfo(streamItem) + _binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty() + } } initializeSeekBar() @@ -422,7 +424,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { } override fun onSingleTap() { - playerService?.player?.togglePlayPauseState() + playerService?.exoPlayer?.togglePlayPauseState() } override fun onLongTap() { @@ -479,7 +481,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions { if (_binding == null) return handler.postDelayed(this::updateChapterIndex, 100) - val player = playerService?.player ?: return + val player = playerService?.exoPlayer ?: return val currentIndex = PlayerHelper.getCurrentChapterIndex(player.currentPosition, chaptersModel.chapters) 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 d28a9ec20..6b2bc09bf 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 @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -11,7 +12,6 @@ import android.content.pm.ActivityInfo import android.content.res.Configuration import android.graphics.Bitmap import android.media.session.PlaybackState -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler @@ -45,16 +45,12 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaItem.SubtitleConfiguration -import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player -import androidx.media3.datasource.cronet.CronetDataSource -import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R -import com.github.libretube.api.CronetHelper import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Streams @@ -66,17 +62,16 @@ import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.FragmentPlayerBinding import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHolder +import com.github.libretube.enums.PlayerCommand import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.ShareObjectType import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.parcelable import com.github.libretube.extensions.serializableExtra -import com.github.libretube.extensions.setMetadata import com.github.libretube.extensions.toID import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.togglePlayPauseState import com.github.libretube.extensions.updateIfChanged -import com.github.libretube.extensions.updateParameters import com.github.libretube.helpers.BackgroundHelper import com.github.libretube.helpers.DownloadHelper import com.github.libretube.helpers.ImageHelper @@ -85,16 +80,16 @@ import com.github.libretube.helpers.NavBarHelper import com.github.libretube.helpers.NavigationHelper import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper.checkForSegments -import com.github.libretube.helpers.PlayerHelper.getVideoStats import com.github.libretube.helpers.PlayerHelper.isInSegment import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ThemeHelper import com.github.libretube.helpers.WindowHelper -import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.obj.ShareData import com.github.libretube.obj.VideoResolution import com.github.libretube.parcelable.PlayerData +import com.github.libretube.services.AbstractPlayerService +import com.github.libretube.services.VideoOnlinePlayerService import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.base.BaseActivity @@ -111,19 +106,15 @@ import com.github.libretube.ui.models.CommonPlayerViewModel import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.sheets.CommentsSheet -import com.github.libretube.ui.sheets.StatsSheet -import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.OnlineTimeFrameReceiver import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PlayingQueue import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils.toTimeInSeconds -import com.github.libretube.util.YoutubeHlsPlaylistParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import java.util.concurrent.Executors import kotlin.math.abs import kotlin.math.ceil @@ -138,9 +129,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { private val playerGestureControlsViewBinding get() = binding.playerGestureControlsView.binding private val commonPlayerViewModel: CommonPlayerViewModel by activityViewModels() - private val viewModel: PlayerViewModel by viewModels { PlayerViewModel.Factory } + private val viewModel: PlayerViewModel by viewModels() private val commentsViewModel: CommentsViewModel by activityViewModels() private val chaptersViewModel: ChaptersViewModel by activityViewModels() + private lateinit var playerController: MediaController // Video information passed by the intent private lateinit var videoId: String @@ -161,10 +153,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // if null, use same quality as fullscreen private var noFullscreenResolution: Int? = null - private val cronetDataSourceFactory = CronetDataSource.Factory( - CronetHelper.cronetEngine, - Executors.newCachedThreadPool() - ) + private var selectedAudioLanguageAndRoleFlags: Pair? = null private val handler = Handler(Looper.getMainLooper()) @@ -211,7 +200,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { override fun onReceive(context: Context, intent: Intent) { val event = intent.serializableExtra(PlayerHelper.CONTROL_TYPE) ?: return - if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return + if (PlayerHelper.handlePlayerAction(playerController, event)) return when (event) { PlayerEvent.Next -> { @@ -291,14 +280,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { ) { updatePlayPauseButton() } - - if (events.contains(Player.EVENT_TRACKS_CHANGED)) { - PlayerHelper.setPreferredAudioQuality( - requireContext(), - viewModel.player, - viewModel.trackSelector - ) - } } override fun onPlaybackStateChanged(playbackState: Int) { @@ -306,9 +287,9 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // set the playback speed to one if having reached the end of a livestream if (playbackState == Player.STATE_BUFFERING && binding.player.isLive && - viewModel.player.duration - viewModel.player.currentPosition < 700 + playerController.duration - playerController.currentPosition < 700 ) { - viewModel.player.setPlaybackSpeed(1f) + playerController.setPlaybackSpeed(1f) } // check if video has ended, next video is available and autoplay is enabled/the video is part of a played playlist. @@ -342,7 +323,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (playbackState == Player.STATE_BUFFERING) { if (bufferingTimeoutTask == null) { bufferingTimeoutTask = Runnable { - viewModel.player.pause() + playerController.pause() } } @@ -360,7 +341,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error) try { - viewModel.player.play() + playerController.play() } catch (e: Exception) { e.printStackTrace() } @@ -406,6 +387,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true) noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false) + + val sessionToken = SessionToken( + requireContext(), + ComponentName(requireContext(), VideoOnlinePlayerService::class.java) + ) + BackgroundHelper.startMediaService(requireContext(), sessionToken, bundleOf()) { + playerController = it + playerController.addListener(playerListener) + } } override fun onCreateView( @@ -431,7 +421,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { playerLayoutOrientation = resources.configuration.orientation - createExoPlayer() initializeTransitionLayout() initializeOnClickActions() @@ -591,7 +580,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } binding.playImageView.setOnClickListener { - viewModel.player.togglePlayPauseState() + playerController.togglePlayPauseState() } activity?.supportFragmentManager @@ -626,7 +615,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { IntentData.shareObjectType to ShareObjectType.VIDEO, IntentData.shareData to ShareData( currentVideo = streams.title, - currentPosition = viewModel.player.currentPosition / 1000 + currentPosition = playerController.currentPosition / 1000 ) ) val newShareDialog = ShareDialog() @@ -653,7 +642,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { binding.relPlayerBackground.setOnClickListener { // pause the current player - viewModel.player.pause() + playerController.pause() // start the background mode playOnBackground() @@ -712,7 +701,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { PixelCopy.request(surfaceView, bmp, { _ -> screenshotBitmap = bmp - val currentPosition = viewModel.player.currentPosition.toFloat() / 1000 + val currentPosition = + playerController.currentPosition.toFloat() / 1000 openScreenshotFile.launch("${streams.title}-${currentPosition}.png") }, Handler(Looper.getMainLooper())) } @@ -743,7 +733,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { BackgroundHelper.playOnBackground( requireContext(), videoId, - viewModel.player.currentPosition, + playerController.currentPosition, playlistId, channelId, keepQueue = true, @@ -756,8 +746,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { private fun updateFullscreenOrientation() { if (PlayerHelper.autoFullscreenEnabled || !this::streams.isInitialized) return - val height = streams.videoStreams.firstOrNull()?.height ?: viewModel.player.videoSize.height - val width = streams.videoStreams.firstOrNull()?.width ?: viewModel.player.videoSize.width + val height = streams.videoStreams.firstOrNull()?.height + ?: playerController.videoSize.height + val width = + streams.videoStreams.firstOrNull()?.width ?: playerController.videoSize.width mainActivity.requestedOrientation = PlayerHelper.getOrientation(width, height) } @@ -839,14 +831,16 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // disable video stream since it's not needed when screen off if (!isInteractive) { - viewModel.trackSelector.updateParameters { - setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) - } + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name to true + ) + ) } // pause player if screen off and setting enabled if (!isInteractive && PlayerHelper.pausePlayerOnScreenOffEnabled) { - viewModel.player.pause() + playerController.pause() } // the app was put somewhere in the background - remember to not automatically continue @@ -864,12 +858,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (closedVideo) { closedVideo = false - viewModel.nowPlayingNotification?.refreshNotification() } // re-enable and load video stream - viewModel.trackSelector.updateParameters { - setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false) + if (::playerController.isInitialized) { + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name to false + ) + ) } } @@ -878,13 +875,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { saveWatchPosition() - viewModel.nowPlayingNotification?.destroySelf() - viewModel.nowPlayingNotification = null watchPositionTimer.destroy() handler.removeCallbacksAndMessages(null) - viewModel.player.removeListener(playerListener) - viewModel.player.pause() + playerController.removeListener(playerListener) + playerController.pause() if (PlayerHelper.pipEnabled) { // disable the auto PiP mode for SDK >= 32 @@ -940,17 +935,17 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // save the watch position if video isn't finished and option enabled private fun saveWatchPosition() { if (!isPlayerTransitioning && PlayerHelper.watchPositionsVideo) { - PlayerHelper.saveWatchPosition(viewModel.player, videoId) + PlayerHelper.saveWatchPosition(playerController, videoId) } } private fun checkForSegments() { - if (!viewModel.player.isPlaying || !PlayerHelper.sponsorBlockEnabled) return + if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return handler.postDelayed(this::checkForSegments, 100) if (!viewModel.sponsorBlockEnabled || viewModel.segments.isEmpty()) return - viewModel.player.checkForSegments( + playerController.checkForSegments( requireContext(), viewModel.segments, viewModel.sponsorBlockConfig @@ -959,12 +954,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let binding.sbSkipBtn.isVisible = true binding.sbSkipBtn.setOnClickListener { - viewModel.player.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong()) + playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong()) segment.skipped = true } return } - if (!viewModel.player.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true + if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = + true } private fun playVideo() { @@ -983,6 +979,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } this@PlayerFragment.streams = streams!! + playerController.sendCustomCommand( + AbstractPlayerService.startServiceCommand, + bundleOf(IntentData.streams to streams) + ) } val isFirstVideo = PlayingQueue.isEmpty() @@ -1019,17 +1019,18 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { binding.player.apply { useController = false - player = viewModel.player + player = playerController } initializePlayerView() // don't continue playback when the fragment is re-created after Android killed it val wasIntentStopped = requireArguments().getBoolean(IntentData.wasIntentStopped, false) - viewModel.player.playWhenReady = PlayerHelper.playAutomatically && !wasIntentStopped + playerController.playWhenReady = + PlayerHelper.playAutomatically && !wasIntentStopped requireArguments().putBoolean(IntentData.wasIntentStopped, false) - viewModel.player.prepare() + playerController.prepare() if (binding.playerMotionLayout.progress != 1.0f) { // show controllers when not in picture in picture mode @@ -1039,13 +1040,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { binding.player.useController = true } } - // show the player notification - initializePlayerNotification() fetchSponsorBlockSegments() if (streams.category == Streams.categoryMusic) { - viewModel.player.setPlaybackSpeed(1f) + playerController.setPlaybackSpeed(1f) } viewModel.isOrientationChangeInProgress = false @@ -1076,7 +1075,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { */ private fun playNextVideo(nextId: String? = null) { if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) { - viewModel.player.seekTo(0) + playerController.seekTo(0) return } @@ -1117,7 +1116,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { viewModel, commonPlayerViewModel, viewLifecycleOwner, - viewModel.trackSelector, this ) @@ -1206,7 +1204,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { if (videoId == this.videoId) { // try finding the time stamp of the url and seek to it if found uri.getQueryParameter("t")?.toTimeInSeconds()?.let { - viewModel.player.seekTo(it * 1000) + playerController.seekTo(it * 1000) } } else { // YouTube video link without time or not the current video, thus load in player @@ -1215,7 +1213,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } private fun updatePlayPauseButton() { - binding.playImageView.setImageResource(PlayerHelper.getPlayPauseActionIcon(viewModel.player)) + binding.playImageView.setImageResource(PlayerHelper.getPlayPauseActionIcon(playerController)) } private suspend fun initializeHighlight(highlight: Segment) { @@ -1234,31 +1232,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { ) } - private fun getSubtitleConfigs(): List = streams.subtitles.map { - val roleFlags = getSubtitleRoleFlags(it) - SubtitleConfiguration.Builder(it.url!!.toUri()) - .setRoleFlags(roleFlags) - .setLanguage(it.code) - .setMimeType(it.mimeType).build() - } - - private fun createMediaItem(uri: Uri, mimeType: String) = MediaItem.Builder() - .setUri(uri) - .setMimeType(mimeType) - .setSubtitleConfigurations(getSubtitleConfigs()) - .setMetadata(streams) - .build() - - private fun setMediaSource(uri: Uri, mimeType: String) { - val mediaItem = createMediaItem(uri, mimeType) - viewModel.player.setMediaItem(mediaItem) - } - /** * Get all available player resolutions */ private fun getAvailableResolutions(): List { - val resolutions = viewModel.player.currentTracks.groups.asSequence() + val resolutions = playerController.currentTracks.groups.asSequence() .flatMap { group -> (0 until group.length).map { group.getTrackFormat(it).height @@ -1274,29 +1252,31 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { private fun initStreamSources() { // use the video's default audio track when starting playback - viewModel.trackSelector.updateParameters { - setPreferredAudioRoleFlags(C.ROLE_FLAG_MAIN) - } + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN + ) + ) // set the default subtitle if available updateCurrentSubtitle(viewModel.currentSubtitle) // set media source and resolution in the beginning - lifecycleScope.launch(Dispatchers.IO) { - setStreamSource() + updateResolutionOnFullscreenChange(commonPlayerViewModel.isFullscreen.value == true) + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, + bundleOf(PlayerCommand.START_PLAYBACK.name to true) + ) - withContext(Dispatchers.Main) { - // support for time stamped links - if (timeStamp != 0L) { - viewModel.player.seekTo(timeStamp * 1000) - // delete the time stamp because it already got consumed - timeStamp = 0L - } else if (!streams.isLive) { - // seek to the saved watch position - PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let { - viewModel.player.seekTo(it) - } - } + // support for time stamped links + if (timeStamp != 0L) { + playerController.seekTo(timeStamp * 1000) + // delete the time stamp because it already got consumed + timeStamp = 0L + } else if (!streams.isLive) { + // seek to the saved watch position + PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let { + playerController.seekTo(it) } } } @@ -1308,10 +1288,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { resolution } - viewModel.trackSelector.updateParameters { - setMaxVideoSize(Int.MAX_VALUE, transformedResolution) - setMinVideoSize(Int.MIN_VALUE, transformedResolution) - } + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_RESOLUTION.name to transformedResolution + ) + ) + + binding.player.selectedResolution = resolution } private fun updateResolutionOnFullscreenChange(isFullscreen: Boolean) { @@ -1322,86 +1305,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } } - private suspend fun setStreamSource() { - updateResolutionOnFullscreenChange(commonPlayerViewModel.isFullscreen.value == true) - - val (uri, mimeType) = when { - // LBRY HLS - PreferenceHelper.getBoolean( - PreferenceKeys.LBRY_HLS, - false - ) && streams.videoStreams.any { - it.quality.orEmpty().contains("LBRY HLS") - } -> { - val lbryHlsUrl = streams.videoStreams.first { - it.quality!!.contains("LBRY HLS") - }.url!! - lbryHlsUrl.toUri() to MimeTypes.APPLICATION_M3U8 - } - // DASH - !PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> { - // only use the dash manifest generated by YT if either it's a livestream or no other source is available - val dashUri = - if (streams.isLive && streams.dash != null) { - ProxyHelper.unwrapStreamUrl( - streams.dash!! - ).toUri() - } else { - // skip LBRY urls when checking whether the stream source is usable - PlayerHelper.createDashSource(streams, requireContext()) - } - - dashUri to MimeTypes.APPLICATION_MPD - } - // HLS - streams.hls != null -> { - val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory) - .setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory()) - - val mediaSource = hlsMediaSourceFactory.createMediaSource( - createMediaItem( - ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri(), - MimeTypes.APPLICATION_M3U8 - ) - ) - withContext(Dispatchers.Main) { viewModel.player.setMediaSource(mediaSource) } - return - } - // NO STREAM FOUND - else -> { - context?.toastFromMainDispatcher(R.string.unknown_error) - return - } - } - withContext(Dispatchers.Main) { setMediaSource(uri, mimeType) } - } - - private fun createExoPlayer() { - viewModel.player.setWakeMode(C.WAKE_MODE_NETWORK) - viewModel.player.addListener(playerListener) - - // control for the track sources like subtitles and audio source - PlayerHelper.setPreferredCodecs(viewModel.trackSelector) - } - - /** - * show the [NowPlayingNotification] for the current video - */ - private fun initializePlayerNotification() { - if (viewModel.nowPlayingNotification == null) { - viewModel.nowPlayingNotification = NowPlayingNotification( - requireContext(), - viewModel.player - ) - } - val playerNotificationData = PlayerNotificationData( - streams.title, - streams.uploader, - streams.thumbnailUrl - ) - viewModel.nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData) - } - /** * Use the sensor mode if auto fullscreen is enabled */ @@ -1438,24 +1341,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { .show(childFragmentManager) } - private fun getSubtitleRoleFlags(subtitle: Subtitle?): Int { - return if (subtitle?.autoGenerated != true) { - C.ROLE_FLAG_CAPTION - } else { - PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE - } - } - override fun onQualityClicked() { // get the available resolutions val resolutions = getAvailableResolutions() - val currentQuality = viewModel.trackSelector.parameters.maxVideoHeight // Dialog for quality selection BaseBottomSheet() .setSimpleItems( resolutions.map(VideoResolution::name), - preselectedItem = resolutions.firstOrNull { it.resolution == currentQuality }?.name + preselectedItem = resolutions.firstOrNull { it.resolution == binding.player.selectedResolution }?.name ) { which -> val newResolution = resolutions[which].resolution setPlayerResolution(newResolution, true) @@ -1473,7 +1367,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { override fun onAudioStreamClicked() { val context = requireContext() val audioLanguagesAndRoleFlags = PlayerHelper.getAudioLanguagesAndRoleFlagsFromTrackGroups( - viewModel.player.currentTracks.groups, + playerController.currentTracks.groups, false ) val audioLanguages = audioLanguagesAndRoleFlags.map { @@ -1499,18 +1393,22 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } else { baseBottomSheet.setSimpleItems( audioLanguages, - preselectedItem = audioLanguagesAndRoleFlags.firstOrNull { - val format = viewModel.player.audioFormat - format?.language == it.first && format?.roleFlags == it.second - }?.let { + preselectedItem = selectedAudioLanguageAndRoleFlags?.let { PlayerHelper.getAudioTrackNameFromFormat(context, it) }, ) { index -> val selectedAudioFormat = audioLanguagesAndRoleFlags[index] - viewModel.trackSelector.updateParameters { - setPreferredAudioLanguage(selectedAudioFormat.first) - setPreferredAudioRoleFlags(selectedAudioFormat.second) - } + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to selectedAudioFormat.second + ) + ) + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_AUDIO_LANGUAGE.name to selectedAudioFormat.first + ) + ) + selectedAudioLanguageAndRoleFlags = selectedAudioFormat } } @@ -1523,10 +1421,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { override fun onStatsClicked() { if (!this::streams.isInitialized) return - val videoStats = getVideoStats(viewModel.player, videoId) - StatsSheet() - .apply { arguments = bundleOf(IntentData.videoStats to videoStats) } - .show(childFragmentManager) + // TODO: reimplement video stats +// val videoStats = getVideoStats(playerController, videoId) +// StatsSheet() +// .apply { arguments = bundleOf(IntentData.videoStats to videoStats) } +// .show(childFragmentManager) } override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { @@ -1545,8 +1444,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // close button got clicked in PiP mode // pause the video and keep the app alive if (lifecycle.currentState == Lifecycle.State.CREATED) { - viewModel.player.pause() - viewModel.nowPlayingNotification?.cancelNotification() + playerController.pause() closedVideo = true } @@ -1559,36 +1457,43 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } } - private fun updateCurrentSubtitle(subtitle: Subtitle?) = - viewModel.trackSelector.updateParameters { - val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0 - setPreferredTextRoleFlags(roleFlags) - setPreferredTextLanguage(subtitle?.code) - } + private fun updateCurrentSubtitle(subtitle: Subtitle?) { + if (!::playerController.isInitialized) return + + playerController.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, bundleOf( + PlayerCommand.SET_SUBTITLE.name to subtitle + ) + ) + } fun onUserLeaveHint() { if (shouldStartPiP()) { PictureInPictureCompat.enterPictureInPictureMode(requireActivity(), pipParams) } else if (PlayerHelper.pauseOnQuit) { - viewModel.player.pause() + playerController.pause() } } - private val pipParams - get() = PictureInPictureParamsCompat.Builder() - .setActions( - PlayerHelper.getPiPModeActions( - requireActivity(), - viewModel.player.isPlaying + private val pipParams: PictureInPictureParamsCompat + get() = run { + val isPlaying = ::playerController.isInitialized && playerController.isPlaying + + PictureInPictureParamsCompat.Builder() + .setActions( + PlayerHelper.getPiPModeActions( + requireActivity(), + isPlaying + ) ) - ) - .setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying) - .apply { - if (viewModel.player.isPlaying) { - setAspectRatio(viewModel.player.videoSize) + .setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying) + .apply { + if (isPlaying) { + setAspectRatio(playerController.videoSize) + } } - } - .build() + .build() + } private fun createSeekbarPreviewListener(): SeekbarPreviewListener { return SeekbarPreviewListener( @@ -1606,7 +1511,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { } private fun shouldStartPiP(): Boolean { - return shouldUsePip() && viewModel.player.isPlaying && + return shouldUsePip() && playerController.isPlaying && !BackgroundHelper.isBackgroundServiceRunning(requireContext()) } @@ -1620,7 +1525,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { val orientation = resources.configuration.orientation if (commonPlayerViewModel.isFullscreen.value != true && orientation != playerLayoutOrientation) { // remember the current position before recreating the activity - arguments?.putLong(IntentData.timeStamp, viewModel.player.currentPosition / 1000) + arguments?.putLong( + IntentData.timeStamp, + playerController.currentPosition / 1000 + ) playerLayoutOrientation = orientation viewModel.isOrientationChangeInProgress = true diff --git a/app/src/main/java/com/github/libretube/ui/models/OfflinePlayerViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/OfflinePlayerViewModel.kt deleted file mode 100644 index 94ef31025..000000000 --- a/app/src/main/java/com/github/libretube/ui/models/OfflinePlayerViewModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.libretube.ui.models - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import com.github.libretube.helpers.PlayerHelper - -@UnstableApi -class OfflinePlayerViewModel( - val player: ExoPlayer, - val trackSelector: DefaultTrackSelector, -) : ViewModel() { - - companion object { - val Factory = viewModelFactory { - initializer { - val context = this[APPLICATION_KEY]!! - val trackSelector = DefaultTrackSelector(context) - OfflinePlayerViewModel( - player = PlayerHelper.createPlayer(context, trackSelector, false), - trackSelector = trackSelector, - ) - } - } - } - - override fun onCleared() { - super.onCleared() - player.release() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt index df8d38a22..d03e7c252 100644 --- a/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt @@ -2,17 +2,10 @@ package com.github.libretube.ui.models import android.content.Context import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import com.github.libretube.R import com.github.libretube.api.JsonHelper import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.StreamsExtractor -import com.github.libretube.api.obj.Message import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Subtitle @@ -24,10 +17,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString @UnstableApi -class PlayerViewModel( - val player: ExoPlayer, - val trackSelector: DefaultTrackSelector, -) : ViewModel() { +class PlayerViewModel : ViewModel() { // data to remember for recovery on orientation change private var streamsInfo: Streams? = null @@ -69,22 +59,4 @@ class PlayerViewModel( ).segments } } - - companion object { - val Factory = viewModelFactory { - initializer { - val context = this[APPLICATION_KEY]!! - val trackSelector = DefaultTrackSelector(context) - PlayerViewModel( - player = PlayerHelper.createPlayer(context, trackSelector, false), - trackSelector = trackSelector, - ) - } - } - } - - override fun onCleared() { - super.onCleared() - player.release() - } } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/sheets/PlaybackOptionsSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/PlaybackOptionsSheet.kt index e5db3fa8c..ed33c7cc6 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/PlaybackOptionsSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/PlaybackOptionsSheet.kt @@ -4,17 +4,22 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.bundleOf import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaController import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.PlaybackBottomSheetBinding +import com.github.libretube.enums.PlayerCommand import com.github.libretube.extensions.round import com.github.libretube.helpers.PreferenceHelper +import com.github.libretube.services.AbstractPlayerService import com.github.libretube.ui.adapters.SliderLabelsAdapter class PlaybackOptionsSheet( - private val player: ExoPlayer + private val player: Player ) : ExpandedBottomSheet() { private var _binding: PlaybackBottomSheetBinding? = null private val binding get() = _binding!! @@ -32,8 +37,10 @@ class PlaybackOptionsSheet( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val binding = binding - binding.speedShortcuts.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - binding.pitchShortcuts.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.speedShortcuts.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.pitchShortcuts.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) binding.speedShortcuts.adapter = SliderLabelsAdapter(SUGGESTED_SPEEDS) { binding.speed.value = it @@ -57,7 +64,15 @@ class PlaybackOptionsSheet( } binding.skipSilence.setOnCheckedChangeListener { _, isChecked -> - player.skipSilenceEnabled = isChecked + // TODO: unify the skip silence handling + if (player is ExoPlayer) { + player.skipSilenceEnabled = isChecked + } else if (player is MediaController) { + player.sendCustomCommand( + AbstractPlayerService.runPlayerActionCommand, + bundleOf(PlayerCommand.SKIP_SILENCE.name to isChecked) + ) + } PreferenceHelper.putBoolean(PreferenceKeys.SKIP_SILENCE, isChecked) } } diff --git a/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt b/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt index f21850eee..fe2015207 100644 --- a/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt +++ b/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt @@ -31,7 +31,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.text.Cue -import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaController import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.PlayerView @@ -601,8 +601,8 @@ abstract class CustomExoPlayerView( } override fun onPlaybackSpeedClicked() { - player?.let { - PlaybackOptionsSheet(it as ExoPlayer).show(supportFragmentManager) + (player as? MediaController)?.let { + PlaybackOptionsSheet(it).show(supportFragmentManager) } } diff --git a/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt b/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt index 5b34c9473..f910dea02 100644 --- a/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt +++ b/app/src/main/java/com/github/libretube/ui/views/OnlinePlayerView.kt @@ -14,7 +14,6 @@ import androidx.lifecycle.LifecycleOwner import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.trackselection.TrackSelector import com.github.libretube.R import com.github.libretube.constants.IntentData import com.github.libretube.constants.PreferenceKeys @@ -29,7 +28,6 @@ import com.github.libretube.ui.dialogs.SubmitSegmentDialog import com.github.libretube.ui.interfaces.OnlinePlayerOptions import com.github.libretube.ui.models.CommonPlayerViewModel import com.github.libretube.ui.models.PlayerViewModel -import com.github.libretube.ui.sheets.PlayingQueueSheet import com.github.libretube.util.PlayingQueue @UnstableApi @@ -40,7 +38,6 @@ class OnlinePlayerView( private var playerOptions: OnlinePlayerOptions? = null private var playerViewModel: PlayerViewModel? = null private var commonPlayerViewModel: CommonPlayerViewModel? = null - private var trackSelector: TrackSelector? = null private var viewLifecycleOwner: LifecycleOwner? = null private val handler = Handler(Looper.getMainLooper()) @@ -51,6 +48,8 @@ class OnlinePlayerView( */ var currentWindow: Window? = null + var selectedResolution: Int? = null + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) override fun getOptionsMenuItems(): List { return super.getOptionsMenuItems() + @@ -72,7 +71,7 @@ class OnlinePlayerView( BottomSheetItem( context.getString(R.string.captions), R.drawable.ic_caption, - this::getCurrentCaptionLanguage + { playerViewModel?.currentSubtitle?.code ?: context.getString(R.string.none) } ) { playerOptions?.onCaptionsClicked() }, @@ -89,25 +88,14 @@ class OnlinePlayerView( private fun getCurrentResolutionSummary(): String { val currentQuality = player?.videoSize?.height ?: 0 var summary = "${currentQuality}p" - val trackSelector = trackSelector ?: return summary - val selectedQuality = trackSelector.parameters.maxVideoHeight - if (selectedQuality == Int.MAX_VALUE) { + if (selectedResolution == null) { summary += " - ${context.getString(R.string.auto)}" - } else if (selectedQuality > currentQuality) { + } else if ((selectedResolution ?: 0) > currentQuality) { summary += " - ${context.getString(R.string.resolution_limited)}" } return summary } - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) - private fun getCurrentCaptionLanguage(): String { - return if (trackSelector != null && trackSelector!!.parameters.preferredTextLanguages.isNotEmpty()) { - trackSelector!!.parameters.preferredTextLanguages[0] - } else { - context.getString(R.string.none) - } - } - private fun getCurrentAudioTrackTitle(): String { if (player == null) { return context.getString(R.string.unknown_or_no_audio) @@ -153,13 +141,11 @@ class OnlinePlayerView( playerViewModel: PlayerViewModel, commonPlayerViewModel: CommonPlayerViewModel, viewLifecycleOwner: LifecycleOwner, - trackSelector: TrackSelector, playerOptions: OnlinePlayerOptions ) { this.playerViewModel = playerViewModel this.commonPlayerViewModel = commonPlayerViewModel this.viewLifecycleOwner = viewLifecycleOwner - this.trackSelector = trackSelector this.playerOptions = playerOptions commonPlayerViewModel.isFullscreen.observe(viewLifecycleOwner) { isFullscreen -> 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 8355fcc26..75445f0a7 100644 --- a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt +++ b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt @@ -1,75 +1,35 @@ package com.github.libretube.util -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.os.Build import android.os.Bundle -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.annotation.DrawableRes -import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat -import androidx.core.content.getSystemService -import androidx.media.app.NotificationCompat.MediaStyle -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaSession import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME import com.github.libretube.R import com.github.libretube.constants.IntentData import com.github.libretube.enums.NotificationId import com.github.libretube.enums.PlayerEvent -import com.github.libretube.extensions.toMediaMetadataCompat -import com.github.libretube.helpers.ImageHelper import com.github.libretube.helpers.PlayerHelper -import com.github.libretube.obj.PlayerNotificationData -import com.github.libretube.services.OnClearFromRecentService import com.github.libretube.ui.activities.MainActivity -import java.util.UUID +import com.google.common.collect.ImmutableList @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class NowPlayingNotification( private val context: Context, - private val player: ExoPlayer, private val backgroundOnly: Boolean = false, private val offlinePlayer: Boolean = false, private val intentActivity: Class<*> = MainActivity::class.java -) { - private var videoId: String? = null - private val nManager = context.getSystemService()!! - - /** - * The metadata of the current playing song (thumbnail, title, uploader) - */ - private var notificationData: PlayerNotificationData? = null - - /** - * The [MediaSessionCompat] for the [notificationData]. - */ - private lateinit var mediaSession: MediaSessionCompat - - /** - * The [NotificationCompat.Builder] to load the [mediaSession] content on it. - */ - private var notificationBuilder: NotificationCompat.Builder? = null - - /** - * The [Bitmap] which represents the background / thumbnail of the notification - */ - private var notificationBitmap: Bitmap? = null - - private fun loadCurrentLargeIcon() { - if (DataSaverMode.isEnabled(context)) return - - if (notificationBitmap == null) { - enqueueThumbnailRequest { - createOrUpdateNotification() - } - } - } +): MediaNotification.Provider { + private val nProvider = DefaultMediaNotificationProvider.Builder(context) + .setNotificationId(NotificationId.PLAYER_PLAYBACK.id) + .setChannelId(PLAYER_CHANNEL_NAME) + .setChannelName(R.string.player_channel_name) + .build() private fun createCurrentContentIntent(): PendingIntent? { // starts a new MainActivity Intent when the player notification is clicked @@ -84,194 +44,10 @@ class NowPlayingNotification( } } - return PendingIntentCompat .getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, false) } - private fun createIntent(action: String): PendingIntent? { - val intent = Intent(action) - .setPackage(context.packageName) - - return PendingIntentCompat - .getBroadcast(context, 1, intent, PendingIntent.FLAG_CANCEL_CURRENT, false) - } - - private fun enqueueThumbnailRequest(callback: (Bitmap) -> Unit) { - ImageHelper.getImageWithCallback( - context, - notificationData?.thumbnailPath?.toString() ?: notificationData?.thumbnailUrl - ) { - notificationBitmap = processBitmap(it) - callback.invoke(notificationBitmap!!) - } - } - - private fun processBitmap(bitmap: Bitmap): Bitmap { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - bitmap - } else { - ImageHelper.getSquareBitmap(bitmap) - } - } - - private val legacyNotificationButtons - get() = listOf( - createNotificationAction(R.drawable.ic_prev_outlined, PlayerEvent.Prev.name), - createNotificationAction( - if (player.isPlaying) R.drawable.ic_pause else R.drawable.ic_play, - PlayerEvent.PlayPause.name - ), - createNotificationAction(R.drawable.ic_next_outlined, PlayerEvent.Next.name), - createNotificationAction(R.drawable.ic_rewind_md, PlayerEvent.Rewind.name), - createNotificationAction(R.drawable.ic_forward_md, PlayerEvent.Forward.name) - ) - - private fun createNotificationAction( - drawableRes: Int, - actionName: String - ): NotificationCompat.Action { - return NotificationCompat.Action.Builder(drawableRes, actionName, createIntent(actionName)) - .build() - } - - private fun createMediaSessionAction( - @DrawableRes drawableRes: Int, - actionName: String - ): PlaybackStateCompat.CustomAction { - return PlaybackStateCompat.CustomAction.Builder(actionName, actionName, drawableRes).build() - } - - /** - * Creates a [MediaSessionCompat] for the player - */ - private fun createMediaSession() { - if (this::mediaSession.isInitialized) return - - val sessionCallback = object : MediaSessionCompat.Callback() { - override fun onRewind() { - handlePlayerAction(PlayerEvent.Rewind) - super.onRewind() - } - - override fun onFastForward() { - handlePlayerAction(PlayerEvent.Forward) - super.onFastForward() - } - - override fun onPlay() { - handlePlayerAction(PlayerEvent.PlayPause) - super.onPlay() - } - - override fun onPause() { - handlePlayerAction(PlayerEvent.PlayPause) - super.onPause() - } - - override fun onSkipToNext() { - handlePlayerAction(PlayerEvent.Next) - super.onSkipToNext() - } - - override fun onSkipToPrevious() { - handlePlayerAction(PlayerEvent.Prev) - super.onSkipToPrevious() - } - - override fun onStop() { - handlePlayerAction(PlayerEvent.Stop) - super.onStop() - } - - override fun onSeekTo(pos: Long) { - player.seekTo(pos) - super.onSeekTo(pos) - } - - override fun onCustomAction(action: String, extras: Bundle?) { - runCatching { handlePlayerAction(PlayerEvent.valueOf(action)) } - super.onCustomAction(action, extras) - } - } - - mediaSession = MediaSessionCompat(context, UUID.randomUUID().toString()) - mediaSession.setCallback(sessionCallback) - - updateSessionMetadata() - updateSessionPlaybackState() - - val playerStateListener = object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - super.onIsPlayingChanged(isPlaying) - updateSessionPlaybackState(isPlaying = isPlaying) - } - - override fun onIsLoadingChanged(isLoading: Boolean) { - super.onIsLoadingChanged(isLoading) - - if (!isLoading) { - updateSessionMetadata() - } - - updateSessionPlaybackState(isLoading = isLoading) - } - - override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { - super.onMediaMetadataChanged(mediaMetadata) - updateSessionMetadata(mediaMetadata) - } - } - - player.addListener(playerStateListener) - } - - private fun updateSessionMetadata(metadata: MediaMetadata? = null) { - val data = metadata ?: player.mediaMetadata - val newMetadata = data.toMediaMetadataCompat(player.duration, notificationBitmap) - mediaSession.setMetadata(newMetadata) - } - - private fun updateSessionPlaybackState(isPlaying: Boolean? = null, isLoading: Boolean? = null) { - val loading = isLoading == true || (isPlaying == false && player.isLoading) - - val newPlaybackState = when { - loading -> PlaybackStateCompat.STATE_BUFFERING - isPlaying ?: player.isPlaying -> PlaybackStateCompat.STATE_PLAYING - else -> PlaybackStateCompat.STATE_PAUSED - } - - mediaSession.setPlaybackState(createPlaybackState(newPlaybackState)) - } - - private fun createPlaybackState(@PlaybackStateCompat.State state: Int): PlaybackStateCompat { - val stateActions = PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_REWIND or - PlaybackStateCompat.ACTION_FAST_FORWARD or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_SEEK_TO - - return PlaybackStateCompat.Builder() - .setActions(stateActions) - .addCustomAction( - createMediaSessionAction( - R.drawable.ic_rewind_md, - PlayerEvent.Rewind.name - ) - ) - .addCustomAction( - createMediaSessionAction( - R.drawable.ic_forward_md, - PlayerEvent.Forward.name - ) - ) - .setState(state, player.currentPosition, player.playbackParameters.speed) - .build() - } - /** * Forward the action to the responsible notification owner (e.g. PlayerFragment) */ @@ -282,79 +58,23 @@ class NowPlayingNotification( context.sendBroadcast(intent) } - /** - * Updates or creates the [notificationBuilder] - */ - fun updatePlayerNotification(videoId: String, data: PlayerNotificationData) { - this.videoId = videoId - this.notificationData = data - // reset the thumbnail bitmap in order to become reloaded for the new video - this.notificationBitmap = null - - loadCurrentLargeIcon() - - if (notificationBuilder == null) { - createMediaSession() - createNotificationBuilder() - // update the notification each time the player continues playing or pauses - player.addListener(object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - createOrUpdateNotification() - super.onIsPlayingChanged(isPlaying) - } - }) - context.startService(Intent(context, OnClearFromRecentService::class.java)) - } - - createOrUpdateNotification() + override fun createNotification( + mediaSession: MediaSession, + customLayout: ImmutableList, + actionFactory: MediaNotification.ActionFactory, + onNotificationChangedCallback: MediaNotification.Provider.Callback + ): MediaNotification { + createCurrentContentIntent()?.let { mediaSession.setSessionActivity(it) } + nProvider.setSmallIcon(R.drawable.ic_launcher_lockscreen) + return nProvider.createNotification(mediaSession, customLayout, actionFactory, onNotificationChangedCallback) } - /** - * Initializes the [notificationBuilder] attached to the [player] and shows it. - */ - private fun createNotificationBuilder() { - notificationBuilder = NotificationCompat.Builder(context, PLAYER_CHANNEL_NAME) - .setSmallIcon(R.drawable.ic_launcher_lockscreen) - .setContentIntent(createCurrentContentIntent()) - .setDeleteIntent(createIntent(PlayerEvent.Stop.name)) - .setStyle( - MediaStyle() - .setMediaSession(mediaSession.sessionToken) - .setShowActionsInCompactView(1) - ) - } - - private fun createOrUpdateNotification() { - if (notificationBuilder == null) return - val notification = notificationBuilder!! - .setContentTitle(notificationData?.title) - .setContentText(notificationData?.uploaderName) - .setLargeIcon(notificationBitmap) - .clearActions() - .apply { - legacyNotificationButtons.forEach { - addAction(it) - } - } - .build() - updateSessionMetadata() - nManager.notify(NotificationId.PLAYER_PLAYBACK.id, notification) - } - - /** - * Destroy the [NowPlayingNotification] - */ - fun destroySelf() { - if (this::mediaSession.isInitialized) mediaSession.release() - - nManager.cancel(NotificationId.PLAYER_PLAYBACK.id) - } - - fun cancelNotification() { - nManager.cancel(NotificationId.PLAYER_PLAYBACK.id) - } - - fun refreshNotification() { - createOrUpdateNotification() + override fun handleCustomCommand( + session: MediaSession, + action: String, + extras: Bundle + ): Boolean { + runCatching { handlePlayerAction(PlayerEvent.valueOf(action)) } + return true } }