-
MediaSessionService - Foreground 음악 재생Android 2024. 5. 23. 23:47
오픈소스인 compose-sample의 Jetcaster는 Android, Wear, TV 등의 다중 플랫폼에 Compose로 음악 플레이어 UI를 구현하는 방법을 제공하고 있는 프로젝트입니다. 현재는 음악 플레이어에 대한 UI만 구현되어 있고, 기능이 구현되어 있지 않아서 이를 구현하여 기여하고 싶었습니다. 이후 여러 플랫폼에서 플레이어를 제어할 수 있는 MediaSession을 구현하기로 결정했습니다.
구조
기능 구현에 앞서, Jetcaster는 다중 플랫폼을 지원하기에 프로젝트가 Multi-module 환경으로 되어있습니다.
이로 인해 먼저 아래와 같은 구조로 설계를 진행하였습니다.
Media Module
백그라운드 재생을 설정하려면 별도의 Service 내에 Player와 MediaSession을 포함해야하기에 아래와 같이 ExoPlayer와 MediaSession을 생성하여 MediaPlayerService에 주입합니다.
// DI.kt @Singleton @Provides fun providesExoPlayer( @ApplicationContext context: Context, ): ExoPlayer = ExoPlayer.Builder(context) .setAudioAttributes(AudioAttributes.DEFAULT, true) .build() @Singleton @Provides fun providesMediaSession( @ApplicationContext context: Context, player: ExoPlayer, ): MediaSession = MediaSession.Builder(context, player) .setCallback(CustomMediaSessionCallback()) .build() // MediaPlayerService.kt @OptIn(UnstableApi::class) @AndroidEntryPoint class MediaPlayerService : MediaSessionService() { @Inject lateinit var mediaSession: MediaSession }
그다음, ForegroundService가 실행 중임을 알리는 Notification에 추가할 사용자 이벤트를 정의하겠습니다.
enum class NotificationCommandButtons { PREVIOUS, REWIND, PLAY_AND_PAUSE, FORWARD, NEXT, FAVORITE; }
주의할 점으로 Android 시스템은 Player의 상태에 따라 최대 5개의 이벤트를 표시할 수 있으며, Compact Mode에서는 순서대로 등록된 3개의 이벤트만 표시됩니다. (자세한 정보)
또한, Media3는 Android 13 이상과 12 이하 버전에 따라 각각 대응해야 하므로 각각 구현을 다르게 해야 합니다.
Android 13 이상
SessionCommand를 추가할 때에는 버튼의 최대 개수 제한에 따라 순서대로 Notification에 이벤트를 보여주기에 이에 맞게 등록을 해줘야 합니다.
internal class CustomMediaSessionCallback : MediaSession.Callback { override fun onConnect( session: MediaSession, controller: MediaSession.ControllerInfo ): MediaSession.ConnectionResult { // 1. 기본으로 PLAY/PAUSE와 PREVIOUS가 적용되어 있기에, // REWIND → FORWARD → FAVROITE 순으로 Notification에 이벤트를 등록합니다. val sessionCommandBuilder = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() NotificationCommandButtons.entries.forEach { commandButton -> sessionCommandBuilder.add(commandButton) } // 2. 해당하는 이벤트의 버튼을 생성하여 CustomLayout에 등록을 해줍니다. val commandButtons = NotificationCommandButtons.entries.map { CommandButton.Builder() .setDisplayName(DisPlayName) .setIconResId(Icon) .setSessionCommand(SessionCommand) .build() } return MediaSession.ConnectionResult.AcceptedResultBuilder(session) .setAvailableSessionCommands(sessionCommandBuilder.build()) .setCustomLayout(commandButtons) .build() } override fun onCustomCommand( session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle ): ListenableFuture<SessionResult> { // 3. 추가로 등록한 이벤트에 대한 처리 when (customCommand.customAction) { NotificationCommandButtons.REWIND.customAction -> // Work.. NotificationCommandButtons.FORWARD.customAction -> // Work.. NotificationCommandButtons.FAVORITE.customAction -> // Work.. else -> // TODO() } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } }
이후, 처음 MediaSession을 생성하는 곳에 Callback 이벤트를 등록해 줍니다.
@Singleton @Provides fun providesMediaSession( @ApplicationContext context: Context, player: ExoPlayer, ): MediaSession = MediaSession.Builder(context, player) .setCallback(CustomMediaSessionCallback()) .build()
Android 12 이하
Android 12 버전 이하에서도 기존 SessionCommand를 활용하여 이벤트를 처리하도록 하겠습니다.
(12 이하에서는 이벤트들이 모두 필요하므로 등록해 주고, 앞서 말한 최대 버튼이 5개로 제한되기에 마지막 FAVORITE은 생략됩니다.)
상단 : Collapsed, 하단 : Expanded @AndroidEntryPoint class MediaPlayerService : MediaSessionService() { @Inject lateinit var mediaSession: MediaSession override fun onCreate() { super.onCreate() setMediaNotificationProvider(object : MediaNotification.Provider { override fun createNotification( mediaSession: MediaSession, customLayout: ImmutableList<CommandButton>, actionFactory: MediaNotification.ActionFactory, onNotificationChangedCallback: MediaNotification.Provider.Callback ): MediaNotification = createMediaNotification(actionFactory) // ... }) } private fun createMediaNotification( actionFactory: MediaNotification.ActionFactory ): MediaNotification { val builder = NotificationCompat.Builder( context, NOTIFICATIO_CAHNNEL_ID, ).apply { // ... setStyle( MediaStyleNotificationHelper.MediaStyle(mediaSession) .setShowActionsInCompactView(0, 2, 4) // 이벤트 추가 순서 ) // 1. addAction을 통해 이벤트 추가 NotificationCommandButtons.entries.forEach { commandButton -> addAction( actionFactory.createCustomAction( mediaSession, icon, title, action, extras ) ) } } return MediaNotification(..., builder.build()) } }
이후에 `onCustomCommand()`에 추가된 이벤트들을 등록해 줍니다.
override fun onCustomCommand( session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle ): ListenableFuture<SessionResult> { when (customCommand.customAction) { NotificationCommandButtons.REWIND.customAction -> // Work.. NotificationCommandButtons.FORWARD.customAction -> // Work.. NotificationCommandButtons.PREVIOUS.customAction -> // work.. // 추가된 이벤트 NotificationCommandButtons.PLAY_AND_PAUSE.customAction -> // Work.. NotificationCommandButtons.NEXT.customAction -> // work.. NotificationCommandButtons.FAVORITE.customAction -> // Todo else -> // TODO() } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) }
간단하게 정리하면, `onCustomCommand()`를 통해 아래와 같은 이벤트를 등록 후 처리한다고 보면 됩니다.
이후, 위와 같이 정의된 MediaSession와 Notification이 정의된 MediaPlayerService를 통해 MediaController를 외부 모듈에 전달합니다.
@Singleton @Provides fun providesSessionToken( @ApplicationContext context: Context ): SessionToken = SessionToken(context, ComponentName(context, MediaPlayerService::class.java)) @Singleton @Provides fun providesListenableFutureMediaController( @ApplicationContext context: Context, sessionToken: SessionToken ): ListenableFuture<MediaController> = MediaController .Builder(context, sessionToken) .buildAsync() // 외부 모듈에 전달 callbackFlow { Futures.addCallback( mediaControllerFuture, object : FutureCallback<MediaController> { override fun onSuccess(result: MediaController) { trySend(result) } override fun onFailure(t: Throwable) { cancel(CancellationException(t.message)) } }, MoreExecutors.directExecutor() ) awaitClose { } }
Domain Module
UseCase에서는 특별한 로직이 아닌 UI에서 필요한 음악 플레이어의 데이터를 가공하는 단계라고 생각하고 정의하였다.
현재 UseCase는 2가지가 있는데 다음과 같은 목적으로 설계하였다.
- MediaPlayerUseCase : 여러 기기(플랫폼)에서 생기는 다양한 이벤트(새로운 MediaItem 추가, 플레이어의 Pause-Resume, Seekbar의 위치 변경 등)를 MediaSession과 상호작용 하는 역할
- MediaPlayerListenerUseCase : 현재 플레이어의 이벤트 리스너를 등록하여 로딩, 재생, Seekbar 이동, 현재 음악의 재생 시간 등의 데이터를 UI 데이터로 치환하는 역할
해당 UseCase들은 데이터 가공의 목적이기에 코드는 생략하고 다이어그램으로 대체하겠습니다.
MediaPlayerUseCase
MediaPlayerListenerUseCase
전체 코드 및 구현
Android 13 이상 Android 12 이하 compose-samples/Jetcaster at jetcaster/podcast-player · yongsuk44/compose-samples
Contribute to yongsuk44/compose-samples development by creating an account on GitHub.
github.com
결론
점진적으로 Android -> Wear -> TV 플랫폼에 기능 구현을 하려 했으나, 다음과 같은 답을 받아 PR은 Close 되었다.
[Jetcaster] Add MediaPlayer to Mobile Module Screen by yongsuk44 · Pull Request #1395 · android/compose-samples
I have implemented basic playback functionality through MediaSession and MediaController, although there are still features that need to be implemented. Screen_recording_2024050...
github.com
Compose-samples의 주 목표는 다양한 폼 팩터에서 Compose로 UI를 구축하는 방법을 보여주는 것입니다. 따라서 UI와 관련이 없는 복잡성을 추가하면 그 목표에서 벗어날 수 있으며, 실제 미디어 재생을 추가하면 유지 관리 비용이 발생하기 때문에 감당할 수 있을지 모르겠습니다.
1주 동안 고민하고 음악 플레이어를 구현하였지만, 프로젝트 목표에 맞지 않기에 아쉬운 마음으로 보내주고.. 나중을 위해서 글이라도 남긴다..
'Android' 카테고리의 다른 글
Compose와 Hilt 의존성 주입 (0) 2024.05.08 Compose - Strong skipping mode (0) 2024.04.26 Android Compose - Composable Component API Guideline (1) 2024.02.29 Android Compose - API GuideLine (0) 2024.02.28 Android Standby Bucket (0) 2022.07.01