Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a16def8
Add aspect-fill, dimensions events, and lockscreen controls fixes
danielwilliamson Mar 5, 2026
4c53506
Fix lockscreen seek and metadata parity on iOS/Android
danielwilliamson Mar 6, 2026
d70afea
Disable previous/restart transport seek for non-premium
danielwilliamson Mar 6, 2026
dcafcde
Set Android seek back/forward increment to 15 seconds
danielwilliamson Mar 6, 2026
34e9e03
FLTR-19614: Add video frame analysis toggle
danielwilliamson Mar 17, 2026
f43964d
FLTR-19614: Harden iOS video event teardown
danielwilliamson Mar 18, 2026
bb77fff
FLTR-19615: Emit Android video dimensions more reliably
danielwilliamson Mar 19, 2026
c210261
FLTR-19589: Handle controller event channel teardown races
danielwilliamson Mar 25, 2026
aa6e940
FLTR-19589: Add video playlist track navigation controls for Android …
danielwilliamson Mar 26, 2026
eb56283
FLTR-19796: Fix mixed video/audio playlist media center showing Not P…
danielwilliamson Apr 7, 2026
9fd31d5
Add feature to disable video and background play
vishnunew Apr 9, 2026
a5db2e3
Fix android crash
vishnunew Apr 9, 2026
6e7a40b
Fix notification for Android
vishnunew Apr 9, 2026
ce91489
One more fix for Android
vishnunew Apr 9, 2026
c4bab0b
One more fix
vishnunew Apr 9, 2026
39b4be0
Fixed Android Notification issue
vishnunew Apr 10, 2026
707b39a
Fixed the issue
vishnunew Apr 10, 2026
5774983
Update for iOS
vishnunew Apr 10, 2026
73a7f8a
Implement handling the audio
vishnunew Apr 16, 2026
2e7b41a
Gap when we switch the background to foreground
vishnunew Apr 16, 2026
bea3344
Fix the player when switching
vishnunew Apr 16, 2026
4457d67
Revert the changes for iOS
vishnunew Apr 16, 2026
14f8651
Add native layout snapshot overlay for smooth iOS rotation transitions
b-ray Apr 16, 2026
18a2ce0
Switch to reparenting approach with RotationReparentContainer
b-ray Apr 16, 2026
ee63f45
Clean up rotation container on dispose to prevent black screen
b-ray Apr 16, 2026
6cf1d96
Fix Audio Focus and crash issue
vishnunew Apr 17, 2026
80962f1
Fix the issues with code
vishnunew Apr 17, 2026
454ac58
Fixed: ios-video-player-end-of-media-loop-resume
johndavid92 Apr 17, 2026
146edc8
Merge branch 'stefan_native-layout-rotation-snapshot' into vishal_fix…
johndavid92 Apr 17, 2026
b00cde1
Merge pull request #3 from Insight-Timer/stefan_native-layout-rotatio…
b-ray Apr 17, 2026
68178e4
Merge pull request #4 from Insight-Timer/vishal_fix-ios-video-player-…
vishnunew Apr 17, 2026
ee80e1b
Merge remote-tracking branch 'origin/gm_video_player_changes' into Ad…
vishnunew Apr 17, 2026
62e1802
Merge pull request #2 from Insight-Timer/Add-feature-to-disable-video…
b-ray Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,94 +1,202 @@
package com.huddlecommunity.better_native_video_player

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture

/**
* MediaSessionService for native video player
* Provides automatic media notification controls with play/pause buttons
* Based on: https://developer.android.com/media/implement/surfaces/mobile
* Foreground MediaSessionService for background audio playback.
*
* IMPORTANT: MediaSessionService automatically creates and manages the notification
* when there's an active MediaSession and the system calls onGetSession()
* The notification appears automatically when media is playing
* The ExoPlayer and MediaSession are created externally in
* [com.huddlecommunity.better_native_video_player.handlers.VideoPlayerNotificationHandler]
* and registered with this service via [setActiveSession] before a handler starts
* foreground playback.
*
* Only one active session is tracked at a time. When a second handler requests
* foreground playback, its session replaces the previous one (single-owner model).
* The system notification is built by Media3's [DefaultMediaNotificationProvider];
* it runs through [startForeground] and is therefore exempt from the
* POST_NOTIFICATIONS runtime permission on Android 13+.
*/
@androidx.annotation.OptIn(UnstableApi::class)
class VideoPlayerMediaSessionService : MediaSessionService() {

companion object {
private const val TAG = "VideoPlayerMSS"
private const val NOTIFICATION_ID = 1001
internal const val CHANNEL_ID = "video_player_channel"
private const val LEGACY_CHANNEL_ID = "video_player"

private var activeSession: MediaSession? = null

// The MediaSession is stored here so it can be accessed by the service
private var mediaSession: MediaSession? = null
fun getActiveSession(): MediaSession? = activeSession

/**
* Gets the current media session
* Registers the session that should drive the foreground notification.
* If another handler's session is already active, it is replaced.
*/
fun getMediaSession(): MediaSession? = mediaSession
fun setActiveSession(session: MediaSession?) {
activeSession = session
}

/**
* Sets the media session (called by VideoPlayerNotificationHandler)
* This must be called before starting the service
* Clears the active session only if it still matches [session]. A handler
* must call this on release so a later handler's session is not wiped.
*/
fun setMediaSession(session: MediaSession?) {
Log.d(TAG, "MediaSession ${if (session != null) "set" else "cleared"}, hasPlayer=${session?.player != null}")
mediaSession = session
fun clearActiveSessionIfMatches(session: MediaSession) {
if (activeSession === session) {
activeSession = null
}
}
}

private var registeredSession: MediaSession? = null
private var isForegroundStarted = false

// ── lifecycle ────────────────────────────────────────────────────────────

override fun onCreate() {
super.onCreate()
Log.d(TAG, "VideoPlayerMediaSessionService onCreate, mediaSession=${mediaSession != null}")

val provider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(CHANNEL_ID)
.setNotificationId(NOTIFICATION_ID)
.build()
provider.setSmallIcon(resolveNotificationIcon())
setMediaNotificationProvider(provider)

setListener(ServiceListener())
}

override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return activeSession
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand called, mediaSession=${mediaSession != null}, player=${mediaSession?.player != null}")
// Satisfy Android's 5s startForeground() deadline with a placeholder;
// Media3 will replace it via onUpdateNotification moments later.
startForegroundIfNeeded()

// Important: Call super to trigger the Media3 notification framework
val result = super.onStartCommand(intent, flags, startId)
val session = activeSession
if (session == null) {
// Nothing to play — let the service die rather than sit foregrounded.
stopSelf()
return super.onStartCommand(intent, flags, startId)
}

// Log player state for debugging
mediaSession?.player?.let { player ->
Log.d(TAG, "Player state: playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}, mediaItemCount=${player.mediaItemCount}")
// addSession can throw IllegalArgumentException if the same session is added twice
// on some Media3 versions, so swap registered sessions only on change.
if (session !== registeredSession) {
registeredSession?.let { prev ->
runCatching { removeSession(prev) }
.onFailure { Log.w(TAG, "removeSession failed: ${it.message}") }
}
runCatching { addSession(session) }
.onFailure { Log.w(TAG, "addSession failed: ${it.message}") }
registeredSession = session
}

return result
return super.onStartCommand(intent, flags, startId)
}

override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
Log.d(TAG, "onGetSession called for ${controllerInfo.packageName}, returning session=${mediaSession != null}")

// Return the MediaSession - this triggers the notification to appear
return mediaSession
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
super.onUpdateNotification(session, startInForegroundRequired)
}

override fun onTaskRemoved(rootIntent: Intent?) {
Log.d(TAG, "Task removed")
val session = mediaSession
if (session != null) {
if (!session.player.playWhenReady || session.player.mediaItemCount == 0) {
// Stop the service if not playing
Log.d(TAG, "Stopping service - not playing")
stopSelf()
}
} else {
val session = activeSession
// Stop only when there is no session or no media loaded. Keep the service
// alive while media is loaded — even when paused — so the user can
// resume from the notification after swiping the task away.
if (session == null || session.player.mediaItemCount == 0) {
stopSelf()
}
}

override fun onDestroy() {
Log.d(TAG, "VideoPlayerMediaSessionService onDestroy")
// Don't release the player or session here - they're managed by the notification handler
isForegroundStarted = false
registeredSession = null
clearListener()
super.onDestroy()
}
}

// ── foreground promotion ────────────────────────────────────────────────

private fun startForegroundIfNeeded() {
if (isForegroundStarted) return

val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?: Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setPackage(packageName)
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE)
val notificationManagerCompat = NotificationManagerCompat.from(this)
ensureNotificationChannel(notificationManagerCompat)

val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(resolveNotificationIcon())
.setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setSound(null)
.setVibrate(longArrayOf(0L))
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(NOTIFICATION_ID, notification)
}
isForegroundStarted = true
}

// ── notification channel ────────────────────────────────────────────────

private fun ensureNotificationChannel(nmc: NotificationManagerCompat) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return

if (nmc.getNotificationChannel(LEGACY_CHANNEL_ID) != null) {
nmc.deleteNotificationChannel(LEGACY_CHANNEL_ID)
}
if (nmc.getNotificationChannel(CHANNEL_ID) != null) return

val channel = NotificationChannel(
CHANNEL_ID,
"Video Playback",
NotificationManager.IMPORTANCE_LOW
).apply {
setSound(null, null)
enableVibration(false)
vibrationPattern = longArrayOf(0L)
setShowBadge(false)
}
nmc.createNotificationChannel(channel)
}

// ── icon helper ─────────────────────────────────────────────────────────

private fun resolveNotificationIcon(): Int {
val resId = resources.getIdentifier("ic_notification", "drawable", packageName)
return if (resId != 0) resId else android.R.drawable.ic_media_play
}

// ── Media3 listener for Android 12+ background-start restriction ────────

private inner class ServiceListener : Listener {
override fun onForegroundServiceStartNotAllowedException() {
Log.w(TAG, "Foreground service start not allowed (Android 12+ restriction)")
}
}

}
Loading