diff --git a/.github/workflows/instrumented-tests.yml b/.github/workflows/instrumented-tests.yml
new file mode 100644
index 000000000..759038f19
--- /dev/null
+++ b/.github/workflows/instrumented-tests.yml
@@ -0,0 +1,30 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ instrumented-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4.2.1
+ - name: Set up Java
+ uses: actions/setup-java@v4.4.0
+ with:
+ distribution: "zulu"
+ java-version: "21"
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+ - name: Run instrumented tests
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 31
+ arch: x86_64
+ script: ./gradlew :app:connectedFreeDebugAndroidTest
diff --git a/.github/workflows/ci.yml b/.github/workflows/test.yml
similarity index 100%
rename from .github/workflows/ci.yml
rename to .github/workflows/test.yml
diff --git a/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt b/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt
new file mode 100644
index 000000000..d17e0f29d
--- /dev/null
+++ b/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt
@@ -0,0 +1,136 @@
+package com.capyreader.app.ui.articles.audio
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.capyreader.app.common.AudioEnclosure
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AudioPlayerControllerTest {
+ private lateinit var controller: AudioPlayerController
+
+ private val testAudio = AudioEnclosure(
+ url = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
+ title = "Test Audio",
+ feedName = "Test Feed",
+ durationSeconds = 60,
+ artworkUrl = null,
+ )
+
+ @Before
+ fun setup() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ controller = AudioPlayerController(context)
+ }
+ }
+
+ @After
+ fun teardown() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ controller.dismiss()
+ controller.release()
+ }
+ }
+
+ @Test
+ fun initialState_hasNullCurrentAudio() = runBlocking {
+ val currentAudio = controller.currentAudio.first()
+ assertNull(currentAudio)
+ }
+
+ @Test
+ fun initialState_isNotPlaying() = runBlocking {
+ val isPlaying = controller.isPlaying.first()
+ assertEquals(false, isPlaying)
+ }
+
+ @Test
+ fun initialState_positionIsZero() = runBlocking {
+ val position = controller.currentPosition.first()
+ assertEquals(0L, position)
+ }
+
+ @Test
+ fun play_setsCurrentAudio() = runBlocking {
+ withContext(Dispatchers.Main) {
+ controller.play(testAudio)
+ }
+
+ withTimeout(5000) {
+ while (controller.currentAudio.value == null) {
+ delay(100)
+ }
+ }
+
+ val currentAudio = controller.currentAudio.first()
+ assertNotNull(currentAudio)
+ assertEquals(testAudio.url, currentAudio?.url)
+ assertEquals(testAudio.title, currentAudio?.title)
+ }
+
+ @Test
+ fun dismiss_clearsCurrentAudio() = runBlocking {
+ withContext(Dispatchers.Main) {
+ controller.play(testAudio)
+ }
+
+ withTimeout(5000) {
+ while (controller.currentAudio.value == null) {
+ delay(100)
+ }
+ }
+
+ withContext(Dispatchers.Main) {
+ controller.dismiss()
+ }
+
+ withTimeout(2000) {
+ while (controller.currentAudio.value != null) {
+ delay(100)
+ }
+ }
+
+ val currentAudio = controller.currentAudio.first()
+ assertNull(currentAudio)
+ }
+
+ @Test
+ fun seekTo_updatesPosition() = runBlocking {
+ withContext(Dispatchers.Main) {
+ controller.play(testAudio)
+ }
+
+ withTimeout(5000) {
+ while (controller.currentAudio.value == null) {
+ delay(100)
+ }
+ }
+
+ val seekPosition = 30_000L
+ withContext(Dispatchers.Main) {
+ controller.seekTo(seekPosition)
+ }
+
+ withTimeout(2000) {
+ while (controller.currentPosition.value != seekPosition) {
+ delay(100)
+ }
+ }
+
+ val position = controller.currentPosition.first()
+ assertEquals(seekPosition, position)
+ }
+}
diff --git a/app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt
deleted file mode 100644
index 96594012c..000000000
--- a/app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.capyreader.app
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.jocmp.basilreader", appContext.packageName)
- }
-}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9bc203360..28dacecd8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
@@ -109,5 +110,14 @@
android:name="android.appwidget.provider"
android:resource="@xml/headlines_widget" />
+
+
+
+
+
+
diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt
index dc2c637ab..ff4530af6 100644
--- a/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt
+++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt
@@ -5,7 +5,6 @@ import com.capyreader.app.R
import com.capyreader.app.preferences.AppPreferences
import com.capyreader.app.ui.articles.audio.AudioPlayerController
import com.capyreader.app.ui.articles.feeds.edit.EditFeedViewModel
-import com.jocmp.capy.accounts.baseHttpClient
import com.jocmp.capy.articles.ArticleRenderer
import com.jocmp.capy.articles.AudioPlayerLabels
import org.koin.androidx.viewmodel.dsl.viewModel
@@ -20,8 +19,7 @@ internal val articlesModule = module {
}
single {
AudioPlayerController(
- context = get(),
- okHttpClient = baseHttpClient()
+ context = get()
)
}
single {
diff --git a/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt b/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt
index 9c523f9c3..9e761b35c 100644
--- a/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt
+++ b/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt
@@ -1,58 +1,37 @@
package com.capyreader.app.ui.articles.audio
+import android.content.ComponentName
import android.content.Context
-import android.os.Handler
+import android.net.Uri
import android.os.Looper
import androidx.annotation.OptIn
+import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
-import androidx.media3.database.StandaloneDatabaseProvider
-import androidx.media3.datasource.cache.CacheDataSource
-import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
-import androidx.media3.datasource.cache.SimpleCache
-import androidx.media3.datasource.okhttp.OkHttpDataSource
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
import com.capyreader.app.common.AudioEnclosure
+import com.google.common.util.concurrent.ListenableFuture
+import com.jocmp.capy.logging.CapyLog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import okhttp3.OkHttpClient
-import java.io.File
-
-private const val CACHE_SIZE_BYTES = 100L * 1024L * 1024L // 100 MB
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
class AudioPlayerController(
private val context: Context,
- okHttpClient: OkHttpClient,
) {
- private val cache: SimpleCache
- private val cacheDataSourceFactory: CacheDataSource.Factory
-
- init {
- val cacheDir = File(context.cacheDir, "audio_cache")
- val databaseProvider = StandaloneDatabaseProvider(context)
- cache = SimpleCache(cacheDir, LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES), databaseProvider)
-
- val okHttpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
- cacheDataSourceFactory = CacheDataSource.Factory()
- .setCache(cache)
- .setUpstreamDataSourceFactory(okHttpDataSourceFactory)
- }
- private var player: ExoPlayer? = null
- private val mainHandler = Handler(Looper.getMainLooper())
- private val positionUpdateRunnable = object : Runnable {
- override fun run() {
- player?.let {
- _currentPosition.value = it.currentPosition
- if (_duration.value == 0L && it.duration > 0) {
- _duration.value = it.duration
- }
- }
- mainHandler.postDelayed(this, 500)
- }
- }
+ private var controllerFuture: ListenableFuture? = null
+ private var mediaController: MediaController? = null
+ private var positionUpdateJob: Job? = null
+ private val mainScope = CoroutineScope(Dispatchers.Main)
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow = _isPlaying.asStateFlow()
@@ -66,88 +45,139 @@ class AudioPlayerController(
private val _currentAudio = MutableStateFlow(null)
val currentAudio: StateFlow = _currentAudio.asStateFlow()
- @OptIn(UnstableApi::class)
- fun play(audio: AudioEnclosure) {
- mainHandler.post {
- playOnMainThread(audio)
+ private fun ensureController(onReady: (MediaController) -> Unit) {
+ mediaController?.let {
+ if (it.isConnected) {
+ onReady(it)
+ return
+ }
}
- }
-
- @OptIn(UnstableApi::class)
- private fun playOnMainThread(audio: AudioEnclosure) {
- val currentUrl = _currentAudio.value?.url
- if (currentUrl == audio.url && player != null) {
- player?.play()
- return
- }
+ val sessionToken = SessionToken(
+ context,
+ ComponentName(context, MediaPlaybackService::class.java)
+ )
+
+ controllerFuture = MediaController.Builder(context, sessionToken)
+ .setApplicationLooper(Looper.getMainLooper())
+ .buildAsync()
+ controllerFuture?.addListener({
+ try {
+ val controller = controllerFuture?.get()
+ mediaController = controller
+ controller?.let {
+ setupPlayerListener(it)
+ onReady(it)
+ }
+ } catch (e: Exception) {
+ CapyLog.error("audio_player", e)
+ }
+ }, ContextCompat.getMainExecutor(context))
+ }
- releaseInternal()
-
- val mediaSourceFactory = DefaultMediaSourceFactory(context)
- .setDataSourceFactory(cacheDataSourceFactory)
-
- player = ExoPlayer.Builder(context)
- .setMediaSourceFactory(mediaSourceFactory)
- .build()
- .apply {
- val mediaItem = MediaItem.fromUri(audio.url)
- setMediaItem(mediaItem)
- prepare()
- playWhenReady = true
-
- addListener(object : Player.Listener {
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- _isPlaying.value = isPlaying
- if (isPlaying) {
- startPositionUpdates()
- } else {
- stopPositionUpdates()
- }
+ private fun setupPlayerListener(controller: MediaController) {
+ controller.addListener(object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ _isPlaying.value = isPlaying
+ if (isPlaying) {
+ startPositionUpdates()
+ } else {
+ stopPositionUpdates()
}
+ }
- override fun onPlaybackStateChanged(playbackState: Int) {
- if (playbackState == Player.STATE_READY) {
- _duration.value = duration
- }
- if (playbackState == Player.STATE_ENDED) {
- _isPlaying.value = false
- _currentPosition.value = 0L
- seekTo(0)
- }
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ if (playbackState == Player.STATE_READY) {
+ _duration.value = controller.duration
}
- })
- }
+ if (playbackState == Player.STATE_ENDED) {
+ _isPlaying.value = false
+ _currentPosition.value = 0L
+ controller.seekTo(0)
+ }
+ }
+ })
+ }
+
+ @OptIn(UnstableApi::class)
+ fun play(audio: AudioEnclosure) {
+ mainScope.launch {
+ val currentUrl = _currentAudio.value?.url
- _currentAudio.value = audio
+ if (currentUrl == audio.url && mediaController?.isConnected == true) {
+ mediaController?.play()
+ return@launch
+ }
- audio.durationSeconds?.let {
- _duration.value = it * 1000
+ ensureController { controller ->
+ val mediaItem = MediaItem.Builder()
+ .setUri(audio.url)
+ .setMediaMetadata(
+ MediaMetadata.Builder()
+ .setTitle(audio.title)
+ .setArtist(audio.feedName)
+ .setArtworkUri(audio.artworkUrl?.let { Uri.parse(it) })
+ .build()
+ )
+ .build()
+
+ controller.setMediaItem(mediaItem)
+ controller.prepare()
+ controller.playWhenReady = true
+
+ _currentAudio.value = audio
+ audio.durationSeconds?.let {
+ _duration.value = it * 1000
+ }
+ }
}
}
fun pause() {
- mainHandler.post {
- player?.pause()
+ mainScope.launch {
+ mediaController?.pause()
}
}
fun resume() {
- mainHandler.post {
- player?.play()
+ mainScope.launch {
+ mediaController?.play()
}
}
fun seekTo(positionMs: Long) {
- mainHandler.post {
- player?.seekTo(positionMs)
+ mainScope.launch {
+ mediaController?.seekTo(positionMs)
_currentPosition.value = positionMs
}
}
+ fun skipBack() {
+ mainScope.launch {
+ mediaController?.let { controller ->
+ val newPosition = SkipCalculator.skipBack(controller.currentPosition)
+ controller.seekTo(newPosition)
+ _currentPosition.value = newPosition
+ }
+ }
+ }
+
+ fun skipForward() {
+ mainScope.launch {
+ mediaController?.let { controller ->
+ val newPosition = SkipCalculator.skipForward(controller.currentPosition, controller.duration)
+ controller.seekTo(newPosition)
+ _currentPosition.value = newPosition
+ }
+ }
+ }
+
fun dismiss() {
- mainHandler.post {
- releaseInternal()
+ mainScope.launch {
+ mediaController?.let { controller ->
+ controller.stop()
+ controller.clearMediaItems()
+ }
_currentAudio.value = null
_isPlaying.value = false
_currentPosition.value = 0L
@@ -155,18 +185,32 @@ class AudioPlayerController(
}
}
- private fun releaseInternal() {
+ fun release() {
stopPositionUpdates()
- player?.release()
- player = null
+ controllerFuture?.let {
+ MediaController.releaseFuture(it)
+ }
+ mediaController = null
+ controllerFuture = null
}
private fun startPositionUpdates() {
stopPositionUpdates()
- mainHandler.post(positionUpdateRunnable)
+ positionUpdateJob = mainScope.launch {
+ while (isActive) {
+ mediaController?.let {
+ _currentPosition.value = it.currentPosition
+ if (_duration.value == 0L && it.duration > 0) {
+ _duration.value = it.duration
+ }
+ }
+ delay(500)
+ }
+ }
}
private fun stopPositionUpdates() {
- mainHandler.removeCallbacks(positionUpdateRunnable)
+ positionUpdateJob?.cancel()
+ positionUpdateJob = null
}
}
diff --git a/app/src/main/java/com/capyreader/app/ui/articles/audio/MediaPlaybackService.kt b/app/src/main/java/com/capyreader/app/ui/articles/audio/MediaPlaybackService.kt
new file mode 100644
index 000000000..26c3041e8
--- /dev/null
+++ b/app/src/main/java/com/capyreader/app/ui/articles/audio/MediaPlaybackService.kt
@@ -0,0 +1,181 @@
+package com.capyreader.app.ui.articles.audio
+
+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.ForwardingPlayer
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.datasource.cache.CacheDataSource
+import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.session.CommandButton
+import androidx.media3.session.DefaultMediaNotificationProvider
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionResult
+import com.capyreader.app.R
+import com.google.common.collect.ImmutableList
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import com.jocmp.capy.accounts.baseHttpClient
+import java.io.File
+
+private const val CACHE_SIZE_BYTES = 100L * 1024L * 1024L
+
+@UnstableApi
+class MediaPlaybackService : MediaSessionService() {
+ private var mediaSession: MediaSession? = null
+ private var cache: SimpleCache? = null
+
+ companion object {
+ const val CUSTOM_COMMAND_SKIP_BACK = "SKIP_BACK_30"
+ const val CUSTOM_COMMAND_SKIP_FORWARD = "SKIP_FORWARD_30"
+ }
+
+ @OptIn(UnstableApi::class)
+ override fun onCreate() {
+ super.onCreate()
+
+ val cacheDir = File(cacheDir, "audio_cache")
+ val databaseProvider = StandaloneDatabaseProvider(this)
+ cache =
+ SimpleCache(cacheDir, LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES), databaseProvider)
+
+ val okHttpClient = baseHttpClient()
+ val okHttpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
+ val cacheDataSourceFactory = CacheDataSource.Factory()
+ .setUpstreamDataSourceFactory(okHttpDataSourceFactory)
+ .also { factory ->
+ cache?.let {
+ factory.setCache(it)
+ }
+ }
+
+ val mediaSourceFactory = DefaultMediaSourceFactory(this)
+ .setDataSourceFactory(cacheDataSourceFactory)
+
+ val exoPlayer = ExoPlayer.Builder(this)
+ .setMediaSourceFactory(mediaSourceFactory)
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .setUsage(C.USAGE_MEDIA)
+ .build(),
+ true
+ )
+ .setHandleAudioBecomingNoisy(true)
+ .build()
+
+ val notificationProvider = DefaultMediaNotificationProvider(this)
+ notificationProvider.setSmallIcon(R.drawable.capy_icon_inline)
+ setMediaNotificationProvider(notificationProvider)
+
+ val player = object : ForwardingPlayer(exoPlayer) {
+ override fun getAvailableCommands(): Player.Commands {
+ return super.getAvailableCommands().buildUpon()
+ .remove(Player.COMMAND_SEEK_TO_PREVIOUS)
+ .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
+ .remove(Player.COMMAND_SEEK_TO_NEXT)
+ .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
+ .build()
+ }
+
+ override fun isCommandAvailable(command: Int): Boolean {
+ return when (command) {
+ Player.COMMAND_SEEK_TO_PREVIOUS,
+ Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
+ Player.COMMAND_SEEK_TO_NEXT,
+ Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> false
+
+ else -> super.isCommandAvailable(command)
+ }
+ }
+ }
+
+ mediaSession = MediaSession.Builder(this, player)
+ .setCallback(MediaSessionCallback())
+ .build()
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
+ return mediaSession
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ val player = mediaSession?.player
+ if (player?.playWhenReady == false || player?.mediaItemCount == 0) {
+ stopSelf()
+ }
+ }
+
+ override fun onDestroy() {
+ mediaSession?.run {
+ player.release()
+ release()
+ }
+ mediaSession = null
+ cache?.release()
+ cache = null
+ super.onDestroy()
+ }
+
+ private inner class MediaSessionCallback : MediaSession.Callback {
+ @OptIn(UnstableApi::class)
+ override fun onConnect(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo
+ ): MediaSession.ConnectionResult {
+ val skipBackCommand = SessionCommand(CUSTOM_COMMAND_SKIP_BACK, Bundle.EMPTY)
+ val skipForwardCommand = SessionCommand(CUSTOM_COMMAND_SKIP_FORWARD, Bundle.EMPTY)
+
+ val skipBackButton = CommandButton.Builder(CommandButton.ICON_SKIP_BACK_30)
+ .setDisplayName(getString(R.string.audio_player_skip_back))
+ .setSessionCommand(skipBackCommand)
+ .build()
+
+ val skipForwardButton = CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_30)
+ .setDisplayName(getString(R.string.audio_player_skip_forward))
+ .setSessionCommand(skipForwardCommand)
+ .build()
+
+ return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
+ .setAvailableSessionCommands(
+ MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
+ .add(skipBackCommand)
+ .add(skipForwardCommand)
+ .build()
+ )
+ .setMediaButtonPreferences(ImmutableList.of(skipBackButton, skipForwardButton))
+ .build()
+ }
+
+ override fun onCustomCommand(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ customCommand: SessionCommand,
+ args: Bundle
+ ): ListenableFuture {
+ val player = session.player
+ when (customCommand.customAction) {
+ CUSTOM_COMMAND_SKIP_BACK -> {
+ val newPosition = SkipCalculator.skipBack(player.currentPosition)
+ player.seekTo(newPosition)
+ }
+
+ CUSTOM_COMMAND_SKIP_FORWARD -> {
+ val newPosition = SkipCalculator.skipForward(player.currentPosition, player.duration)
+ player.seekTo(newPosition)
+ }
+ }
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
+ }
+ }
+}
diff --git a/app/src/main/java/com/capyreader/app/ui/articles/audio/SkipCalculator.kt b/app/src/main/java/com/capyreader/app/ui/articles/audio/SkipCalculator.kt
new file mode 100644
index 000000000..fd7c67ade
--- /dev/null
+++ b/app/src/main/java/com/capyreader/app/ui/articles/audio/SkipCalculator.kt
@@ -0,0 +1,17 @@
+package com.capyreader.app.ui.articles.audio
+
+internal object SkipCalculator {
+ const val SKIP_DURATION_MS = 30_000L
+
+ fun skipBack(currentPosition: Long): Long {
+ return maxOf(0L, currentPosition - SKIP_DURATION_MS)
+ }
+
+ fun skipForward(currentPosition: Long, duration: Long): Long {
+ return if (duration > 0) {
+ minOf(duration, currentPosition + SKIP_DURATION_MS)
+ } else {
+ currentPosition + SKIP_DURATION_MS
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 88321574f..e01946308 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -312,4 +312,6 @@
Play
Pause
Close player
+ Skip back 30 seconds
+ Skip forward 30 seconds
diff --git a/app/src/test/java/com/capyreader/app/ui/articles/audio/SkipCalculatorTest.kt b/app/src/test/java/com/capyreader/app/ui/articles/audio/SkipCalculatorTest.kt
new file mode 100644
index 000000000..f36021870
--- /dev/null
+++ b/app/src/test/java/com/capyreader/app/ui/articles/audio/SkipCalculatorTest.kt
@@ -0,0 +1,48 @@
+package com.capyreader.app.ui.articles.audio
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SkipCalculatorTest {
+ @Test
+ fun skipBack_fromMiddle() {
+ val result = SkipCalculator.skipBack(60_000L)
+ assertEquals(30_000L, result)
+ }
+
+ @Test
+ fun skipBack_nearStart_clampsToZero() {
+ val result = SkipCalculator.skipBack(15_000L)
+ assertEquals(0L, result)
+ }
+
+ @Test
+ fun skipBack_atStart_staysAtZero() {
+ val result = SkipCalculator.skipBack(0L)
+ assertEquals(0L, result)
+ }
+
+ @Test
+ fun skipForward_fromMiddle() {
+ val result = SkipCalculator.skipForward(60_000L, 120_000L)
+ assertEquals(90_000L, result)
+ }
+
+ @Test
+ fun skipForward_nearEnd_clampsToDuration() {
+ val result = SkipCalculator.skipForward(100_000L, 120_000L)
+ assertEquals(120_000L, result)
+ }
+
+ @Test
+ fun skipForward_unknownDuration_addsSkipAmount() {
+ val result = SkipCalculator.skipForward(60_000L, 0L)
+ assertEquals(90_000L, result)
+ }
+
+ @Test
+ fun skipForward_negativeDuration_addsSkipAmount() {
+ val result = SkipCalculator.skipForward(60_000L, -1L)
+ assertEquals(90_000L, result)
+ }
+}