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 */
@@ -41,9 +42,14 @@ import com.nextcloud.client.network.ClientFactory
4142import com.nextcloud.common.NextcloudClient
4243import com.nextcloud.utils.extensions.registerBroadcastReceiver
4344import com.owncloud.android.MainApp
45+ import com.owncloud.android.R
4446import com.owncloud.android.datamodel.ReceiverFlag
47+ import com.owncloud.android.lib.common.utils.Log_OC
48+ import kotlinx.coroutines.CoroutineScope
4549import kotlinx.coroutines.Dispatchers
46- import kotlinx.coroutines.runBlocking
50+ import kotlinx.coroutines.SupervisorJob
51+ import kotlinx.coroutines.cancel
52+ import kotlinx.coroutines.launch
4753import kotlinx.coroutines.withContext
4854import javax.inject.Inject
4955
@@ -52,21 +58,21 @@ class BackgroundPlayerService :
5258 MediaSessionService (),
5359 Injectable {
5460
61+ private val serviceScope = CoroutineScope (SupervisorJob () + Dispatchers .Main )
62+
5563 private val seekBackSessionCommand = SessionCommand (SESSION_COMMAND_ACTION_SEEK_BACK , Bundle .EMPTY )
5664 private val seekForwardSessionCommand = SessionCommand (SESSION_COMMAND_ACTION_SEEK_FORWARD , Bundle .EMPTY )
5765
58- val seekForward =
59- CommandButton .Builder ()
60- .setDisplayName(" Seek Forward" )
61- .setIconResId(CommandButton .getIconResIdForIconConstant(CommandButton .ICON_SKIP_FORWARD_15 ))
66+ private val seekForward =
67+ CommandButton .Builder (CommandButton .getIconResIdForIconConstant(CommandButton .ICON_SKIP_FORWARD_15 ))
68+ .setDisplayName(getString(R .string.media_player_seek_forward))
6269 .setSessionCommand(seekForwardSessionCommand)
6370 .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 2 ) })
6471 .build()
6572
66- val seekBackward =
67- CommandButton .Builder ()
68- .setDisplayName(" Seek Backward" )
69- .setIconResId(CommandButton .getIconResIdForIconConstant(CommandButton .ICON_SKIP_BACK_5 ))
73+ private val seekBackward =
74+ CommandButton .Builder (CommandButton .getIconResIdForIconConstant(CommandButton .ICON_SKIP_BACK_15 ))
75+ .setDisplayName(getString(R .string.media_player_seek_backward))
7076 .setSessionCommand(seekBackSessionCommand)
7177 .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 0 ) })
7278 .build()
@@ -76,21 +82,37 @@ class BackgroundPlayerService :
7682
7783 @Inject
7884 lateinit var userAccountManager: UserAccountManager
79- lateinit var exoPlayer: ExoPlayer
85+
86+ private lateinit var exoPlayer: ExoPlayer
8087 private var mediaSession: MediaSession ? = null
8188
89+ private var isPlayerReady = false
90+
8291 private val stopReceiver = object : BroadcastReceiver () {
8392 override fun onReceive (context : Context ? , intent : Intent ? ) {
8493 when (intent?.action) {
8594 RELEASE_MEDIA_SESSION_BROADCAST_ACTION -> release()
86- STOP_MEDIA_SESSION_BROADCAST_ACTION -> exoPlayer.stop()
95+ STOP_MEDIA_SESSION_BROADCAST_ACTION -> {
96+ if (isPlayerReady) {
97+ exoPlayer.stop()
98+ } else {
99+ stopSelf()
100+ }
101+ }
87102 }
88103 }
89104 }
90105
91106 override fun onCreate () {
92107 super .onCreate()
93108
109+ MainApp .getAppComponent().inject(this )
110+
111+ exoPlayer = ExoPlayer .Builder (this ).build()
112+ mediaSession = buildMediaSession(exoPlayer)
113+
114+ setMediaNotificationProvider(buildNotificationProvider())
115+
94116 registerBroadcastReceiver(
95117 stopReceiver,
96118 IntentFilter ().apply {
@@ -100,109 +122,121 @@ class BackgroundPlayerService :
100122 ReceiverFlag .NotExported
101123 )
102124
103- MainApp .getAppComponent().inject(this )
104- initNextcloudExoPlayer()
105-
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
132- }
133- })
125+ initExoPlayer()
134126 }
135127
136- private fun initNextcloudExoPlayer () {
137- runBlocking {
138- var nextcloudClient: NextcloudClient
139- withContext(Dispatchers .IO ) {
140- nextcloudClient = clientFactory.createNextcloudClient(userAccountManager.user)
141- }
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()
128+ private fun initExoPlayer () {
129+ serviceScope.launch {
130+ try {
131+ val nextcloudClient: NextcloudClient = withContext(Dispatchers .IO ) {
132+ clientFactory.createNextcloudClient(userAccountManager.user)
133+ }
134+
135+ val realPlayer = createNextcloudExoplayer(this @BackgroundPlayerService, nextcloudClient)
136+
137+ exoPlayer.release()
138+ exoPlayer = realPlayer
139+ isPlayerReady = true
140+
141+ // Update the session to use the real player
142+ mediaSession?.player = realPlayer
143+ } catch (e: Exception ) {
144+ Log_OC .e(TAG , " Failed to initialise Nextcloud ExoPlayer: ${e.message} " )
145+ stopSelf()
196146 }
197147 }
198148 }
199149
150+ private fun buildMediaSession (player : ExoPlayer ): MediaSession =
151+ MediaSession .Builder (applicationContext, player)
152+ .setId(BACKGROUND_MEDIA_SESSION_ID )
153+ .setCustomLayout(listOf (seekBackward, seekForward))
154+ .setCallback(object : MediaSession .Callback {
155+ override fun onConnect (
156+ session : MediaSession ,
157+ controller : MediaSession .ControllerInfo
158+ ): ConnectionResult = AcceptedResultBuilder (mediaSession ? : session)
159+ .setAvailablePlayerCommands(
160+ ConnectionResult .DEFAULT_PLAYER_COMMANDS .buildUpon()
161+ .remove(COMMAND_SEEK_TO_NEXT )
162+ .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM )
163+ .remove(COMMAND_SEEK_TO_PREVIOUS )
164+ .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM )
165+ .build()
166+ )
167+ .setAvailableSessionCommands(
168+ ConnectionResult .DEFAULT_SESSION_COMMANDS .buildUpon()
169+ .addSessionCommands(
170+ listOf (seekBackSessionCommand, seekForwardSessionCommand)
171+ ).build()
172+ )
173+ .build()
174+
175+ override fun onPostConnect (
176+ session : MediaSession ,
177+ controller : MediaSession .ControllerInfo
178+ ) {
179+ session.setCustomLayout(listOf (seekBackward, seekForward))
180+ }
181+
182+ override fun onCustomCommand (
183+ session : MediaSession ,
184+ controller : MediaSession .ControllerInfo ,
185+ customCommand : SessionCommand ,
186+ args : Bundle
187+ ): ListenableFuture <SessionResult > = when (customCommand.customAction) {
188+ SESSION_COMMAND_ACTION_SEEK_FORWARD -> {
189+ session.player.seekForward()
190+ Futures .immediateFuture(SessionResult (SessionResult .RESULT_SUCCESS ))
191+ }
192+
193+ SESSION_COMMAND_ACTION_SEEK_BACK -> {
194+ session.player.seekBack()
195+ Futures .immediateFuture(SessionResult (SessionResult .RESULT_SUCCESS ))
196+ }
197+
198+ else -> super .onCustomCommand(session, controller, customCommand, args)
199+ }
200+ })
201+ .build()
202+
203+ private fun buildNotificationProvider () = object : DefaultMediaNotificationProvider (this ) {
204+ val icon = if (mediaSession?.player?.isPlaying == true ) {
205+ CommandButton .ICON_PAUSE
206+ } else {
207+ CommandButton .ICON_PLAY
208+ }
209+
210+ val displayName = if (mediaSession?.player?.isPlaying == true ) {
211+ getString(R .string.media_player_pause)
212+ } else {
213+ getString(R .string.media_player_play)
214+ }
215+
216+ override fun getMediaButtons (
217+ session : MediaSession ,
218+ playerCommands : Player .Commands ,
219+ customLayout : ImmutableList <CommandButton >,
220+ showPauseButton : Boolean
221+ ): ImmutableList <CommandButton > {
222+ val playPauseButton =
223+ CommandButton .Builder (CommandButton .getIconResIdForIconConstant(icon))
224+ .setDisplayName(displayName)
225+ .setPlayerCommand(COMMAND_PLAY_PAUSE )
226+ .setExtras(Bundle ().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX , 1 ) })
227+ .build()
228+
229+ return ImmutableList .of(seekBackward, playPauseButton, seekForward)
230+ }
231+ }
232+
200233 override fun onTaskRemoved (rootIntent : Intent ? ) {
201234 release()
202235 }
203236
204237 override fun onDestroy () {
205238 unregisterReceiver(stopReceiver)
239+ serviceScope.cancel()
206240 mediaSession?.run {
207241 player.release()
208242 release()
@@ -214,28 +248,26 @@ class BackgroundPlayerService :
214248 private fun release () {
215249 val player = mediaSession?.player
216250 if (player?.playWhenReady == true ) {
217- // Make sure the service is not in foreground.
218251 player.pause()
219252 }
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!
225253 val nm = getSystemService(NOTIFICATION_SERVICE ) as NotificationManager
226254 nm.cancel(DefaultMediaNotificationProvider .DEFAULT_NOTIFICATION_ID )
227255 stopSelf()
228256 }
229257
230- override fun onGetSession (p0 : MediaSession .ControllerInfo ): MediaSession ? = mediaSession
258+ override fun onGetSession (controllerInfo : MediaSession .ControllerInfo ): MediaSession ? = mediaSession
231259
232260 companion object {
261+ private val TAG = BackgroundPlayerService ::class .java.simpleName
262+
233263 private const val SESSION_COMMAND_ACTION_SEEK_BACK = " SESSION_COMMAND_ACTION_SEEK_BACK"
234264 private const val SESSION_COMMAND_ACTION_SEEK_FORWARD = " SESSION_COMMAND_ACTION_SEEK_FORWARD"
265+ private const val BACKGROUND_MEDIA_SESSION_ID =
266+ " com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID"
235267
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"
268+ const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION =
269+ " com.nextcloud.client.media.RELEASE_MEDIA_SESSION "
270+ const val STOP_MEDIA_SESSION_BROADCAST_ACTION =
271+ " com.nextcloud.client.media.STOP_MEDIA_SESSION"
240272 }
241273}
0 commit comments