Skip to content

Commit 60a16bd

Browse files
Merge pull request #16603 from nextcloud/fix/exoplayer-usage
fix: exoplayer usage
2 parents f1f2452 + 3796736 commit 60a16bd

6 files changed

Lines changed: 292 additions & 197 deletions

File tree

Lines changed: 170 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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
1213
import android.content.Context
1314
import android.content.Intent
1415
import android.content.IntentFilter
16+
import android.content.pm.ServiceInfo
17+
import android.os.Build
1518
import android.os.Bundle
1619
import androidx.annotation.OptIn
20+
import androidx.core.app.NotificationCompat
21+
import androidx.core.app.ServiceCompat
1722
import androidx.media3.common.Player
1823
import androidx.media3.common.Player.COMMAND_PLAY_PAUSE
1924
import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT
@@ -41,9 +46,15 @@ import com.nextcloud.client.network.ClientFactory
4146
import com.nextcloud.common.NextcloudClient
4247
import com.nextcloud.utils.extensions.registerBroadcastReceiver
4348
import com.owncloud.android.MainApp
49+
import com.owncloud.android.R
4450
import 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
4554
import kotlinx.coroutines.Dispatchers
46-
import kotlinx.coroutines.runBlocking
55+
import kotlinx.coroutines.SupervisorJob
56+
import kotlinx.coroutines.cancel
57+
import kotlinx.coroutines.launch
4758
import kotlinx.coroutines.withContext
4859
import 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

Comments
 (0)