Skip to content

Commit 2475a61

Browse files
authored
Merge pull request #883 from DimensionDev/feature/podcast
add podcast support
2 parents 97baadf + 08f7833 commit 2475a61

File tree

22 files changed

+1526
-91
lines changed

22 files changed

+1526
-91
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
<uses-permission android:name="android.permission.INTERNET" />
55
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
66
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
7+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
8+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
79

810
<application
911
android:allowBackup="true"
@@ -57,6 +59,16 @@
5759
<data android:mimeType="image/*" />
5860
</intent-filter>
5961
</activity>
62+
<service
63+
android:name=".common.PlaybackService"
64+
android:foregroundServiceType="mediaPlayback"
65+
android:exported="true">
66+
<intent-filter>
67+
<action android:name="androidx.media3.session.MediaSessionService"/>
68+
<action android:name="android.media.browse.MediaBrowserService"/>
69+
</intent-filter>
70+
</service>
71+
6072
</application>
6173

6274
</manifest>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package dev.dimension.flare.common
2+
3+
import android.content.ComponentName
4+
import android.content.Context
5+
import android.content.Intent
6+
import androidx.annotation.OptIn
7+
import androidx.core.net.toUri
8+
import androidx.media3.common.util.UnstableApi
9+
import androidx.media3.datasource.DefaultHttpDataSource
10+
import androidx.media3.exoplayer.ExoPlayer
11+
import androidx.media3.exoplayer.hls.DefaultHlsDataSourceFactory
12+
import androidx.media3.exoplayer.hls.HlsMediaSource
13+
import androidx.media3.session.MediaController
14+
import androidx.media3.session.MediaSession
15+
import androidx.media3.session.MediaSessionService
16+
import androidx.media3.session.SessionToken
17+
import com.google.common.util.concurrent.MoreExecutors
18+
import dev.dimension.flare.ui.model.UiPodcast
19+
import kotlinx.coroutines.flow.Flow
20+
import kotlinx.coroutines.flow.MutableStateFlow
21+
import kotlinx.coroutines.flow.asSharedFlow
22+
23+
internal class PodcastManager(
24+
private val context: Context,
25+
) : AutoCloseable {
26+
private var currentController: MediaController? = null
27+
private val _currentPodcast = MutableStateFlow<UiPodcast?>(null)
28+
val currentPodcast: Flow<UiPodcast?> = _currentPodcast.asSharedFlow()
29+
30+
@OptIn(UnstableApi::class)
31+
fun playPodcast(podcast: UiPodcast) {
32+
stopPodcast()
33+
_currentPodcast.value = podcast
34+
val sessionToken =
35+
SessionToken(context, ComponentName(context, PlaybackService::class.java))
36+
val controllerFuture =
37+
MediaController
38+
.Builder(context, sessionToken)
39+
.buildAsync()
40+
controllerFuture.addListener({
41+
currentController =
42+
controllerFuture.get().also {
43+
it.playWhenReady = true
44+
it.setMediaItem(
45+
androidx.media3.common.MediaItem
46+
.Builder()
47+
.setMediaId("podcast_session")
48+
.setUri(podcast.playbackUrl)
49+
.setMediaMetadata(
50+
androidx.media3.common.MediaMetadata
51+
.Builder()
52+
.setTitle(podcast.title)
53+
.setArtist(podcast.creator.name.innerText)
54+
.setArtworkUri(podcast.creator.avatar.toUri())
55+
.build(),
56+
).build(),
57+
)
58+
it.prepare()
59+
}
60+
}, MoreExecutors.directExecutor())
61+
}
62+
63+
fun stopPodcast() {
64+
currentController?.stop()
65+
currentController?.release()
66+
currentController = null
67+
_currentPodcast.value = null
68+
}
69+
70+
override fun close() {
71+
stopPodcast()
72+
}
73+
}
74+
75+
internal class PlaybackService : MediaSessionService() {
76+
private var mediaSession: MediaSession? = null
77+
78+
@OptIn(UnstableApi::class)
79+
override fun onTaskRemoved(rootIntent: Intent?) {
80+
pauseAllPlayersAndStopSelf()
81+
}
82+
83+
@OptIn(UnstableApi::class)
84+
override fun onCreate() {
85+
super.onCreate()
86+
val player =
87+
ExoPlayer
88+
.Builder(this)
89+
.setMediaSourceFactory(
90+
HlsMediaSource.Factory(DefaultHlsDataSourceFactory(DefaultHttpDataSource.Factory())),
91+
).setAudioAttributes(
92+
androidx.media3.common.AudioAttributes
93+
.Builder()
94+
.setContentType(androidx.media3.common.C.AUDIO_CONTENT_TYPE_MUSIC)
95+
.setUsage(androidx.media3.common.C.USAGE_MEDIA)
96+
.build(),
97+
true,
98+
).build()
99+
mediaSession =
100+
MediaSession
101+
.Builder(this, player)
102+
.setCommandButtonsForMediaItems(
103+
listOf(),
104+
).build()
105+
}
106+
107+
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
108+
109+
override fun onDestroy() {
110+
mediaSession?.run {
111+
player.release()
112+
release()
113+
mediaSession = null
114+
}
115+
super.onDestroy()
116+
}
117+
}

app/src/main/java/dev/dimension/flare/di/AndroidModule.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dev.dimension.flare.di
33
import androidx.media3.common.util.UnstableApi
44
import dev.dimension.flare.common.ComposeInAppNotification
55
import dev.dimension.flare.common.InAppNotification
6+
import dev.dimension.flare.common.PodcastManager
67
import dev.dimension.flare.common.VideoDownloadHelper
78
import dev.dimension.flare.data.repository.SettingsRepository
89
import dev.dimension.flare.ui.component.VideoPlayerPool
@@ -17,4 +18,5 @@ val androidModule =
1718
singleOf(::VideoPlayerPool)
1819
singleOf(::ComposeInAppNotification) binds arrayOf(InAppNotification::class, ComposeInAppNotification::class)
1920
singleOf(::VideoDownloadHelper)
21+
singleOf(::PodcastManager)
2022
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package dev.dimension.flare.ui.component
2+
3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.core.LinearEasing
5+
import androidx.compose.animation.core.RepeatMode
6+
import androidx.compose.animation.core.VectorConverter
7+
import androidx.compose.animation.core.animateFloat
8+
import androidx.compose.animation.core.animateValue
9+
import androidx.compose.animation.core.infiniteRepeatable
10+
import androidx.compose.animation.core.rememberInfiniteTransition
11+
import androidx.compose.animation.core.tween
12+
import androidx.compose.foundation.Canvas
13+
import androidx.compose.foundation.border
14+
import androidx.compose.foundation.layout.Arrangement
15+
import androidx.compose.foundation.layout.Box
16+
import androidx.compose.foundation.layout.Column
17+
import androidx.compose.foundation.layout.ColumnScope
18+
import androidx.compose.foundation.layout.padding
19+
import androidx.compose.foundation.layout.size
20+
import androidx.compose.foundation.shape.CircleShape
21+
import androidx.compose.material3.ExperimentalMaterial3Api
22+
import androidx.compose.material3.ExtendedFloatingActionButton
23+
import androidx.compose.material3.MaterialTheme
24+
import androidx.compose.material3.ModalBottomSheet
25+
import androidx.compose.material3.Text
26+
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.LaunchedEffect
28+
import androidx.compose.runtime.collectAsState
29+
import androidx.compose.runtime.getValue
30+
import androidx.compose.runtime.mutableStateOf
31+
import androidx.compose.runtime.remember
32+
import androidx.compose.runtime.setValue
33+
import androidx.compose.ui.Alignment
34+
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.graphics.Color
36+
import androidx.compose.ui.graphics.drawscope.Stroke
37+
import androidx.compose.ui.graphics.graphicsLayer
38+
import androidx.compose.ui.platform.LocalDensity
39+
import androidx.compose.ui.unit.Dp
40+
import androidx.compose.ui.unit.dp
41+
import dev.dimension.flare.common.PodcastManager
42+
import dev.dimension.flare.ui.model.UiState
43+
import dev.dimension.flare.ui.screen.media.PodcastContent
44+
import dev.dimension.flare.ui.theme.screenHorizontalPadding
45+
import org.koin.compose.koinInject
46+
47+
@OptIn(ExperimentalMaterial3Api::class)
48+
@Composable
49+
internal fun ColumnScope.PodcastFAB(
50+
onVisibilityChanged: (Boolean) -> Unit,
51+
modifier: Modifier = Modifier,
52+
podcastManager: PodcastManager = koinInject(),
53+
) {
54+
val podcast by podcastManager.currentPodcast.collectAsState(null)
55+
LaunchedEffect(podcast) {
56+
onVisibilityChanged.invoke(podcast != null)
57+
}
58+
AnimatedVisibility(
59+
podcast != null,
60+
modifier = modifier,
61+
) {
62+
podcast?.let { data ->
63+
var showInfoSheet by remember { mutableStateOf(false) }
64+
ExtendedFloatingActionButton(
65+
shape = MaterialTheme.shapes.large,
66+
onClick = {
67+
showInfoSheet = true
68+
},
69+
icon = {
70+
Box {
71+
AvatarComponent(
72+
data.creator.avatar,
73+
size = 36.dp,
74+
modifier =
75+
Modifier
76+
.graphicsLayer()
77+
.border(
78+
2.dp,
79+
MaterialTheme.colorScheme.primary,
80+
shape = CircleShape,
81+
),
82+
)
83+
PulsingCircle(
84+
modifier = Modifier.align(Alignment.Center),
85+
)
86+
}
87+
},
88+
text = {
89+
Text(data.title)
90+
},
91+
)
92+
if (showInfoSheet) {
93+
ModalBottomSheet(
94+
onDismissRequest = {
95+
showInfoSheet = false
96+
},
97+
) {
98+
Column(
99+
modifier =
100+
Modifier
101+
.padding(
102+
horizontal = screenHorizontalPadding,
103+
),
104+
verticalArrangement = Arrangement.spacedBy(8.dp),
105+
) {
106+
PodcastContent(
107+
data = data,
108+
isPlaying = UiState.Success(true),
109+
toUser = {},
110+
onJoinPodcast = {
111+
podcastManager.playPodcast(data)
112+
},
113+
onLeavePodcast = {
114+
podcastManager.stopPodcast()
115+
},
116+
)
117+
}
118+
}
119+
}
120+
}
121+
}
122+
}
123+
124+
@Composable
125+
internal fun PulsingCircle(
126+
modifier: Modifier = Modifier,
127+
color: Color = MaterialTheme.colorScheme.primary,
128+
) {
129+
val infiniteTransition = rememberInfiniteTransition()
130+
131+
val scale by infiniteTransition.animateFloat(
132+
initialValue = 1f,
133+
targetValue = 48f / 36f,
134+
animationSpec =
135+
infiniteRepeatable(
136+
animation = tween(durationMillis = 1000, easing = LinearEasing),
137+
repeatMode = RepeatMode.Restart,
138+
),
139+
)
140+
141+
val alpha by infiniteTransition.animateFloat(
142+
initialValue = 1f,
143+
targetValue = 0f,
144+
animationSpec =
145+
infiniteRepeatable(
146+
animation = tween(durationMillis = 1000, easing = LinearEasing),
147+
repeatMode = RepeatMode.Restart,
148+
),
149+
)
150+
151+
val strokeWidthDp by infiniteTransition.animateValue(
152+
initialValue = 4.dp,
153+
targetValue = 0.dp,
154+
typeConverter = Dp.VectorConverter,
155+
animationSpec =
156+
infiniteRepeatable(
157+
animation = tween(1000, easing = LinearEasing),
158+
repeatMode = RepeatMode.Restart,
159+
),
160+
)
161+
162+
val baseSize = 36.dp
163+
val strokeWidthPx = with(LocalDensity.current) { strokeWidthDp.toPx() }
164+
val sizePx = with(LocalDensity.current) { baseSize.toPx() }
165+
166+
Canvas(
167+
modifier =
168+
modifier
169+
.size(baseSize)
170+
.graphicsLayer {
171+
scaleX = scale
172+
scaleY = scale
173+
this.alpha = alpha
174+
},
175+
) {
176+
drawCircle(
177+
color = color,
178+
radius = sizePx / 2 - strokeWidthPx / 2,
179+
style = Stroke(width = strokeWidthPx),
180+
)
181+
}
182+
}

0 commit comments

Comments
 (0)