Skip to content

Commit 386161b

Browse files
committed
Add experimental audio support for podcasts
1 parent 7e81786 commit 386161b

45 files changed

Lines changed: 1018 additions & 17 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ dependencies {
128128
implementation(libs.androidx.material.icons.extended)
129129
implementation(libs.androidx.material3)
130130
implementation(libs.androidx.material3.window.size)
131+
implementation(libs.androidx.media3.exoplayer)
132+
implementation(libs.androidx.media3.session)
131133
implementation(libs.androidx.navigation.compose)
132134
implementation(libs.androidx.paging.compose)
133135
implementation(libs.androidx.paging.runtime.ktx)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.capyreader.app.common
2+
3+
import androidx.compose.runtime.MutableState
4+
import androidx.compose.runtime.mutableStateOf
5+
import androidx.compose.runtime.saveable.Saver
6+
import kotlinx.serialization.Serializable
7+
import kotlinx.serialization.json.Json
8+
9+
@Serializable
10+
data class AudioEnclosure(
11+
val url: String,
12+
val title: String,
13+
val feedName: String,
14+
val durationSeconds: Long?,
15+
val artworkUrl: String?
16+
) {
17+
companion object
18+
}
19+
20+
val AudioEnclosure.Companion.Saver
21+
get() = Saver<MutableState<AudioEnclosure?>, String>(
22+
save = { state ->
23+
Json.encodeToString(state.value)
24+
},
25+
restore = { jsonString ->
26+
mutableStateOf(Json.decodeFromString(jsonString))
27+
}
28+
)

app/src/main/java/com/capyreader/app/common/WebViewInterface.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import kotlinx.serialization.json.Json
99
class WebViewInterface(
1010
private val navigateToMedia: (media: Media) -> Unit,
1111
private val onRequestLinkDialog: (link: ShareLink) -> Unit,
12+
private val onOpenAudioPlayer: (audio: AudioEnclosure) -> Unit = {},
13+
private val onPauseAudio: () -> Unit = {},
1214
) {
1315
@JavascriptInterface
1416
fun openImageGallery(imagesJson: String, clickedIndex: Int) {
@@ -33,6 +35,21 @@ class WebViewInterface(
3335
}
3436
}
3537

38+
@JavascriptInterface
39+
fun openAudioPlayer(audioJson: String) {
40+
try {
41+
val audio = Json.decodeFromString<AudioEnclosure>(audioJson)
42+
onOpenAudioPlayer(audio)
43+
} catch (e: Exception) {
44+
CapyLog.error("open_audio_player", e)
45+
}
46+
}
47+
48+
@JavascriptInterface
49+
fun pauseAudio() {
50+
onPauseAudio()
51+
}
52+
3653
companion object {
3754
const val INTERFACE_NAME = "Android"
3855
}

app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ class AppPreferences(context: Context) {
8282
val showTodayFilter: Preference<Boolean>
8383
get() = preferenceStore.getBoolean("show_today_filter", true)
8484

85+
val enableAudioPlayer: Preference<Boolean>
86+
get() = preferenceStore.getBoolean("enable_audio_player", false)
87+
8588
fun clearAll() {
8689
preferenceStore.clearAll()
8790
}

app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import androidx.activity.compose.BackHandler
44
import androidx.compose.animation.AnimatedVisibility
55
import androidx.compose.animation.fadeIn
66
import androidx.compose.animation.fadeOut
7+
import androidx.compose.animation.slideInVertically
8+
import androidx.compose.animation.slideOutVertically
79
import androidx.compose.foundation.layout.Box
810
import androidx.compose.foundation.layout.fillMaxSize
911
import androidx.compose.material3.DrawerValue
@@ -43,8 +45,11 @@ import androidx.core.net.toUri
4345
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4446
import androidx.paging.compose.collectAsLazyPagingItems
4547
import com.capyreader.app.R
48+
import com.capyreader.app.common.AudioEnclosure
4649
import com.capyreader.app.common.Media
4750
import com.capyreader.app.common.Saver
51+
import com.capyreader.app.ui.articles.audio.AudioPlayerController
52+
import com.capyreader.app.ui.articles.audio.FloatingAudioPlayer
4853
import com.capyreader.app.preferences.AfterReadAllBehavior
4954
import com.capyreader.app.preferences.AppPreferences
5055
import com.capyreader.app.refresher.RefreshInterval
@@ -191,6 +196,8 @@ fun ArticleScreen(
191196
val currentFeed by viewModel.currentFeed.collectAsStateWithLifecycle(null)
192197
val scrollBehavior = pinnedScrollBehavior()
193198
var media by rememberSaveable(saver = Media.Saver) { mutableStateOf(null) }
199+
val audioController: AudioPlayerController = koinInject()
200+
val audioEnclosure by audioController.currentAudio.collectAsState()
194201
val focusManager = LocalFocusManager.current
195202
val openUpdatePasswordDialog = {
196203
viewModel.dismissUnauthorizedMessage()
@@ -535,6 +542,23 @@ fun ArticleScreen(
535542
position = MarkReadPosition.FLOATING_ACTION_BUTTON,
536543
)
537544
}
545+
},
546+
bottomBar = {
547+
AnimatedVisibility(
548+
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
549+
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
550+
visible = audioEnclosure != null,
551+
) {
552+
audioEnclosure?.let { audio ->
553+
FloatingAudioPlayer(
554+
audio = audio,
555+
controller = audioController,
556+
onDismiss = {
557+
audioController.dismiss()
558+
},
559+
)
560+
}
561+
}
538562
}
539563
) { innerPadding ->
540564
ArticleListScaffold(
@@ -594,6 +618,9 @@ fun ArticleScreen(
594618
CapyPlaceholder()
595619
}
596620
} else if (article != null) {
621+
val isAudioPlaying by audioController.isPlaying.collectAsState()
622+
val currentAudio by audioController.currentAudio.collectAsState()
623+
597624
ArticleView(
598625
article = article,
599626
articles = articles,
@@ -604,12 +631,20 @@ fun ArticleScreen(
604631
onToggleStar = viewModel::toggleArticleStar,
605632
enableBackHandler = media == null,
606633
onSelectMedia = { media = it },
634+
onSelectAudio = { audio ->
635+
audioController.play(audio)
636+
},
637+
onPauseAudio = {
638+
audioController.pause()
639+
},
607640
onSelectArticle = { articleID ->
608641
setArticle(articleID)
609642
},
610643
onScrollToArticle = { index ->
611644
scrollToArticle(index)
612-
}
645+
},
646+
currentAudioUrl = currentAudio?.url,
647+
isAudioPlaying = isAudioPlaying,
613648
)
614649
}
615650
}
@@ -628,6 +663,7 @@ fun ArticleScreen(
628663
)
629664
}
630665

666+
631667
if (viewModel.showUnauthorizedMessage) {
632668
UnauthorizedAlertDialog(
633669
onConfirm = openUpdatePasswordDialog,
@@ -673,7 +709,11 @@ fun ArticleScreen(
673709
media = null
674710
}
675711

676-
BackHandler(media == null && search.isActive && article == null) {
712+
BackHandler(audioEnclosure != null && media == null) {
713+
audioController.dismiss()
714+
}
715+
716+
BackHandler(media == null && audioEnclosure == null && search.isActive && article == null) {
677717
search.clear()
678718
}
679719

app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.capyreader.app.ui.articles
22

33
import com.capyreader.app.preferences.AppPreferences
4+
import com.capyreader.app.ui.articles.audio.AudioPlayerController
45
import com.capyreader.app.ui.articles.feeds.edit.EditFeedViewModel
56
import com.jocmp.capy.articles.ArticleRenderer
67
import org.koin.androidx.viewmodel.dsl.viewModel
@@ -13,6 +14,9 @@ internal val articlesModule = module {
1314
appPreferences = get()
1415
)
1516
}
17+
single {
18+
AudioPlayerController(context = get())
19+
}
1620
single {
1721
ArticleRenderer(
1822
context = get(),
@@ -23,6 +27,7 @@ internal val articlesModule = module {
2327
titleFollowsBodyFont = get<AppPreferences>().readerOptions.titleFollowsBodyFont,
2428
hideTopMargin = get<AppPreferences>().readerOptions.pinTopToolbar,
2529
enableHorizontalScroll = get<AppPreferences>().readerOptions.enableHorizontaPagination,
30+
enableAudioPlayer = get<AppPreferences>().enableAudioPlayer,
2631
)
2732
}
2833
viewModel {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.capyreader.app.ui.articles.audio
2+
3+
import android.content.Context
4+
import android.os.Handler
5+
import android.os.Looper
6+
import androidx.annotation.OptIn
7+
import androidx.media3.common.MediaItem
8+
import androidx.media3.common.Player
9+
import androidx.media3.common.util.UnstableApi
10+
import androidx.media3.exoplayer.ExoPlayer
11+
import com.capyreader.app.common.AudioEnclosure
12+
import kotlinx.coroutines.flow.MutableStateFlow
13+
import kotlinx.coroutines.flow.StateFlow
14+
import kotlinx.coroutines.flow.asStateFlow
15+
16+
class AudioPlayerController(
17+
private val context: Context
18+
) {
19+
private var player: ExoPlayer? = null
20+
private val mainHandler = Handler(Looper.getMainLooper())
21+
private val positionUpdateRunnable = object : Runnable {
22+
override fun run() {
23+
player?.let {
24+
_currentPosition.value = it.currentPosition
25+
if (_duration.value == 0L && it.duration > 0) {
26+
_duration.value = it.duration
27+
}
28+
}
29+
mainHandler.postDelayed(this, 500)
30+
}
31+
}
32+
33+
private val _isPlaying = MutableStateFlow(false)
34+
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
35+
36+
private val _currentPosition = MutableStateFlow(0L)
37+
val currentPosition: StateFlow<Long> = _currentPosition.asStateFlow()
38+
39+
private val _duration = MutableStateFlow(0L)
40+
val duration: StateFlow<Long> = _duration.asStateFlow()
41+
42+
private val _currentAudio = MutableStateFlow<AudioEnclosure?>(null)
43+
val currentAudio: StateFlow<AudioEnclosure?> = _currentAudio.asStateFlow()
44+
45+
@OptIn(UnstableApi::class)
46+
fun play(audio: AudioEnclosure) {
47+
mainHandler.post {
48+
playOnMainThread(audio)
49+
}
50+
}
51+
52+
@OptIn(UnstableApi::class)
53+
private fun playOnMainThread(audio: AudioEnclosure) {
54+
val currentUrl = _currentAudio.value?.url
55+
56+
if (currentUrl == audio.url && player != null) {
57+
player?.play()
58+
return
59+
}
60+
61+
releaseInternal()
62+
63+
player = ExoPlayer.Builder(context).build().apply {
64+
val mediaItem = MediaItem.fromUri(audio.url)
65+
setMediaItem(mediaItem)
66+
prepare()
67+
playWhenReady = true
68+
69+
addListener(object : Player.Listener {
70+
override fun onIsPlayingChanged(isPlaying: Boolean) {
71+
_isPlaying.value = isPlaying
72+
if (isPlaying) {
73+
startPositionUpdates()
74+
} else {
75+
stopPositionUpdates()
76+
}
77+
}
78+
79+
override fun onPlaybackStateChanged(playbackState: Int) {
80+
if (playbackState == Player.STATE_READY) {
81+
_duration.value = duration
82+
}
83+
if (playbackState == Player.STATE_ENDED) {
84+
_isPlaying.value = false
85+
_currentPosition.value = 0L
86+
seekTo(0)
87+
}
88+
}
89+
})
90+
}
91+
92+
_currentAudio.value = audio
93+
94+
audio.durationSeconds?.let {
95+
_duration.value = it * 1000
96+
}
97+
}
98+
99+
fun pause() {
100+
mainHandler.post {
101+
player?.pause()
102+
}
103+
}
104+
105+
fun resume() {
106+
mainHandler.post {
107+
player?.play()
108+
}
109+
}
110+
111+
fun seekTo(positionMs: Long) {
112+
mainHandler.post {
113+
player?.seekTo(positionMs)
114+
_currentPosition.value = positionMs
115+
}
116+
}
117+
118+
fun dismiss() {
119+
mainHandler.post {
120+
releaseInternal()
121+
_currentAudio.value = null
122+
_isPlaying.value = false
123+
_currentPosition.value = 0L
124+
_duration.value = 0L
125+
}
126+
}
127+
128+
private fun releaseInternal() {
129+
stopPositionUpdates()
130+
player?.release()
131+
player = null
132+
}
133+
134+
private fun startPositionUpdates() {
135+
stopPositionUpdates()
136+
mainHandler.post(positionUpdateRunnable)
137+
}
138+
139+
private fun stopPositionUpdates() {
140+
mainHandler.removeCallbacks(positionUpdateRunnable)
141+
}
142+
}

0 commit comments

Comments
 (0)