Skip to content

Commit e6a37d2

Browse files
committed
feat: add background media playback support
Implement MediaPlaybackService and integrate MediaSessionManager to enable background audio playback for web content. Includes necessary foreground service permissions and custom WebView visibility handling.
1 parent 880c2b6 commit e6a37d2

14 files changed

Lines changed: 1165 additions & 189 deletions

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ dependencies {
101101
implementation(libs.androidx.room.ktx)
102102
implementation(libs.androidx.room.runtime)
103103
implementation(libs.androidx.webkit)
104+
implementation(libs.androidx.media)
104105
implementation(libs.kotlinx.coroutines.android)
105106
implementation(libs.kotlinx.coroutines.core)
106107

app/src/main/AndroidManifest.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
<uses-permission android:name="android.permission.INTERNET" />
66
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
8+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
9+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
710

811
<application
912
android:allowBackup="true"
@@ -33,6 +36,10 @@
3336
<data android:scheme="https" />
3437
</intent-filter>
3538
</activity>
39+
<service
40+
android:name="com.example.media.MediaPlaybackService"
41+
android:exported="false"
42+
android:foregroundServiceType="mediaPlayback" />
3643
</application>
3744

3845
</manifest>
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package com.example.media
2+
3+
import android.app.*
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.pm.ServiceInfo
7+
import android.os.Build
8+
import android.os.IBinder
9+
import android.support.v4.media.MediaMetadataCompat
10+
import android.support.v4.media.session.MediaSessionCompat
11+
import android.support.v4.media.session.PlaybackStateCompat
12+
import androidx.core.app.NotificationCompat
13+
import androidx.core.app.ServiceCompat
14+
import androidx.media.app.NotificationCompat.MediaStyle
15+
import com.example.MainActivity
16+
import com.example.R
17+
18+
class MediaPlaybackService : Service() {
19+
20+
companion object {
21+
const val CHANNEL_ID = "browser_media_channel"
22+
const val NOTIFICATION_ID = 2001
23+
24+
const val ACTION_UPDATE_STATE = "com.example.media.UPDATE_STATE"
25+
const val ACTION_PLAY = "com.example.media.PLAY"
26+
const val ACTION_PAUSE = "com.example.media.PAUSE"
27+
const val ACTION_TOGGLE = "com.example.media.TOGGLE"
28+
const val ACTION_NEXT = "com.example.media.NEXT"
29+
const val ACTION_PREV = "com.example.media.PREV"
30+
const val ACTION_STOP = "com.example.media.STOP"
31+
}
32+
33+
private var mediaSession: MediaSessionCompat? = null
34+
35+
override fun onCreate() {
36+
super.onCreate()
37+
createNotificationChannel()
38+
39+
mediaSession = MediaSessionCompat(this, "NeonMediaSession").apply {
40+
setFlags(
41+
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
42+
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
43+
)
44+
45+
setCallback(object : MediaSessionCompat.Callback() {
46+
override fun onPlay() {
47+
MediaSessionManager.dispatchAction(MediaControlAction.PLAY)
48+
}
49+
50+
override fun onPause() {
51+
MediaSessionManager.dispatchAction(MediaControlAction.PAUSE)
52+
}
53+
54+
override fun onSkipToNext() {
55+
MediaSessionManager.dispatchAction(MediaControlAction.NEXT)
56+
}
57+
58+
override fun onSkipToPrevious() {
59+
MediaSessionManager.dispatchAction(MediaControlAction.PREVIOUS)
60+
}
61+
62+
override fun onStop() {
63+
MediaSessionManager.stopPlayback(this@MediaPlaybackService)
64+
stopForeground(STOP_FOREGROUND_REMOVE)
65+
stopSelf()
66+
}
67+
})
68+
69+
isActive = true
70+
}
71+
}
72+
73+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
74+
val action = intent?.action ?: ACTION_UPDATE_STATE
75+
76+
when (action) {
77+
ACTION_PLAY -> MediaSessionManager.dispatchAction(MediaControlAction.PLAY)
78+
ACTION_PAUSE -> MediaSessionManager.dispatchAction(MediaControlAction.PAUSE)
79+
ACTION_TOGGLE -> MediaSessionManager.dispatchAction(MediaControlAction.TOGGLE_PLAY_PAUSE)
80+
ACTION_NEXT -> MediaSessionManager.dispatchAction(MediaControlAction.NEXT)
81+
ACTION_PREV -> MediaSessionManager.dispatchAction(MediaControlAction.PREVIOUS)
82+
ACTION_STOP -> {
83+
mediaSession?.isActive = false
84+
stopForeground(STOP_FOREGROUND_REMOVE)
85+
stopSelf()
86+
return START_NOT_STICKY
87+
}
88+
ACTION_UPDATE_STATE -> {
89+
updateNotificationAndSession()
90+
}
91+
}
92+
93+
updateNotificationAndSession()
94+
return START_NOT_STICKY
95+
}
96+
97+
private fun updateNotificationAndSession() {
98+
val metadata = MediaSessionManager.currentMetadata.value
99+
val isPlaying = MediaSessionManager.isPlaying.value
100+
101+
if (metadata == null) {
102+
stopForeground(STOP_FOREGROUND_REMOVE)
103+
stopSelf()
104+
return
105+
}
106+
107+
// Update MediaSession state
108+
val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
109+
val playbackState = PlaybackStateCompat.Builder()
110+
.setActions(
111+
PlaybackStateCompat.ACTION_PLAY or
112+
PlaybackStateCompat.ACTION_PAUSE or
113+
PlaybackStateCompat.ACTION_PLAY_PAUSE or
114+
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
115+
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
116+
PlaybackStateCompat.ACTION_STOP
117+
)
118+
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f)
119+
.build()
120+
mediaSession?.setPlaybackState(playbackState)
121+
122+
val metaBuilder = MediaMetadataCompat.Builder()
123+
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata.title)
124+
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, metadata.artist)
125+
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, metadata.album.ifBlank { "Neon Browser" })
126+
mediaSession?.setMetadata(metaBuilder.build())
127+
128+
// Build Media Notification
129+
val openAppIntent = Intent(this, MainActivity::class.java).apply {
130+
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
131+
}
132+
val contentPendingIntent = PendingIntent.getActivity(
133+
this,
134+
0,
135+
openAppIntent,
136+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
137+
)
138+
139+
val prevIntent = PendingIntent.getService(
140+
this,
141+
1,
142+
Intent(this, MediaPlaybackService::class.java).apply { action = ACTION_PREV },
143+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
144+
)
145+
146+
val playPauseIntent = PendingIntent.getService(
147+
this,
148+
2,
149+
Intent(this, MediaPlaybackService::class.java).apply { action = ACTION_TOGGLE },
150+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
151+
)
152+
153+
val nextIntent = PendingIntent.getService(
154+
this,
155+
3,
156+
Intent(this, MediaPlaybackService::class.java).apply { action = ACTION_NEXT },
157+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
158+
)
159+
160+
val stopIntent = PendingIntent.getService(
161+
this,
162+
4,
163+
Intent(this, MediaPlaybackService::class.java).apply { action = ACTION_STOP },
164+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
165+
)
166+
167+
val playPauseIcon = if (isPlaying) R.drawable.ic_media_pause else R.drawable.ic_media_play
168+
val playPauseTitle = if (isPlaying) "Pause" else "Play"
169+
170+
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
171+
.setSmallIcon(R.drawable.ic_media_notification)
172+
.setContentTitle(metadata.title)
173+
.setContentText(metadata.artist)
174+
.setSubText("Neon Browser")
175+
.setContentIntent(contentPendingIntent)
176+
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
177+
.setPriority(NotificationCompat.PRIORITY_LOW)
178+
.setOnlyAlertOnce(true)
179+
.setOngoing(isPlaying)
180+
.addAction(R.drawable.ic_media_prev, "Previous", prevIntent)
181+
.addAction(playPauseIcon, playPauseTitle, playPauseIntent)
182+
.addAction(R.drawable.ic_media_next, "Next", nextIntent)
183+
.setStyle(
184+
MediaStyle()
185+
.setMediaSession(mediaSession?.sessionToken)
186+
.setShowActionsInCompactView(0, 1, 2)
187+
.setShowCancelButton(true)
188+
.setCancelButtonIntent(stopIntent)
189+
)
190+
.build()
191+
192+
try {
193+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
194+
ServiceCompat.startForeground(
195+
this,
196+
NOTIFICATION_ID,
197+
notification,
198+
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
199+
)
200+
} else {
201+
startForeground(NOTIFICATION_ID, notification)
202+
}
203+
} catch (e: Exception) {
204+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
205+
notificationManager.notify(NOTIFICATION_ID, notification)
206+
}
207+
}
208+
209+
private fun createNotificationChannel() {
210+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
211+
val channel = NotificationChannel(
212+
CHANNEL_ID,
213+
"Media Playback",
214+
NotificationManager.IMPORTANCE_LOW
215+
).apply {
216+
description = "Background audio and media playback controls"
217+
setShowBadge(false)
218+
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
219+
}
220+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
221+
manager.createNotificationChannel(channel)
222+
}
223+
}
224+
225+
override fun onBind(intent: Intent?): IBinder? = null
226+
227+
override fun onDestroy() {
228+
super.onDestroy()
229+
mediaSession?.isActive = false
230+
mediaSession?.release()
231+
mediaSession = null
232+
}
233+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.example.media
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.os.Build
6+
import kotlinx.coroutines.CoroutineScope
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.flow.MutableSharedFlow
9+
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.flow.asSharedFlow
11+
import kotlinx.coroutines.flow.asStateFlow
12+
import kotlinx.coroutines.launch
13+
14+
data class BrowserMediaMetadata(
15+
val title: String,
16+
val artist: String,
17+
val album: String = "",
18+
val artworkUrl: String = "",
19+
val tabId: String = ""
20+
)
21+
22+
enum class MediaControlAction {
23+
PLAY,
24+
PAUSE,
25+
TOGGLE_PLAY_PAUSE,
26+
NEXT,
27+
PREVIOUS,
28+
STOP
29+
}
30+
31+
object MediaSessionManager {
32+
private val scope = CoroutineScope(Dispatchers.Main.immediate)
33+
34+
private val _currentMetadata = MutableStateFlow<BrowserMediaMetadata?>(null)
35+
val currentMetadata = _currentMetadata.asStateFlow()
36+
37+
private val _isPlaying = MutableStateFlow(false)
38+
val isPlaying = _isPlaying.asStateFlow()
39+
40+
private val _activeMediaTabId = MutableStateFlow<String?>(null)
41+
val activeMediaTabId = _activeMediaTabId.asStateFlow()
42+
43+
private val _controlActions = MutableSharedFlow<MediaControlAction>(extraBufferCapacity = 16)
44+
val controlActions = _controlActions.asSharedFlow()
45+
46+
fun updateMetadata(
47+
context: Context,
48+
tabId: String,
49+
title: String,
50+
artist: String,
51+
album: String = "",
52+
artworkUrl: String = ""
53+
) {
54+
val cleanTitle = title.trim().ifBlank { "Playing Audio" }
55+
val cleanArtist = artist.trim().ifBlank { "Neon Browser" }
56+
val meta = BrowserMediaMetadata(
57+
title = cleanTitle,
58+
artist = cleanArtist,
59+
album = album,
60+
artworkUrl = artworkUrl,
61+
tabId = tabId
62+
)
63+
_currentMetadata.value = meta
64+
_activeMediaTabId.value = tabId
65+
66+
startOrUpdateService(context)
67+
}
68+
69+
fun updatePlaybackState(
70+
context: Context,
71+
tabId: String,
72+
playing: Boolean
73+
) {
74+
_isPlaying.value = playing
75+
_activeMediaTabId.value = tabId
76+
77+
if (playing) {
78+
startOrUpdateService(context)
79+
} else {
80+
// Keep notification visible in paused state so user can resume
81+
startOrUpdateService(context)
82+
}
83+
}
84+
85+
fun dispatchAction(action: MediaControlAction) {
86+
scope.launch {
87+
_controlActions.emit(action)
88+
}
89+
}
90+
91+
fun onMediaEnded(context: Context, tabId: String) {
92+
if (_activeMediaTabId.value == tabId) {
93+
_isPlaying.value = false
94+
stopService(context)
95+
}
96+
}
97+
98+
fun stopPlayback(context: Context) {
99+
_isPlaying.value = false
100+
_currentMetadata.value = null
101+
_activeMediaTabId.value = null
102+
stopService(context)
103+
}
104+
105+
private fun startOrUpdateService(context: Context) {
106+
try {
107+
val intent = Intent(context, MediaPlaybackService::class.java).apply {
108+
action = MediaPlaybackService.ACTION_UPDATE_STATE
109+
}
110+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
111+
context.startForegroundService(intent)
112+
} else {
113+
context.startService(intent)
114+
}
115+
} catch (e: Exception) {
116+
// Safe fallback if background execution limits apply
117+
}
118+
}
119+
120+
private fun stopService(context: Context) {
121+
try {
122+
val intent = Intent(context, MediaPlaybackService::class.java).apply {
123+
action = MediaPlaybackService.ACTION_STOP
124+
}
125+
context.startService(intent)
126+
} catch (e: Exception) { }
127+
}
128+
}

0 commit comments

Comments
 (0)