From efe63e0de3a11ad95580b17243bef082319715b0 Mon Sep 17 00:00:00 2001 From: zechs Date: Mon, 27 Oct 2025 16:34:55 +0530 Subject: [PATCH 1/2] prevent null on next/previous --- app/src/main/java/zechs/zplex/ui/player/PlayerViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/zechs/zplex/ui/player/PlayerViewModel.kt b/app/src/main/java/zechs/zplex/ui/player/PlayerViewModel.kt index 5414f5d..31a96bb 100644 --- a/app/src/main/java/zechs/zplex/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/zechs/zplex/ui/player/PlayerViewModel.kt @@ -185,11 +185,13 @@ class PlayerViewModel @Inject constructor( } fun next() { + if (head?.next == null) return head = head?.next updateWithToken() } fun previous() { + if (head?.prev == null) return head = head?.prev updateWithToken() } From 6435748434bc4a0d3373e06d73a43a617a92f901 Mon Sep 17 00:00:00 2001 From: zechs Date: Mon, 27 Oct 2025 16:36:18 +0530 Subject: [PATCH 2/2] implement MediaSession --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 8 +- .../java/zechs/zplex/ui/player/MPVActivity.kt | 301 +++++++++++++++++- .../zplex/ui/player/MediaMetadataData.kt | 23 ++ .../ui/player/NotificationButtonReceiver.kt | 19 +- app/src/main/res/drawable/ic_next_24.xml | 2 +- app/src/main/res/drawable/ic_previous_24.xml | 2 +- app/src/main/res/drawable/ic_tv_play.xml | 10 + 8 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/zechs/zplex/ui/player/MediaMetadataData.kt create mode 100644 app/src/main/res/drawable/ic_tv_play.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0819a97..477919a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,6 +113,10 @@ dependencies { val roomVersion = "2.7.2" val testExtJunitVersion = "1.3.0" val workVersion = "2.10.3" + val mediaVersion = "1.7.1" + + // Media Session + implementation("androidx.media:media:$mediaVersion") // --- AndroidX Core --- implementation("androidx.core:core-ktx:$kotlinCoreVersion") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 566d377..35e1b03 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -77,7 +77,13 @@ - + + + + + \ No newline at end of file diff --git a/app/src/main/java/zechs/zplex/ui/player/MPVActivity.kt b/app/src/main/java/zechs/zplex/ui/player/MPVActivity.kt index 55aa429..e211eb2 100644 --- a/app/src/main/java/zechs/zplex/ui/player/MPVActivity.kt +++ b/app/src/main/java/zechs/zplex/ui/player/MPVActivity.kt @@ -1,7 +1,11 @@ package zechs.zplex.ui.player +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.PictureInPictureParams import android.app.RemoteAction +import android.content.ComponentName import android.content.Context import android.content.res.Configuration import android.graphics.drawable.Icon @@ -16,6 +20,9 @@ import android.media.AudioManager.STREAM_MUSIC import android.net.Uri import android.os.Build import android.os.Bundle +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import android.util.Rational import android.view.View @@ -27,6 +34,7 @@ import androidx.activity.viewModels import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.core.view.WindowCompat @@ -38,10 +46,13 @@ import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.media.session.MediaButtonReceiver import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.AutoTransition import androidx.transition.Fade import androidx.transition.TransitionManager +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.sidesheet.SideSheetDialog import com.google.android.material.snackbar.Snackbar @@ -50,6 +61,7 @@ import com.samsung.android.sdk.penremote.ButtonEvent import com.samsung.android.sdk.penremote.SpenEventListener import com.samsung.android.sdk.penremote.SpenUnit import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import zechs.mpv.MPVLib import zechs.mpv.MPVLib.mpvEventId.MPV_EVENT_END_FILE @@ -58,11 +70,13 @@ import zechs.mpv.MPVLib.mpvEventId.MPV_EVENT_PLAYBACK_RESTART import zechs.mpv.MPVView import zechs.mpv.utils.Utils import zechs.zplex.R +import zechs.zplex.data.model.PosterSize import zechs.zplex.databinding.ActivityMpvBinding import zechs.zplex.databinding.PlayerControlViewBinding import zechs.zplex.databinding.SideSheetEpisodesBinding import zechs.zplex.ui.player.sidesheet.episodes.adapter.SideSheetEpisodesAdapter import zechs.zplex.utils.Constants.DRIVE_API +import zechs.zplex.utils.Constants.TMDB_IMAGE_PREFIX import zechs.zplex.utils.SpenRemoteHelper import zechs.zplex.utils.state.Resource import zechs.zplex.utils.util.Orientation @@ -78,11 +92,22 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { companion object { const val TAG = "MPVActivity" + // Notification channel constants + private const val PLAYER_CHANNEL_ID = "zplex_video_player" + private const val MEDIA_SESSION_ID = "zplex_session" + private const val PLAYER_NOTIFICATION_ID = 100002 + // fraction to which audio volume is ducked on loss of audio focus private const val AUDIO_FOCUS_DUCKING = 0.5f private const val SKIP_DURATION = 10 // in seconds } + // Media session & notification + private lateinit var mediaSession: MediaSessionCompat + private var notificationManager: NotificationManager? = null + private var currentNotification: Notification? = null + private var isSessionActive = false + private lateinit var audioManager: AudioManager private var audioFocusRestore: () -> Unit = {} @@ -254,6 +279,7 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { } playlistObserver() playMedia() + initMediaSession() if (isSamsungWithSPen()) { SpenRemoteHelper.initialize( @@ -507,6 +533,7 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { } else { window.addFlags(FLAG_KEEP_SCREEN_ON) } + updateNotification() } private fun skipForward() { @@ -734,6 +761,214 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { return Build.MANUFACTURER.equals("samsung", ignoreCase = true) && hasSPenFeature } + private fun initMediaSession() { + val mediaButtonReceiver = ComponentName( + applicationContext, + NotificationButtonReceiver::class.java + ) + + mediaSession = MediaSessionCompat(this, MEDIA_SESSION_ID, mediaButtonReceiver, null) + .apply { + setCallback(object : MediaSessionCompat.Callback() { + override fun onPlay() { + player.cyclePause() + updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) + updateNotification() + } + + override fun onPause() { + player.cyclePause() + updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) + updateNotification() + } + + override fun onSeekTo(pos: Long) { + player.timePos = (pos / 1000).toInt() + } + + override fun onSkipToNext() { + saveProgress(viewModel.head) + player.stop() + viewModel.next() + updateNotification() + } + + override fun onSkipToPrevious() { + saveProgress(viewModel.head) + player.stop() + viewModel.previous() + updateNotification() + } + }) + } + + notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel( + PLAYER_CHANNEL_ID, + "Playback", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Playback controls" + } + notificationManager?.createNotificationChannel(channel) + + if (!isSessionActive) { + mediaSession.isActive = true + isSessionActive = true + } + } + + private fun releaseMediaSession() { + if (isSessionActive) { + mediaSession.isActive = false + isSessionActive = false + } + try { + mediaSession.release() + } catch (e: Exception) { + Log.e(TAG, "Failed to release Media Session", e) + } + notificationManager?.cancel(PLAYER_NOTIFICATION_ID) + } + + private fun updatePlaybackState( + state: Int, + customActions: List? = null + ) { + if (!::mediaSession.isInitialized) return + + val positionMs = (player.timePos?.times(1000))?.toLong() ?: 0L + val playbackSpeed = if (player.paused == true) + 0f else (MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f) + + val stateBuilder = PlaybackStateCompat.Builder() + .setActions( + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + ) + .setState(state, positionMs, playbackSpeed) + + customActions?.forEach { stateBuilder.addCustomAction(it) } + + mediaSession.setPlaybackState(stateBuilder.build()) + } + + private fun updateMetadata(metadata: MediaMetadataData) { + if (!::mediaSession.isInitialized) return + + val metadataBuilder = MediaMetadataCompat.Builder() + metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata.title) + metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, metadata.title) + + val subtitle = when (metadata) { + is ShowMetadata -> { + val epString = "S${metadata.seasonNumber} • E${metadata.episodeNumber}" + val epName = metadata.episodeName?.let { " — $it" } ?: "" + epString + epName + } + + is MovieMetadata -> metadata.studio ?: "" + } + + metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, subtitle) + metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle) + + val durationMs = metadata.durationSecs?.times(1000L) ?: ((player.duration ?: 0) * 1000L) + metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs) + + metadata.posterPath?.let { path -> + val posterUrl = "$TMDB_IMAGE_PREFIX/${PosterSize.w342}${path}" + lifecycleScope.launch(Dispatchers.IO) { + try { + val bitmap = Glide.with(this@MPVActivity) + .asBitmap() + .load(posterUrl) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .submit() + .get() + metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) + } catch (e: Exception) { + Log.e(TAG, "Failed to load poster: ${e.message}") + } finally { + mediaSession.setMetadata(metadataBuilder.build()) + } + } + } ?: mediaSession.setMetadata(metadataBuilder.build()) + } + + private fun buildNotification(): Notification { + val controller = mediaSession.controller + val mediaMetadata = controller.metadata + + val title = mediaMetadata?.getString(MediaMetadataCompat.METADATA_KEY_TITLE) + ?: controller.queueTitle?.toString() ?: "Preparing playback..." + val subtitle = + mediaMetadata?.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE) ?: "" + + val playAction = if (player.paused == true) { + NotificationCompat.Action.Builder( + R.drawable.ic_play_24, + "Play", + MediaButtonReceiver.buildMediaButtonPendingIntent( + this, + PlaybackStateCompat.ACTION_PLAY + ) + ).build() + } else { + NotificationCompat.Action.Builder( + R.drawable.ic_pause_24, + "Pause", + MediaButtonReceiver.buildMediaButtonPendingIntent( + this, + PlaybackStateCompat.ACTION_PAUSE + ) + ).build() + } + + val prevAction = NotificationCompat.Action.Builder( + R.drawable.ic_previous_24, + "Previous", + MediaButtonReceiver.buildMediaButtonPendingIntent( + this, + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + ) + ).build() + val nextAction = NotificationCompat.Action.Builder( + R.drawable.ic_next_24, + "Next", + MediaButtonReceiver.buildMediaButtonPendingIntent( + this, + PlaybackStateCompat.ACTION_SKIP_TO_NEXT + ) + ).build() + + val style = androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(mediaSession.sessionToken) + .setShowActionsInCompactView(0, 1, 2) + + val builder = NotificationCompat.Builder(this, PLAYER_CHANNEL_ID) + .setContentTitle(title) + .setContentText(subtitle) + .setSmallIcon(R.drawable.ic_tv_play) + .setStyle(style) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addAction(prevAction) + .addAction(playAction) + .addAction(nextAction) + .setOnlyAlertOnce(true) + + return builder.build() + } + + private fun updateNotification() { + currentNotification = buildNotification() + notificationManager?.notify(PLAYER_NOTIFICATION_ID, currentNotification) + } + private fun onPiPModeChangedImpl(state: Boolean) { Log.v(TAG, "onPiPModeChanged($state)") if (state) { @@ -784,10 +1019,8 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { val params = with(PictureInPictureParams.Builder()) { val aspect = player.getVideoAspect() ?: 0.0 setAspectRatio(Rational(aspect.times(10000).toInt(), 10000)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setAutoEnterEnabled(true) - setSeamlessResizeEnabled(true) - } + setAutoEnterEnabled(true) + setSeamlessResizeEnabled(true) setActions(actions) } try { @@ -820,10 +1053,27 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { if (!activityIsForeground) return runOnUiThread { when (property) { - "time-pos" -> updatePlaybackPos(value.toInt()) - "duration" -> updatePlaybackDuration(value.toInt()) + "time-pos" -> { + updatePlaybackPos(value.toInt()) + val state = + if (player.paused == true) PlaybackStateCompat.STATE_PAUSED + else PlaybackStateCompat.STATE_PLAYING + updatePlaybackState(state) + } + + "duration" -> { + updatePlaybackDuration(value.toInt()) + mediaSession.controller.metadata?.let { currentMetadata -> + val updatedMetadata = MediaMetadataCompat.Builder(currentMetadata) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, value * 1000L) + .build() + mediaSession.setMetadata(updatedMetadata) + } + } + "demuxer-cache-time" -> updateBufferedPos(value.toInt()) } + updateNotification() } } @@ -879,6 +1129,44 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { } if (eventId == MPV_EVENT_FILE_LOADED) { Log.d(TAG, "File has starting playing...") + + viewModel.head?.let { head -> + val metadata: MediaMetadataData? = when (head) { + is Movie -> { + MovieMetadata( + title = head.title, + posterPath = head.posterPath, + durationSecs = player.duration ?: 0, + studio = "No studio" + ) + } + + is Show -> { + ShowMetadata( + title = head.title, + posterPath = head.posterPath, + durationSecs = player.duration ?: 0, + seasonNumber = head.seasonNumber, + episodeNumber = head.episodeNumber, + episodeName = head.episodeTitle + ) + } + + else -> null + } + + metadata?.let { + updateMetadata(it) + updatePlaybackState( + if (player.paused == true) + PlaybackStateCompat.STATE_PAUSED + else + PlaybackStateCompat.STATE_PLAYING + ) + updateNotification() + } + } + } if (eventId == MPV_EVENT_END_FILE) { binding.controller.apply { @@ -960,6 +1248,7 @@ class MPVActivity : AppCompatActivity(), MPVLib.EventObserver { player.removeObserver(this) player.destroy() + releaseMediaSession() super.onDestroy() } diff --git a/app/src/main/java/zechs/zplex/ui/player/MediaMetadataData.kt b/app/src/main/java/zechs/zplex/ui/player/MediaMetadataData.kt new file mode 100644 index 0000000..4d9acf4 --- /dev/null +++ b/app/src/main/java/zechs/zplex/ui/player/MediaMetadataData.kt @@ -0,0 +1,23 @@ +package zechs.zplex.ui.player + +sealed interface MediaMetadataData { + val title: String + val posterPath: String? + val durationSecs: Int? +} + +data class MovieMetadata( + override val title: String, + override val posterPath: String? = null, + override val durationSecs: Int? = null, + val studio: String? = null +) : MediaMetadataData + +data class ShowMetadata( + override val title: String, + override val posterPath: String? = null, + override val durationSecs: Int? = null, + val seasonNumber: Int, + val episodeNumber: Int, + val episodeName: String? = null +) : MediaMetadataData diff --git a/app/src/main/java/zechs/zplex/ui/player/NotificationButtonReceiver.kt b/app/src/main/java/zechs/zplex/ui/player/NotificationButtonReceiver.kt index 4a6c1fc..e1b0e17 100644 --- a/app/src/main/java/zechs/zplex/ui/player/NotificationButtonReceiver.kt +++ b/app/src/main/java/zechs/zplex/ui/player/NotificationButtonReceiver.kt @@ -11,9 +11,8 @@ import zechs.mpv.MPVLib class NotificationButtonReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Log.v(TAG, "NotificationButtonReceiver: ${intent!!.action}") - // remember to update AndroidManifest.xml too when adding here - when (intent.action) { + Log.v(TAG, "NotificationButtonReceiver: ${intent?.action}") + when (intent?.action) { "$PREFIX.PLAY_PAUSE" -> MPVLib.command(arrayOf("cycle", "pause")) "$PREFIX.ACTION_PREV" -> MPVLib.command(arrayOf("playlist-prev")) "$PREFIX.ACTION_NEXT" -> MPVLib.command(arrayOf("playlist-next")) @@ -22,12 +21,16 @@ class NotificationButtonReceiver : BroadcastReceiver() { companion object { fun createIntent(context: Context, action: String): PendingIntent { - val intent = Intent("$PREFIX.$action") - // turn into explicit intent - intent.component = ComponentName(context, NotificationButtonReceiver::class.java) + val intent = Intent("$PREFIX.$action").apply { + component = ComponentName(context, NotificationButtonReceiver::class.java) + } + return PendingIntentCompat.getBroadcast( - context, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT, false + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false )!! } diff --git a/app/src/main/res/drawable/ic_next_24.xml b/app/src/main/res/drawable/ic_next_24.xml index b550b02..755048e 100644 --- a/app/src/main/res/drawable/ic_next_24.xml +++ b/app/src/main/res/drawable/ic_next_24.xml @@ -5,6 +5,6 @@ android:viewportWidth="960" android:viewportHeight="960"> diff --git a/app/src/main/res/drawable/ic_previous_24.xml b/app/src/main/res/drawable/ic_previous_24.xml index 27d04ca..335ba89 100644 --- a/app/src/main/res/drawable/ic_previous_24.xml +++ b/app/src/main/res/drawable/ic_previous_24.xml @@ -5,6 +5,6 @@ android:viewportWidth="960" android:viewportHeight="960"> diff --git a/app/src/main/res/drawable/ic_tv_play.xml b/app/src/main/res/drawable/ic_tv_play.xml new file mode 100644 index 0000000..729ce7b --- /dev/null +++ b/app/src/main/res/drawable/ic_tv_play.xml @@ -0,0 +1,10 @@ + + +