Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b741ebf
Using a modern media3 player with a foreground service to display a m…
ThomasSession Dec 22, 2025
673b20c
todos
ThomasSession Dec 22, 2025
88cf486
audio notification tweaks
ThomasSession Dec 23, 2025
6c8bf23
Using MessageId for message to scroll to
ThomasSession Dec 23, 2025
eaad244
Logic clean up
ThomasSession Jan 4, 2026
9cc3f34
Merge branch 'dev' into experiment/audio-playback
ThomasSession Jan 29, 2026
6dcd2de
Loading state tweak
ThomasSession Jan 29, 2026
909a9e6
Merge branch 'dev' into experiment/audio-playback
ThomasSession Jan 29, 2026
7a23947
Merge branch 'dev' into experiment/audio-playback
ThomasSession Jan 29, 2026
0fb236a
Fixed up some TODOs
ThomasSession Jan 29, 2026
3065feb
New todos
ThomasSession Jan 29, 2026
bdd68c0
Properly handling notification image
ThomasSession Jan 30, 2026
bf60388
BEtter title and artist logic
ThomasSession Jan 30, 2026
a1c465b
Simpler logic
ThomasSession Jan 30, 2026
22f5b73
Merge branch 'dev' into experiment/audio-playback
ThomasSession Jan 30, 2026
1c4cb43
Scrubber in audio message
ThomasSession Jan 30, 2026
ba1b654
Better play/pause/scrubbing controls
ThomasSession Feb 1, 2026
14a0e5f
Remembered state
ThomasSession Feb 1, 2026
fc6d799
Better state management
ThomasSession Feb 1, 2026
dc3a5b5
Updated logic
ThomasSession Feb 2, 2026
0c66abf
More ui and logic tweaks
ThomasSession Feb 2, 2026
85376a7
Added a title to the message
ThomasSession Feb 2, 2026
93787c4
Mini player - WIP
ThomasSession Feb 2, 2026
3f28b55
Mini player on home screen
ThomasSession Feb 2, 2026
c607edc
New util to animate composable content with the data being kept while…
ThomasSession Feb 2, 2026
f51814a
Making sure the mini player doesn't hide anything
ThomasSession Feb 2, 2026
ead01fe
Fixed end audio state
ThomasSession Feb 3, 2026
b8a1e99
Merge branch 'dev' into experiment/audio-playback
ThomasSession Feb 3, 2026
192410c
Adding audio finished event - used by autoplay
ThomasSession Feb 3, 2026
30d8876
Added the "scroll to audio message" logic
ThomasSession Feb 3, 2026
3c21939
new icons + new mini player style
ThomasSession Feb 3, 2026
0822d86
Merge branch 'dev' into experiment/audio-playback
ThomasSession Feb 3, 2026
5d06b18
Added progress bar to miniplayer and fixed seeking logic state
ThomasSession Feb 3, 2026
93d0c73
Message bubble color styling
ThomasSession Feb 4, 2026
e012ccd
alpha update
ThomasSession Feb 4, 2026
6394e36
Updated scroll logic - unified methods
ThomasSession Feb 5, 2026
371c2ce
Added option to choose smooth or not for scroll id
ThomasSession Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,11 @@ dependencies {

implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.common.ktx)
implementation(libs.androidx.media3.ui.compose)
implementation(libs.androidx.media3.ui.compose.material3)

implementation(libs.conscrypt.android)
implementation(libs.android)
implementation(libs.photoview)
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />

<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
Expand Down Expand Up @@ -334,6 +335,17 @@
android:name="org.thoughtcrime.securesms.service.CallForegroundService"
android:foregroundServiceType="phoneCall|microphone"
android:exported="false" />
<service
android:name="org.thoughtcrime.securesms.audio.AudioMediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">

<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>

</service>

<receiver
android:name="org.thoughtcrime.securesms.notifications.MarkReadReceiver"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package org.thoughtcrime.securesms.audio

import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionError
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import org.session.libsession.utilities.Address
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.audio.model.AudioCommands
import org.thoughtcrime.securesms.audio.model.MediaItemFactory
import org.thoughtcrime.securesms.audio.model.MediaItemFactory.EXTRA_MESSAGE_ID
import org.thoughtcrime.securesms.audio.model.MediaItemFactory.EXTRA_THREAD_ADDRESS
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.util.getParcelableCompat

@AndroidEntryPoint
class AudioMediaService : MediaSessionService() {
private val TAG = "AudioMediaService"

private lateinit var player: ExoPlayer
private var session: MediaSession? = null

@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()

// Media-style notification + lockscreen controls handled by MediaSessionService.
val notificationProvider = DefaultMediaNotificationProvider.Builder(this)
.build()

notificationProvider.setSmallIcon(R.drawable.ic_notification)

setMediaNotificationProvider(
notificationProvider
)

player = ExoPlayer.Builder(this).build().apply {
addListener(servicePlayerListener)
}

session = MediaSession.Builder(this, player)
.setCallback(SessionCallback())
.build()

updateSessionActivityFromCurrentItem()

// Apply policy for the initial item if any controller sets it very quickly.
applyAudioPolicyForCurrentItem()
}

@OptIn(UnstableApi::class)
private fun updateSessionActivityFromCurrentItem() {
val item = player.currentMediaItem ?: return
val extras = item.mediaMetadata.extras ?: return

val thread = extras.getParcelableCompat<Address.Conversable>(EXTRA_THREAD_ADDRESS) ?: return

val messageId = extras.getParcelableCompat<MessageId>(
EXTRA_MESSAGE_ID
) ?: return

val intent = ConversationActivityV2.createIntent(
context = applicationContext,
address = thread,
scrollToMessage = messageId
).apply {
// good defaults for "return to existing convo if open"
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}

val requestCode = item.mediaId.hashCode()

val pi = PendingIntent.getActivity(
applicationContext,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

session?.setSessionActivity(pi)
}

override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session

override fun onDestroy() {
session?.release()
session = null

player.removeListener(servicePlayerListener)
player.release()

super.onDestroy()
}

override fun onTaskRemoved(rootIntent: Intent?) {
if (player.isPlaying) {
player.stop()
}
stopSelf()
}

// Player policy (voice vs music)

private fun applyAudioPolicyForCurrentItem() {
val isVoice = MediaItemFactory.isVoice(player.currentMediaItem)

val attrs = AudioAttributes.Builder()
.setContentType(if (isVoice) C.AUDIO_CONTENT_TYPE_SPEECH else C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()

player.setAudioAttributes(attrs, /* handleAudioFocus = */ true)
}

private val servicePlayerListener = object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
applyAudioPolicyForCurrentItem()
updateSessionActivityFromCurrentItem()
}

override fun onPlaybackStateChanged(playbackState: Int) {
// stop service once track has ended
if (playbackState == Player.STATE_ENDED) {
stopSelf()
return
}

// If nothing queued, stop service
if (playbackState == Player.STATE_IDLE && player.mediaItemCount == 0) {
stopSelf()
}
}
}

// MediaSession callback

private inner class SessionCallback : MediaSession.Callback {

@OptIn(UnstableApi::class)
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val base = super.onConnect(session, controller)

// Tighten commands for the system notification controller (no next/previous).
if (session.isMediaNotificationController(controller)) {
val playerCommands = base.availablePlayerCommands
.buildUpon()
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.build()

val sessionCommands = base.availableSessionCommands
.buildUpon()
.add(AudioCommands.ScrubStart)
.add(AudioCommands.ScrubStop)
.build()

return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands)
}

// In-app UI controller: allow scrubbing commands too.
return MediaSession.ConnectionResult.accept(
base.availableSessionCommands.buildUpon()
.add(AudioCommands.ScrubStart)
.add(AudioCommands.ScrubStop)
.build(),
base.availablePlayerCommands
)
}

@OptIn(UnstableApi::class)
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
return when {
AudioCommands.isScrubStart(customCommand) -> {
player.isScrubbingModeEnabled = true
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
AudioCommands.isScrubStop(customCommand) -> {
player.isScrubbingModeEnabled = false
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
else -> {
Log.w(TAG, "Unsupported custom command: ${customCommand.customAction}")
Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
}
}
}
}
}
Loading