11/*
22 * Nextcloud - Android Client
33 *
4+ * SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
45 * SPDX-FileCopyrightText: 2024 Parneet Singh <gurayaparneet@gmail.com>
56 * SPDX-License-Identifier: AGPL-3.0-or-later
67 */
@@ -12,8 +13,12 @@ import android.content.BroadcastReceiver
1213import android.content.Context
1314import android.content.Intent
1415import android.content.IntentFilter
16+ import android.content.pm.ServiceInfo
17+ import android.os.Build
1518import android.os.Bundle
1619import androidx.annotation.OptIn
20+ import androidx.core.app.NotificationCompat
21+ import androidx.core.app.ServiceCompat
1722import androidx.media3.common.Player
1823import androidx.media3.common.Player.COMMAND_PLAY_PAUSE
1924import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT
@@ -41,9 +46,15 @@ import com.nextcloud.client.network.ClientFactory
4146import com.nextcloud.common.NextcloudClient
4247import com.nextcloud.utils.extensions.registerBroadcastReceiver
4348import com.owncloud.android.MainApp
49+ import com.owncloud.android.R
4450import com.owncloud.android.datamodel.ReceiverFlag
51+ import com.owncloud.android.lib.common.utils.Log_OC
52+ import com.owncloud.android.ui.notifications.NotificationUtils
53+ import kotlinx.coroutines.CoroutineScope
4554import kotlinx.coroutines.Dispatchers
46- import kotlinx.coroutines.runBlocking
55+ import kotlinx.coroutines.SupervisorJob
56+ import kotlinx.coroutines.cancel
57+ import kotlinx.coroutines.launch
4758import kotlinx.coroutines.withContext
4859import javax.inject.Inject
4960
@@ -52,45 +63,86 @@ class BackgroundPlayerService :
5263 MediaSessionService (),
5364 Injectable {
5465
66+ private val serviceScope = CoroutineScope (SupervisorJob () + Dispatchers .Main )
67+
5568 private val seekBackSessionCommand = SessionCommand (SESSION_COMMAND_ACTION_SEEK_BACK , Bundle .EMPTY )
5669 private val seekForwardSessionCommand = SessionCommand (SESSION_COMMAND_ACTION_SEEK_FORWARD , Bundle .EMPTY )
5770
58- val seekForward =
59- CommandButton .Builder ()
60- .setDisplayName(" Seek Forward" )
61- .setIconResId(CommandButton .getIconResIdForIconConstant(CommandButton .ICON_SKIP_FORWARD_15 ))
62- .setSessionCommand(seekForwardSessionCommand)
63- .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 2 ) })
64- .build()
65-
66- val seekBackward =
67- CommandButton .Builder ()
68- .setDisplayName(" Seek Backward" )
69- .setIconResId(CommandButton .getIconResIdForIconConstant(CommandButton .ICON_SKIP_BACK_5 ))
70- .setSessionCommand(seekBackSessionCommand)
71- .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 0 ) })
72- .build()
71+ private lateinit var seekForward: CommandButton
72+ private lateinit var seekBackward: CommandButton
7373
7474 @Inject
7575 lateinit var clientFactory: ClientFactory
7676
7777 @Inject
7878 lateinit var userAccountManager: UserAccountManager
79- lateinit var exoPlayer: ExoPlayer
79+
80+ private lateinit var exoPlayer: ExoPlayer
8081 private var mediaSession: MediaSession ? = null
82+ private var isPlayerReady = false
8183
8284 private val stopReceiver = object : BroadcastReceiver () {
8385 override fun onReceive (context : Context ? , intent : Intent ? ) {
8486 when (intent?.action) {
8587 RELEASE_MEDIA_SESSION_BROADCAST_ACTION -> release()
86- STOP_MEDIA_SESSION_BROADCAST_ACTION -> exoPlayer.stop()
88+
89+ STOP_MEDIA_SESSION_BROADCAST_ACTION -> {
90+ if (isPlayerReady) {
91+ exoPlayer.stop()
92+ } else {
93+ stopSelf()
94+ }
95+ }
8796 }
8897 }
8998 }
9099
100+ override fun onStartCommand (intent : Intent ? , flags : Int , startId : Int ): Int {
101+ val notification = NotificationCompat .Builder (this , NotificationUtils .NOTIFICATION_CHANNEL_MEDIA )
102+ .setSmallIcon(R .drawable.logo)
103+ .setContentTitle(getString(R .string.media_player_playing))
104+ .setSilent(true )
105+ .build()
106+
107+ ServiceCompat .startForeground(
108+ this ,
109+ DefaultMediaNotificationProvider .DEFAULT_NOTIFICATION_ID ,
110+ notification,
111+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .Q ) {
112+ ServiceInfo .FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
113+ } else {
114+ 0
115+ }
116+ )
117+
118+ return super .onStartCommand(intent, flags, startId)
119+ }
120+
121+ @Suppress(" DEPRECATION" )
91122 override fun onCreate () {
92123 super .onCreate()
93124
125+ MainApp .getAppComponent().inject(this )
126+
127+ seekForward = CommandButton .Builder ()
128+ .setDisplayName(getString(R .string.media_player_seek_forward))
129+ .setIconResId(R .drawable.ic_skip_next)
130+ .setSessionCommand(seekForwardSessionCommand)
131+ .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 2 ) })
132+ .build()
133+
134+ seekBackward = CommandButton .Builder ()
135+ .setDisplayName(getString(R .string.media_player_seek_backward))
136+ .setIconResId(R .drawable.ic_skip_previous)
137+ .setSessionCommand(seekBackSessionCommand)
138+ .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 0 ) })
139+ .build()
140+
141+ exoPlayer = ExoPlayer .Builder (this ).build()
142+ mediaSession = buildMediaSession(exoPlayer)
143+
144+ setMediaNotificationProvider(buildNotificationProvider())
145+
94146 registerBroadcastReceiver(
95147 stopReceiver,
96148 IntentFilter ().apply {
@@ -100,100 +152,101 @@ class BackgroundPlayerService :
100152 ReceiverFlag .NotExported
101153 )
102154
103- MainApp .getAppComponent().inject( this )
104- initNextcloudExoPlayer()
155+ initExoPlayer( )
156+ }
105157
106- setMediaNotificationProvider(object : DefaultMediaNotificationProvider (this ) {
107- override fun getMediaButtons (
108- session : MediaSession ,
109- playerCommands : Player .Commands ,
110- customLayout : ImmutableList <CommandButton >,
111- showPauseButton : Boolean
112- ): ImmutableList <CommandButton > {
113- val playPauseButton =
114- CommandButton .Builder ()
115- .setDisplayName(" PlayPause" )
116- .setIconResId(
117- CommandButton .getIconResIdForIconConstant(
118- if (mediaSession?.player?.isPlaying == true ) {
119- CommandButton .ICON_PAUSE
120- } else {
121- CommandButton .ICON_PLAY
122- }
123- )
124- )
125- .setPlayerCommand(COMMAND_PLAY_PAUSE )
126- .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 1 ) })
127- .build()
128-
129- val myCustomButtonsLayout =
130- ImmutableList .of(seekBackward, playPauseButton, seekForward)
131- return myCustomButtonsLayout
158+ @Suppress(" TooGenericExceptionCaught" )
159+ private fun initExoPlayer () {
160+ serviceScope.launch {
161+ try {
162+ val nextcloudClient: NextcloudClient = withContext(Dispatchers .IO ) {
163+ clientFactory.createNextcloudClient(userAccountManager.user)
164+ }
165+
166+ val realPlayer = createNextcloudExoplayer(this @BackgroundPlayerService, nextcloudClient)
167+ exoPlayer.release()
168+ exoPlayer = realPlayer
169+ isPlayerReady = true
170+
171+ // Update the session to use the real player
172+ mediaSession?.player = realPlayer
173+ } catch (e: Exception ) {
174+ Log_OC .e(TAG , " Failed to initialise Nextcloud ExoPlayer: ${e.message} " )
175+ stopSelf()
132176 }
133- })
177+ }
134178 }
135179
136- private fun initNextcloudExoPlayer () {
137- runBlocking {
138- var nextcloudClient: NextcloudClient
139- withContext(Dispatchers .IO ) {
140- nextcloudClient = clientFactory.createNextcloudClient(userAccountManager.user)
180+ private fun buildMediaSession (player : ExoPlayer ): MediaSession = MediaSession .Builder (applicationContext, player)
181+ .setId(BACKGROUND_MEDIA_SESSION_ID )
182+ .setCustomLayout(listOf (seekBackward, seekForward))
183+ .setCallback(object : MediaSession .Callback {
184+ override fun onConnect (session : MediaSession , controller : MediaSession .ControllerInfo ): ConnectionResult =
185+ AcceptedResultBuilder (mediaSession ? : session)
186+ .setAvailablePlayerCommands(
187+ ConnectionResult .DEFAULT_PLAYER_COMMANDS .buildUpon()
188+ .remove(COMMAND_SEEK_TO_NEXT )
189+ .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM )
190+ .remove(COMMAND_SEEK_TO_PREVIOUS )
191+ .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM )
192+ .build()
193+ )
194+ .setAvailableSessionCommands(
195+ ConnectionResult .DEFAULT_SESSION_COMMANDS .buildUpon()
196+ .addSessionCommands(
197+ listOf (seekBackSessionCommand, seekForwardSessionCommand)
198+ ).build()
199+ )
200+ .build()
201+
202+ override fun onPostConnect (session : MediaSession , controller : MediaSession .ControllerInfo ) {
203+ session.setCustomLayout(listOf (seekBackward, seekForward))
141204 }
142- nextcloudClient.let {
143- exoPlayer = createNextcloudExoplayer(this @BackgroundPlayerService, nextcloudClient)
144- mediaSession =
145- MediaSession .Builder (applicationContext, exoPlayer)
146- // set id to distinct this session to avoid crash
147- // in case session release delayed a bit and
148- // we start another session for eg. video
149- .setId(BACKGROUND_MEDIA_SESSION_ID )
150- .setCustomLayout(listOf (seekBackward, seekForward))
151- .setCallback(object : MediaSession .Callback {
152- override fun onConnect (
153- session : MediaSession ,
154- controller : MediaSession .ControllerInfo
155- ): ConnectionResult = AcceptedResultBuilder (mediaSession!! )
156- .setAvailablePlayerCommands(
157- ConnectionResult .DEFAULT_PLAYER_COMMANDS .buildUpon()
158- .remove(COMMAND_SEEK_TO_NEXT )
159- .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM )
160- .remove(COMMAND_SEEK_TO_PREVIOUS )
161- .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM )
162- .build()
163- )
164- .setAvailableSessionCommands(
165- ConnectionResult .DEFAULT_SESSION_COMMANDS .buildUpon()
166- .addSessionCommands(
167- listOf (seekBackSessionCommand, seekForwardSessionCommand)
168- ).build()
169- )
170- .build()
171-
172- override fun onPostConnect (session : MediaSession , controller : MediaSession .ControllerInfo ) {
173- session.setCustomLayout(listOf (seekBackward, seekForward))
174- }
175-
176- override fun onCustomCommand (
177- session : MediaSession ,
178- controller : MediaSession .ControllerInfo ,
179- customCommand : SessionCommand ,
180- args : Bundle
181- ): ListenableFuture <SessionResult > = when (customCommand.customAction) {
182- SESSION_COMMAND_ACTION_SEEK_FORWARD -> {
183- session.player.seekForward()
184- Futures .immediateFuture(SessionResult (SessionResult .RESULT_SUCCESS ))
185- }
186-
187- SESSION_COMMAND_ACTION_SEEK_BACK -> {
188- session.player.seekBack()
189- Futures .immediateFuture(SessionResult (SessionResult .RESULT_SUCCESS ))
190- }
191-
192- else -> super .onCustomCommand(session, controller, customCommand, args)
193- }
194- })
195- .build()
205+
206+ override fun onCustomCommand (
207+ session : MediaSession ,
208+ controller : MediaSession .ControllerInfo ,
209+ customCommand : SessionCommand ,
210+ args : Bundle
211+ ): ListenableFuture <SessionResult > = when (customCommand.customAction) {
212+ SESSION_COMMAND_ACTION_SEEK_FORWARD -> {
213+ session.player.seekForward()
214+ Futures .immediateFuture(SessionResult (SessionResult .RESULT_SUCCESS ))
215+ }
216+
217+ SESSION_COMMAND_ACTION_SEEK_BACK -> {
218+ session.player.seekBack()
219+ Futures .immediateFuture(SessionResult (SessionResult .RESULT_SUCCESS ))
220+ }
221+
222+ else -> super .onCustomCommand(session, controller, customCommand, args)
196223 }
224+ })
225+ .build()
226+
227+ private fun buildNotificationProvider () = object : DefaultMediaNotificationProvider (this ) {
228+ @Suppress(" DEPRECATION" )
229+ override fun getMediaButtons (
230+ session : MediaSession ,
231+ playerCommands : Player .Commands ,
232+ customLayout : ImmutableList <CommandButton >,
233+ showPauseButton : Boolean
234+ ): ImmutableList <CommandButton > {
235+ val isPlaying = mediaSession?.player?.isPlaying == true
236+ val playPauseButton = CommandButton .Builder ()
237+ .setDisplayName(
238+ if (isPlaying) {
239+ getString(R .string.media_player_pause)
240+ } else {
241+ getString(R .string.media_player_play)
242+ }
243+ )
244+ .setIconResId(if (isPlaying) R .drawable.ic_pause else R .drawable.ic_play_arrow)
245+ .setPlayerCommand(COMMAND_PLAY_PAUSE )
246+ .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 1 ) })
247+ .build()
248+
249+ return ImmutableList .of(seekBackward, playPauseButton, seekForward)
197250 }
198251 }
199252
@@ -203,6 +256,7 @@ class BackgroundPlayerService :
203256
204257 override fun onDestroy () {
205258 unregisterReceiver(stopReceiver)
259+ serviceScope.cancel()
206260 mediaSession?.run {
207261 player.release()
208262 release()
@@ -214,28 +268,27 @@ class BackgroundPlayerService :
214268 private fun release () {
215269 val player = mediaSession?.player
216270 if (player?.playWhenReady == true ) {
217- // Make sure the service is not in foreground.
218271 player.pause()
219272 }
220- // Bug in Android 14, https://github.com/androidx/media/issues/805
221- // that sometimes onTaskRemove() doesn't get called immediately
222- // eventually gets called so the service stops but the notification doesn't clear out.
223- // [WORKAROUND] So, explicitly removing the notification here.
224- // TODO revisit after bug solved!
225273 val nm = getSystemService(NOTIFICATION_SERVICE ) as NotificationManager
226274 nm.cancel(DefaultMediaNotificationProvider .DEFAULT_NOTIFICATION_ID )
275+ stopForeground(STOP_FOREGROUND_REMOVE )
227276 stopSelf()
228277 }
229278
230- override fun onGetSession (p0 : MediaSession .ControllerInfo ): MediaSession ? = mediaSession
279+ override fun onGetSession (controllerInfo : MediaSession .ControllerInfo ): MediaSession ? = mediaSession
231280
232281 companion object {
282+ private val TAG = BackgroundPlayerService ::class .java.simpleName
283+
233284 private const val SESSION_COMMAND_ACTION_SEEK_BACK = " SESSION_COMMAND_ACTION_SEEK_BACK"
234285 private const val SESSION_COMMAND_ACTION_SEEK_FORWARD = " SESSION_COMMAND_ACTION_SEEK_FORWARD"
286+ private const val BACKGROUND_MEDIA_SESSION_ID =
287+ " com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID"
235288
236- private const val BACKGROUND_MEDIA_SESSION_ID = " com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID "
237-
238- const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION = " com.nextcloud.client.media.RELEASE_MEDIA_SESSION "
239- const val STOP_MEDIA_SESSION_BROADCAST_ACTION = " com.nextcloud.client.media.STOP_MEDIA_SESSION"
289+ const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION =
290+ " com.nextcloud.client.media.RELEASE_MEDIA_SESSION "
291+ const val STOP_MEDIA_SESSION_BROADCAST_ACTION =
292+ " com.nextcloud.client.media.STOP_MEDIA_SESSION"
240293 }
241294}
0 commit comments