vc=23: minibar + MediaController unification + Downloads UI + green theme
Real MediaController/MediaSessionService unification: a single ExoPlayer owned by PlaybackService, every UI surface is a MediaController client. Playback never restarts on screen transitions. Drops the per-screen ExoPlayer instances; drops the EXTRA_URL Intent-based handoff from vc=21. Minibar overlay: persistent strip pinned to the bottom of every non-Player screen whenever something is loaded. Tap to expand to fullscreen, x to stop and clear, play/pause toggles. Drag-down on the fullscreen player or the down-chevron overlay button minimizes into the minibar. Single source of truth for what is playing is NowPlaying — a process-wide StateFlow refreshed by whichever surface calls setPlayingFrom. Custom MediaSource.Factory in the service routes DASH/HLS/progressive by MIME, and merges video+audio progressives via a side-channel EXTRA_AUDIO_URL bundle on the MediaItem. SponsorBlock skip loop is now activity-scoped, hoisted out of PlayerScreen, so segments are skipped in minibar mode too. Downloads tab wired into the drawer. Reads DownloadManager every second, shows status + progress, tap to open, x to remove. Theme: forest-green primary palette replaces the M3 default lavender / NewPipe red. Modern, clean, distinct.
This commit is contained in:
parent
e7d45aa6b4
commit
1be4c4265f
12 changed files with 1067 additions and 494 deletions
|
|
@ -16,6 +16,20 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
|||
|
||||
// Sulkta fork — Straw
|
||||
//
|
||||
// vc=23 / 0.1.0-AI — minibar + downloads UI + green theme:
|
||||
// * MediaController/MediaSessionService unification — single ExoPlayer
|
||||
// owned by PlaybackService, every UI surface is a controller client.
|
||||
// Inline player on VideoDetail, fullscreen Player, and the new
|
||||
// minibar overlay all drive the same underlying player; nothing
|
||||
// restarts on screen transitions.
|
||||
// * Persistent minibar overlay at the bottom of every non-Player
|
||||
// screen whenever something is loaded. Tap → expand to fullscreen.
|
||||
// Drag-down on fullscreen → minimize to minibar. ⌄ overlay button
|
||||
// also minimizes. × on the minibar stops + clears.
|
||||
// * Downloads page wired into the drawer.
|
||||
// * Theme: forest-green primary palette in place of M3 default
|
||||
// lavender / NewPipe red — modern, clean, distinct.
|
||||
//
|
||||
// vc=22 / 0.1.0-AH — V-2 player polish + local playlists:
|
||||
// * Inline → fullscreen now hands off seek position. Tap Play (or the
|
||||
// ⛶ pill on the inline player) while the inline is mid-track and
|
||||
|
|
@ -41,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
|||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||
// NewPipeExtractor in the runtime path.
|
||||
const val STRAW_VERSION_CODE = 22
|
||||
const val STRAW_VERSION_NAME = "0.1.0-AH"
|
||||
const val STRAW_VERSION_CODE = 23
|
||||
const val STRAW_VERSION_NAME = "0.1.0-AI"
|
||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ sealed interface Screen {
|
|||
data object Search : Screen
|
||||
data object Settings : Screen
|
||||
data object Playlists : Screen
|
||||
data object Downloads : Screen
|
||||
data class VideoDetail(val streamUrl: String, val title: String) : Screen
|
||||
data class Player(
|
||||
val streamUrl: String,
|
||||
|
|
|
|||
|
|
@ -12,17 +12,25 @@ import androidx.activity.OnBackPressedCallback
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.sulkta.straw.feature.channel.ChannelScreen
|
||||
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
||||
import com.sulkta.straw.feature.download.DownloadsScreen
|
||||
import com.sulkta.straw.feature.player.LocalStrawController
|
||||
import com.sulkta.straw.feature.player.MinibarOverlay
|
||||
import com.sulkta.straw.feature.player.PlayerLeaveHandler
|
||||
import com.sulkta.straw.feature.player.PlayerScreen
|
||||
import com.sulkta.straw.feature.player.SponsorBlockSkipLoop
|
||||
import com.sulkta.straw.feature.player.rememberStrawController
|
||||
import com.sulkta.straw.feature.playlist.PlaylistViewScreen
|
||||
import com.sulkta.straw.feature.playlist.PlaylistsScreen
|
||||
import com.sulkta.straw.feature.search.SearchScreen
|
||||
|
|
@ -38,6 +46,7 @@ private val YT_URL_RE = Regex(
|
|||
)
|
||||
|
||||
class StrawActivity : ComponentActivity() {
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -45,8 +54,14 @@ class StrawActivity : ComponentActivity() {
|
|||
val startUrl = pickYouTubeUrl(intent)
|
||||
|
||||
setContent {
|
||||
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
|
||||
val scheme = if (isSystemInDarkTheme()) strawDarkColors() else strawLightColors()
|
||||
// Build one MediaController for the whole activity. Every screen
|
||||
// pulls it via LocalStrawController, every PlayerView binds to
|
||||
// it, and the minibar overlay (rendered below) uses it too.
|
||||
// Single player, single source of truth.
|
||||
val controller = rememberStrawController()
|
||||
MaterialTheme(colorScheme = scheme) {
|
||||
CompositionLocalProvider(LocalStrawController provides controller) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val initial: Screen =
|
||||
if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home
|
||||
|
|
@ -65,11 +80,47 @@ class StrawActivity : ComponentActivity() {
|
|||
onDispose { cb.remove() }
|
||||
}
|
||||
|
||||
when (val s = nav.current) {
|
||||
// SponsorBlock skip loop runs at the activity level so it
|
||||
// applies whether the user is fullscreen, in the minibar,
|
||||
// or away from the player surface.
|
||||
SponsorBlockSkipLoop()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScreenContent(nav, s = nav.current)
|
||||
// Persistent minibar overlay — visible on every screen
|
||||
// except Player itself (fullscreen has its own UI).
|
||||
if (nav.current !is Screen.Player) {
|
||||
MinibarOverlay(
|
||||
onExpand = {
|
||||
val item = com.sulkta.straw.feature.player.NowPlaying.current.value
|
||||
if (item != null) {
|
||||
nav.push(
|
||||
Screen.Player(
|
||||
item.streamUrl,
|
||||
item.title,
|
||||
controller?.currentPosition ?: 0L,
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenContent(nav: Navigator, s: Screen) {
|
||||
when (s) {
|
||||
is Screen.Home -> StrawHome(
|
||||
onOpenSearch = { nav.push(Screen.Search) },
|
||||
onOpenSettings = { nav.push(Screen.Settings) },
|
||||
onOpenPlaylists = { nav.push(Screen.Playlists) },
|
||||
onOpenDownloads = { nav.push(Screen.Downloads) },
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
|
|
@ -77,6 +128,7 @@ class StrawActivity : ComponentActivity() {
|
|||
nav.push(Screen.Channel(url, name))
|
||||
},
|
||||
)
|
||||
is Screen.Downloads -> DownloadsScreen()
|
||||
is Screen.Settings -> SettingsScreen()
|
||||
is Screen.Search -> SearchScreen(
|
||||
onOpenVideo = { url, title ->
|
||||
|
|
@ -107,22 +159,20 @@ class StrawActivity : ComponentActivity() {
|
|||
streamUrl = s.streamUrl,
|
||||
title = s.title,
|
||||
startPositionMs = s.startPositionMs,
|
||||
onMinimize = { nav.pop() },
|
||||
)
|
||||
is Screen.Playlists -> PlaylistsScreen(
|
||||
onOpenPlaylist = { id, name ->
|
||||
nav.push(Screen.PlaylistView(id, name))
|
||||
},
|
||||
)
|
||||
is Screen.PlaylistView -> PlaylistViewScreen(
|
||||
playlistId = s.playlistId,
|
||||
initialName = s.name,
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Screen.PlaylistView -> PlaylistViewScreen(
|
||||
playlistId = s.playlistId,
|
||||
initialName = s.name,
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ fun StrawHome(
|
|||
onOpenSearch: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenPlaylists: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onOpenVideo: (url: String, title: String) -> Unit,
|
||||
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||
feedVm: SubscriptionFeedViewModel = viewModel(),
|
||||
|
|
@ -136,6 +137,16 @@ fun StrawHome(
|
|||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Downloads") },
|
||||
icon = { Text("⬇") },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
onOpenDownloads()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Settings") },
|
||||
|
|
|
|||
64
strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt
Normal file
64
strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Sulkta green palette for Straw. Replaces the M3 default lavender/red
|
||||
* tints with a clean forest green primary — modern, clean, distinct from
|
||||
* NewPipe / Tubular's red. Same Tonal Palette structure Material 3 uses
|
||||
* internally so all the derived surfaces stay in harmony.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val GreenPrimary = Color(0xFF386A1F)
|
||||
private val GreenOnPrimary = Color(0xFFFFFFFF)
|
||||
private val GreenPrimaryContainer = Color(0xFFB6F397)
|
||||
private val GreenOnPrimaryContainer = Color(0xFF0B2000)
|
||||
private val GreenSecondary = Color(0xFF55624C)
|
||||
private val GreenOnSecondary = Color(0xFFFFFFFF)
|
||||
private val GreenSecondaryContainer = Color(0xFFD8E7CB)
|
||||
private val GreenOnSecondaryContainer = Color(0xFF131F0D)
|
||||
private val GreenTertiary = Color(0xFF386666)
|
||||
private val GreenOnTertiary = Color(0xFFFFFFFF)
|
||||
|
||||
private val DarkGreenPrimary = Color(0xFF9BD67D)
|
||||
private val DarkGreenOnPrimary = Color(0xFF153800)
|
||||
private val DarkGreenPrimaryContainer = Color(0xFF205107)
|
||||
private val DarkGreenOnPrimaryContainer = Color(0xFFB6F397)
|
||||
private val DarkGreenSecondary = Color(0xFFBDCBB0)
|
||||
private val DarkGreenOnSecondary = Color(0xFF283420)
|
||||
private val DarkGreenSecondaryContainer = Color(0xFF3E4A35)
|
||||
private val DarkGreenOnSecondaryContainer = Color(0xFFD8E7CB)
|
||||
private val DarkGreenTertiary = Color(0xFFA0CFD0)
|
||||
private val DarkGreenOnTertiary = Color(0xFF003738)
|
||||
|
||||
fun strawLightColors(): ColorScheme = lightColorScheme(
|
||||
primary = GreenPrimary,
|
||||
onPrimary = GreenOnPrimary,
|
||||
primaryContainer = GreenPrimaryContainer,
|
||||
onPrimaryContainer = GreenOnPrimaryContainer,
|
||||
secondary = GreenSecondary,
|
||||
onSecondary = GreenOnSecondary,
|
||||
secondaryContainer = GreenSecondaryContainer,
|
||||
onSecondaryContainer = GreenOnSecondaryContainer,
|
||||
tertiary = GreenTertiary,
|
||||
onTertiary = GreenOnTertiary,
|
||||
)
|
||||
|
||||
fun strawDarkColors(): ColorScheme = darkColorScheme(
|
||||
primary = DarkGreenPrimary,
|
||||
onPrimary = DarkGreenOnPrimary,
|
||||
primaryContainer = DarkGreenPrimaryContainer,
|
||||
onPrimaryContainer = DarkGreenOnPrimaryContainer,
|
||||
secondary = DarkGreenSecondary,
|
||||
onSecondary = DarkGreenOnSecondary,
|
||||
secondaryContainer = DarkGreenSecondaryContainer,
|
||||
onSecondaryContainer = DarkGreenOnSecondaryContainer,
|
||||
tertiary = DarkGreenTertiary,
|
||||
onTertiary = DarkGreenOnTertiary,
|
||||
)
|
||||
|
|
@ -64,19 +64,13 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.net.STRAW_USER_AGENT
|
||||
import com.sulkta.straw.feature.player.LocalStrawController
|
||||
import com.sulkta.straw.feature.player.NowPlaying
|
||||
import com.sulkta.straw.feature.player.setPlayingFrom
|
||||
import com.sulkta.straw.util.formatCount
|
||||
import com.sulkta.straw.util.formatViews
|
||||
import com.sulkta.straw.util.stripHtml
|
||||
|
|
@ -129,6 +123,9 @@ fun VideoDetailScreen(
|
|||
if (inlinePlaying) {
|
||||
InlinePlayer(
|
||||
streamUrl = streamUrl,
|
||||
title = d.title,
|
||||
uploader = d.uploader,
|
||||
thumbnail = d.thumbnail,
|
||||
onFullscreen = { onPlay(inlinePositionMs) },
|
||||
onPositionChanged = { inlinePositionMs = it },
|
||||
modifier = Modifier
|
||||
|
|
@ -493,93 +490,63 @@ private fun SaveToPlaylistDialog(
|
|||
@Composable
|
||||
private fun InlinePlayer(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
onFullscreen: () -> Unit,
|
||||
onPositionChanged: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controller = LocalStrawController.current
|
||||
val playerVm: PlayerViewModel = viewModel()
|
||||
val state by playerVm.ui.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) }
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Path C-7: surface ExoPlayer failures into UI state. Without this an
|
||||
// HTTP 403 / source error showed as a stuck black box with the pause
|
||||
// controls visible — directly enabled a false-positive in the prior
|
||||
// verification pass.
|
||||
var playbackError by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(exoPlayer) {
|
||||
val listener = object : androidx.media3.common.Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
playbackError =
|
||||
"${error.errorCodeName}: ${error.message ?: "(no message)"}"
|
||||
}
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose {
|
||||
exoPlayer.removeListener(listener)
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// As soon as we have a resolved stream AND the active video isn't
|
||||
// already this URL, push it into the shared controller. The controller
|
||||
// is the same one driving the fullscreen Player + the minibar overlay,
|
||||
// so playback survives any nav transition unchanged.
|
||||
val resolved = state.resolved
|
||||
LaunchedEffect(resolved) {
|
||||
LaunchedEffect(controller, resolved, streamUrl) {
|
||||
val c = controller ?: return@LaunchedEffect
|
||||
val r = resolved ?: return@LaunchedEffect
|
||||
// Path C-7: chunk open-ended Range requests so iOS googlevideo URLs
|
||||
// don't 403 on first byte. See net/IosSafeHttpDataSource.kt.
|
||||
val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory(
|
||||
DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(STRAW_USER_AGENT)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
val source = when {
|
||||
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
|
||||
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
|
||||
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
|
||||
r.videoUrl != null && r.audioUrl != null -> {
|
||||
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.audioUrl))
|
||||
MergingMediaSource(v, a)
|
||||
}
|
||||
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
else -> null
|
||||
}
|
||||
if (source != null) {
|
||||
exoPlayer.setMediaSource(source)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
val activeUrl = NowPlaying.current.value?.streamUrl
|
||||
if (activeUrl != streamUrl) {
|
||||
c.setPlayingFrom(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumbnail,
|
||||
resolved = r,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// V-2: report inline position to the parent so the Play / ⛶ button
|
||||
// can pick up where playback was when the user goes fullscreen.
|
||||
LaunchedEffect(exoPlayer) {
|
||||
// Report position to the parent on every tick so a Play / ⛶ tap picks
|
||||
// up at the right spot if the active video is somehow desynced.
|
||||
LaunchedEffect(controller) {
|
||||
val c = controller ?: return@LaunchedEffect
|
||||
while (true) {
|
||||
onPositionChanged(exoPlayer.currentPosition.coerceAtLeast(0L))
|
||||
onPositionChanged(c.currentPosition.coerceAtLeast(0L))
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
var playbackError by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(controller) {
|
||||
val c = controller
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
playbackError = "${error.errorCodeName}: ${error.message ?: "(no message)"}"
|
||||
}
|
||||
}
|
||||
c?.addListener(listener)
|
||||
onDispose { c?.removeListener(listener) }
|
||||
}
|
||||
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
state.loading -> CircularProgressIndicator(color = Color.White)
|
||||
controller == null || state.loading -> CircularProgressIndicator(color = Color.White)
|
||||
state.error != null -> Text(
|
||||
"playback error: ${state.error}",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
|
|
@ -599,10 +566,11 @@ private fun InlinePlayer(
|
|||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
player = controller
|
||||
useController = true
|
||||
}
|
||||
},
|
||||
update = { it.player = controller },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// Top-right fullscreen pill — hops to the fullscreen
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Downloads tab — lists everything Phase R's Downloader handed off to
|
||||
* Android's DownloadManager. Reads live from DownloadManager.query()
|
||||
* keyed by package owner, so we naturally show only this app's queue.
|
||||
*
|
||||
* Row shows: title, kind (audio / video), state (running / completed /
|
||||
* failed), and progress / size. Tap a completed row → ACTION_VIEW
|
||||
* intent to whatever player the user has registered. × removes the
|
||||
* entry from the queue (and the file, per DownloadManager.remove
|
||||
* semantics).
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.download
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class DownloadRow(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val localUri: String?,
|
||||
val mediaType: String?,
|
||||
val status: Int,
|
||||
val reason: Int,
|
||||
val bytesSoFar: Long,
|
||||
val totalBytes: Long,
|
||||
) {
|
||||
val progressFraction: Float?
|
||||
get() = if (totalBytes > 0) (bytesSoFar.toFloat() / totalBytes).coerceIn(0f, 1f) else null
|
||||
|
||||
val statusLabel: String
|
||||
get() = when (status) {
|
||||
DownloadManager.STATUS_RUNNING -> "downloading"
|
||||
DownloadManager.STATUS_PENDING -> "pending"
|
||||
DownloadManager.STATUS_PAUSED -> "paused"
|
||||
DownloadManager.STATUS_SUCCESSFUL -> "done"
|
||||
DownloadManager.STATUS_FAILED -> "failed"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen() {
|
||||
val context = LocalContext.current
|
||||
var rows by remember { mutableStateOf<List<DownloadRow>>(emptyList()) }
|
||||
|
||||
// Poll DownloadManager every second while the screen is visible.
|
||||
// DownloadManager doesn't broadcast progress, so polling is the
|
||||
// standard pattern. Cheap query — single cursor across the app's own
|
||||
// download queue.
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
rows = queryDownloads(context)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Downloads",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"${rows.size} item${if (rows.size == 1) "" else "s"} · saved to app private storage",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
Text(
|
||||
"Nothing here yet. Tap Download on any video.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(rows, key = { it.id }) { row ->
|
||||
DownloadRowView(row, context, onRemove = {
|
||||
runCatching {
|
||||
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
|
||||
.remove(row.id)
|
||||
}
|
||||
rows = rows.filterNot { it.id == row.id }
|
||||
})
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadRowView(
|
||||
row: DownloadRow,
|
||||
context: Context,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val openable = row.status == DownloadManager.STATUS_SUCCESSFUL && !row.localUri.isNullOrBlank()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = openable) {
|
||||
row.localUri?.let { uri ->
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(Uri.parse(uri), row.mediaType ?: "*/*")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
runCatching { context.startActivity(intent) }
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 56.dp, height = 56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(if (row.mediaType?.startsWith("audio") == true) "🎵" else "🎬")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
row.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
buildString {
|
||||
append(row.statusLabel)
|
||||
if (row.totalBytes > 0) {
|
||||
append(" · ")
|
||||
append(formatBytes(row.bytesSoFar))
|
||||
append(" / ")
|
||||
append(formatBytes(row.totalBytes))
|
||||
} else if (row.bytesSoFar > 0) {
|
||||
append(" · ")
|
||||
append(formatBytes(row.bytesSoFar))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
row.progressFraction?.takeIf { row.status != DownloadManager.STATUS_SUCCESSFUL }
|
||||
?.let { p ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { p },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onRemove) { Text("×") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryDownloads(context: Context): List<DownloadRow> {
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
|
||||
?: return emptyList()
|
||||
val query = DownloadManager.Query()
|
||||
val out = mutableListOf<DownloadRow>()
|
||||
runCatching { dm.query(query) }.getOrNull()?.use { c ->
|
||||
val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
|
||||
val titleIdx = c.getColumnIndex(DownloadManager.COLUMN_TITLE)
|
||||
val uriIdx = c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||
val mimeIdx = c.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)
|
||||
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
while (c.moveToNext()) {
|
||||
out += DownloadRow(
|
||||
id = c.getLong(idIdx),
|
||||
title = c.getString(titleIdx) ?: "(no title)",
|
||||
localUri = c.getString(uriIdx),
|
||||
mediaType = c.getString(mimeIdx),
|
||||
status = c.getInt(statusIdx),
|
||||
reason = c.getInt(reasonIdx),
|
||||
bytesSoFar = c.getLong(soFarIdx),
|
||||
totalBytes = c.getLong(totalIdx),
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.sortedByDescending { it.id }
|
||||
}
|
||||
|
||||
private fun formatBytes(b: Long): String = when {
|
||||
b < 1024 -> "$b B"
|
||||
b < 1024L * 1024 -> "${b / 1024} KB"
|
||||
b < 1024L * 1024 * 1024 -> "%.1f MB".format(b.toDouble() / (1024 * 1024))
|
||||
else -> "%.2f GB".format(b.toDouble() / (1024L * 1024 * 1024))
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* The minibar: a thin persistent strip pinned to the bottom of every
|
||||
* non-Player screen whenever a video is loaded into the MediaController.
|
||||
* Tap to expand back to fullscreen. The × clears playback and dismisses.
|
||||
*
|
||||
* The actual player + audio lives in PlaybackService — this composable
|
||||
* is purely UI on top of the MediaController. Pause/play toggles the
|
||||
* controller, which is the same player feeding the fullscreen surface
|
||||
* and the inline detail player. There is only ever one player.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import coil3.compose.AsyncImage
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun MinibarOverlay(
|
||||
onExpand: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val controller = LocalStrawController.current
|
||||
val item by NowPlaying.current.collectAsStateWithLifecycle()
|
||||
if (controller == null || item == null) return
|
||||
val cur = item ?: return
|
||||
|
||||
// Reflect the controller's play state in the play/pause icon. Listening
|
||||
// is the only reliable way; isPlaying snapshots stale between events.
|
||||
var isPlaying by remember { mutableStateOf(controller.isPlaying) }
|
||||
DisposableEffect(controller) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
}
|
||||
}
|
||||
controller.addListener(listener)
|
||||
isPlaying = controller.isPlaying
|
||||
onDispose { controller.removeListener(listener) }
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
HorizontalDivider()
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.clickable(onClick = onExpand),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = cur.thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(width = 80.dp, height = 48.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Black),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
cur.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
cur.uploader,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
MinibarIconButton(label = if (isPlaying) "⏸" else "▶") {
|
||||
if (controller.isPlaying) controller.pause() else controller.play()
|
||||
}
|
||||
MinibarIconButton(label = "×") {
|
||||
controller.stop()
|
||||
controller.clearMediaItems()
|
||||
NowPlaying.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MinibarIconButton(label: String, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Singleton "currently active video" state — drives the minibar overlay
|
||||
* and tells screens whether their video matches what's playing. Updated
|
||||
* by whichever surface starts playback (VideoDetail tap, Player Play
|
||||
* button, playlist item tap). Cleared by the minibar's × button.
|
||||
*
|
||||
* Why a process-wide singleton instead of a ViewModel: the minibar is
|
||||
* rendered at the activity layout level and needs to outlive any
|
||||
* specific Screen.* composable. Same shape as Subscriptions / Playlists
|
||||
* — runtime-only here since there's no persistence (session-scoped).
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import com.sulkta.straw.net.SbSegment
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class NowPlayingItem(
|
||||
val streamUrl: String,
|
||||
val title: String,
|
||||
val uploader: String,
|
||||
val thumbnail: String?,
|
||||
val segments: List<SbSegment> = emptyList(),
|
||||
)
|
||||
|
||||
object NowPlaying {
|
||||
private val _current = MutableStateFlow<NowPlayingItem?>(null)
|
||||
val current: StateFlow<NowPlayingItem?> = _current.asStateFlow()
|
||||
|
||||
fun set(item: NowPlayingItem?) {
|
||||
_current.value = item
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_current.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -2,51 +2,46 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Phase S: foreground-service ExoPlayer for "Background" audio mode.
|
||||
* Independent of the activity-side player. When the user taps Background
|
||||
* on the player overlay, the activity stops its own playback and starts
|
||||
* this service with the audio URL. Audio continues even if the activity
|
||||
* is killed (swipe out of recents).
|
||||
* Universal player for Straw. Owns the single ExoPlayer + MediaSession.
|
||||
* Every UI surface (inline player on VideoDetail, fullscreen PlayerScreen,
|
||||
* the minibar overlay) is a MediaController client talking to this
|
||||
* session — so playback never restarts on a screen transition and a
|
||||
* dragged-down player just keeps going at the bottom of the layout.
|
||||
*
|
||||
* Audit fixes (2026-05-24 pass #2):
|
||||
* CRIT-1: call startForeground() immediately on first onStartCommand so
|
||||
* Android 12+ doesn't kill the process with
|
||||
* ForegroundServiceDidNotStartInTimeException after the 5s window.
|
||||
* HIGH-2: return START_NOT_STICKY when there is no playable URL — the
|
||||
* OS will not relaunch us with a null intent and crash-loop.
|
||||
* HIGH-3: stop the service when playback ends (Player.Listener) so the
|
||||
* WAKE_LOCK / foreground notification doesn't linger.
|
||||
* MED-1: null the field before releasing the session to close a tiny
|
||||
* onGetSession race during teardown.
|
||||
* The service is brought up automatically the first time the activity
|
||||
* builds a MediaController against `SessionToken(ctx, ComponentName)`.
|
||||
* It transitions to foreground when playback starts (Media3 handles the
|
||||
* required notification); it stops itself when idle (no controllers
|
||||
* connected AND nothing in the queue).
|
||||
*
|
||||
* Limitations:
|
||||
* - Single URL only. The activity-side merged-DASH path doesn't carry
|
||||
* over (we just use the best audioStream). Acceptable trade-off for
|
||||
* background mode.
|
||||
* - No SponsorBlock skip here. That logic lives in PlayerScreen and is
|
||||
* foreground-only for now.
|
||||
* - Service plays one item at a time. Queue/playlist is future work.
|
||||
* Media source dispatch lives in [StrawMediaSourceFactory] below. It
|
||||
* routes by MIME type for DASH / HLS / progressive and merges video +
|
||||
* audio when the audio URL is carried in the MediaItem's
|
||||
* `requestMetadata.extras[EXTRA_AUDIO_URL]`.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.sulkta.straw.StrawActivity
|
||||
|
|
@ -57,54 +52,51 @@ import com.sulkta.straw.net.STRAW_USER_AGENT
|
|||
class PlaybackService : MediaSessionService() {
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
private var foregroundStarted = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureChannel()
|
||||
|
||||
// Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended
|
||||
// Range requests get chunked into bounded reads. iOS-bound googlevideo
|
||||
// URLs 403 on `Range: bytes=N-` but accept `Range: bytes=N-M`.
|
||||
// Range requests get chunked into bounded reads. iOS-bound
|
||||
// googlevideo URLs 403 on `Range: bytes=N-` but accept `Range:
|
||||
// bytes=N-M`.
|
||||
val httpFactory = IosSafeHttpDataSource.Factory(
|
||||
DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(STRAW_USER_AGENT)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(this)
|
||||
.setDataSourceFactory(httpFactory)
|
||||
|
||||
val mediaSourceFactory = StrawMediaSourceFactory(httpFactory)
|
||||
|
||||
val player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.build()
|
||||
|
||||
// HIGH-3: end-of-playback should release the foreground slot.
|
||||
// Stop ourselves once playback genuinely ends so the foreground slot
|
||||
// is released. STATE_IDLE + STATE_ENDED both qualify; STATE_BUFFERING
|
||||
// / STATE_READY mean we're still doing work even if paused.
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) {
|
||||
stopSelf()
|
||||
}
|
||||
if (state == Player.STATE_ENDED) stopSelfWhenIdle()
|
||||
}
|
||||
})
|
||||
|
||||
val sessionActivityIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, StrawActivity::class.java),
|
||||
Intent(this, StrawActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
// Distinct session ID so we don't collide with the activity-side
|
||||
// MediaSession (also in this process) when the user hands off from
|
||||
// PlayerScreen → background audio. Default ID is "" which throws
|
||||
// IllegalStateException("Session ID must be unique. ID=").
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.setId(MEDIA_SESSION_ID)
|
||||
.setSessionActivity(sessionActivityIntent)
|
||||
|
|
@ -115,58 +107,24 @@ class PlaybackService : MediaSessionService() {
|
|||
controllerInfo: MediaSession.ControllerInfo,
|
||||
): MediaSession? = mediaSession
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
// CRIT-1: must startForeground within ~5s of startForegroundService,
|
||||
// before anything that can throw or block.
|
||||
startForegroundCompat()
|
||||
|
||||
val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) }
|
||||
val title = intent?.getStringExtra(EXTRA_TITLE)
|
||||
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
|
||||
val startPositionMs = intent?.getLongExtra(EXTRA_POSITION_MS, 0L)?.coerceAtLeast(0L) ?: 0L
|
||||
val player = mediaSession?.player
|
||||
if (url == null || player == null) {
|
||||
// HIGH-2: nothing to play (likely a re-launch with null intent
|
||||
// after a kill). Tear down so we don't sit holding the FG slot.
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val item = MediaItem.Builder()
|
||||
.setUri(url)
|
||||
.setMediaMetadata(
|
||||
androidx.media3.common.MediaMetadata.Builder()
|
||||
.setTitle(title ?: "")
|
||||
.setArtist(uploader ?: "")
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
player.setMediaItem(item, startPositionMs)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user swipes the app out of Recents, only kill the service
|
||||
* if playback isn't running. If the user is intentionally backgrounding
|
||||
* to keep music going, we stay alive.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
// HIGH-3: keep service alive ONLY while playback is genuinely in
|
||||
// progress. After STATE_ENDED, playWhenReady stays true but state
|
||||
// is ENDED — old check missed that and held WAKE_LOCK forever.
|
||||
val p = mediaSession?.player
|
||||
val keep = p != null &&
|
||||
val keepAlive = p != null &&
|
||||
p.playWhenReady &&
|
||||
p.mediaItemCount > 0 &&
|
||||
p.playbackState != Player.STATE_IDLE &&
|
||||
p.playbackState != Player.STATE_ENDED
|
||||
if (!keep) stopSelf()
|
||||
if (!keepAlive) stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// MED-1: null the field first so a late onGetSession from the
|
||||
// controller-binding teardown gets null instead of a released session.
|
||||
// Null the field first so a late onGetSession during teardown gets
|
||||
// null rather than a released session.
|
||||
val s = mediaSession
|
||||
mediaSession = null
|
||||
s?.player?.release()
|
||||
|
|
@ -174,73 +132,86 @@ class PlaybackService : MediaSessionService() {
|
|||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startForegroundCompat() {
|
||||
if (foregroundStarted) return
|
||||
val tap = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, StrawActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification: Notification = NotificationCompat.Builder(this, NOTIF_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle("Straw")
|
||||
.setContentText("Background audio")
|
||||
.setContentIntent(tap)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_TRANSPORT)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIF_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
private fun stopSelfWhenIdle() {
|
||||
val p = mediaSession?.player ?: return
|
||||
if (p.mediaItemCount == 0 || p.playbackState == Player.STATE_IDLE) {
|
||||
stopSelf()
|
||||
}
|
||||
foregroundStarted = true
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(NOTIF_CHANNEL_ID) != null) return
|
||||
val ch = NotificationChannel(
|
||||
NOTIF_CHANNEL_ID,
|
||||
"Background audio",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Straw audio playback while the app is in background"
|
||||
setShowBadge(false)
|
||||
}
|
||||
nm.createNotificationChannel(ch)
|
||||
}
|
||||
|
||||
/**
|
||||
* HIGH-4 mirror on the service side: the URL in EXTRA_URL came from
|
||||
* NewPipeExtractor's audioStream.content. Re-validate host + scheme
|
||||
* before handing it to ExoPlayer's HTTP source. Only YT googlevideo
|
||||
* hosts allowed; HTTPS only.
|
||||
*/
|
||||
private fun isAllowedAudioUrl(url: String): Boolean {
|
||||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (!uri.scheme.equals("https", ignoreCase = true)) return false
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
return host.endsWith(".googlevideo.com") ||
|
||||
host.endsWith(".youtube.com") ||
|
||||
host == "youtube.com"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
|
||||
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
|
||||
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
|
||||
const val EXTRA_POSITION_MS = "com.sulkta.straw.extra.POSITION_MS"
|
||||
const val MEDIA_SESSION_ID = "straw"
|
||||
|
||||
private const val NOTIF_CHANNEL_ID = "straw.playback"
|
||||
private const val NOTIF_ID = 4242
|
||||
private const val MEDIA_SESSION_ID = "straw-bg"
|
||||
/**
|
||||
* Bundle key — when set on a MediaItem's `requestMetadata.extras`,
|
||||
* the source factory will merge that audio URL with the
|
||||
* MediaItem's video URI to produce a combined video+audio source.
|
||||
*/
|
||||
const val EXTRA_AUDIO_URL = "straw.audio_url"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaSource.Factory that picks the right inner source per MediaItem:
|
||||
*
|
||||
* - If `requestMetadata.extras[EXTRA_AUDIO_URL]` is set → MergingMediaSource
|
||||
* (progressive video + progressive audio).
|
||||
* - Else by MIME: application/dash+xml → DASH, application/x-mpegURL → HLS,
|
||||
* everything else → progressive.
|
||||
*
|
||||
* Lets us drive all stream shapes (DASH MPD, HLS, combined progressive,
|
||||
* separate video+audio progressive) through the single MediaController API
|
||||
* without exposing MediaSource directly to the UI layer.
|
||||
*/
|
||||
@UnstableApi
|
||||
class StrawMediaSourceFactory(
|
||||
private val dataSourceFactory: DataSource.Factory,
|
||||
) : MediaSource.Factory {
|
||||
private val dashFactory = DashMediaSource.Factory(dataSourceFactory)
|
||||
private val hlsFactory = HlsMediaSource.Factory(dataSourceFactory)
|
||||
private val progFactory = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
// For mime-sniffing fallthroughs we also fall back to DefaultMediaSourceFactory
|
||||
// so things like extractors-only progressive items keep working.
|
||||
private val defaultFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
val audioUrl = mediaItem.requestMetadata.extras
|
||||
?.getString(PlaybackService.EXTRA_AUDIO_URL)
|
||||
if (audioUrl != null) {
|
||||
val videoSource = progFactory.createMediaSource(mediaItem)
|
||||
val audioSource = progFactory.createMediaSource(MediaItem.fromUri(Uri.parse(audioUrl)))
|
||||
return MergingMediaSource(videoSource, audioSource)
|
||||
}
|
||||
val mime = mediaItem.localConfiguration?.mimeType
|
||||
return when (mime) {
|
||||
MimeTypes.APPLICATION_MPD -> dashFactory.createMediaSource(mediaItem)
|
||||
MimeTypes.APPLICATION_M3U8 -> hlsFactory.createMediaSource(mediaItem)
|
||||
else -> {
|
||||
// Try progressive first; fall back to the default factory's
|
||||
// extractor-based selection so generic URIs (e.g. local
|
||||
// file:// from the downloads dir) still work.
|
||||
runCatching { progFactory.createMediaSource(mediaItem) }
|
||||
.getOrElse { defaultFactory.createMediaSource(mediaItem) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDrmSessionManagerProvider(p: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
dashFactory.setDrmSessionManagerProvider(p)
|
||||
hlsFactory.setDrmSessionManagerProvider(p)
|
||||
progFactory.setDrmSessionManagerProvider(p)
|
||||
defaultFactory.setDrmSessionManagerProvider(p)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setLoadErrorHandlingPolicy(p: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
dashFactory.setLoadErrorHandlingPolicy(p)
|
||||
hlsFactory.setLoadErrorHandlingPolicy(p)
|
||||
progFactory.setLoadErrorHandlingPolicy(p)
|
||||
defaultFactory.setLoadErrorHandlingPolicy(p)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun getSupportedTypes(): IntArray =
|
||||
intArrayOf(C.CONTENT_TYPE_DASH, C.CONTENT_TYPE_HLS, C.CONTENT_TYPE_OTHER)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,78 +2,83 @@
|
|||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Phase C: Media3 PlayerView embedded in Compose.
|
||||
* Phase D: SponsorBlock auto-skip wired in via position-poll loop.
|
||||
* Fullscreen player surface. After the V-2 unification, the player
|
||||
* itself lives in PlaybackService (one ExoPlayer for the whole app).
|
||||
* This composable is a thin shell that:
|
||||
* 1. Asks the PlayerViewModel to resolve the stream URL
|
||||
* 2. Pushes the resolved MediaItem into the shared MediaController
|
||||
* 3. Renders PlayerView bound to that controller
|
||||
* 4. Runs the SponsorBlock skip loop against the controller
|
||||
* 5. Lets the user drag-down to dismiss into the minibar
|
||||
*
|
||||
* Audio-only toggle, speed picker, share, manual PiP, and the
|
||||
* background-audio button stay as overlays. Audio-only flips the
|
||||
* controller's track-selection params; nothing more to do because
|
||||
* playback is one player.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.TrackGroup as Media3TrackGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.sulkta.straw.net.STRAW_USER_AGENT
|
||||
import com.sulkta.straw.net.SbSegment
|
||||
import com.sulkta.straw.util.strawLogI
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
|
|
@ -81,195 +86,115 @@ fun PlayerScreen(
|
|||
streamUrl: String,
|
||||
title: String,
|
||||
startPositionMs: Long = 0L,
|
||||
onMinimize: () -> Unit = {},
|
||||
vm: PlayerViewModel = viewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controller = LocalStrawController.current
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(streamUrl) { vm.resolve(streamUrl) }
|
||||
|
||||
// Local UI state for speed / audio-only / dialog open.
|
||||
var playbackSpeed by remember { mutableStateOf(1.0f) }
|
||||
var audioOnly by remember { mutableStateOf(false) }
|
||||
var showSpeedDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
// Tell the system we're playing media so audio focus +
|
||||
// ducking + Bluetooth routing work, and notifications can
|
||||
// sit alongside other media apps.
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Wrap the player in a MediaSession so the OS gets lock-screen +
|
||||
// notification media controls while this Activity is alive. Full
|
||||
// background-audio-after-Activity-kill is M-3 (MediaSessionService +
|
||||
// MediaController refactor).
|
||||
val mediaSession = remember {
|
||||
MediaSession.Builder(context, exoPlayer).build()
|
||||
}
|
||||
|
||||
// Path C-7: surface ExoPlayer failures so they don't read as "stuck spinner"
|
||||
// (Audit Finding 2). Posts to playbackError state which the UI renders.
|
||||
var playbackError by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(exoPlayer) {
|
||||
val listener = object : androidx.media3.common.Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
playbackError =
|
||||
"${error.errorCodeName}: ${error.message ?: "(no message)"}"
|
||||
}
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose { exoPlayer.removeListener(listener) }
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
mediaSession.release()
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Home / recents button → seamless hand-off to background audio service
|
||||
// instead of Picture-in-Picture. PiP is still available manually via the
|
||||
// ⊟ overlay button. We register a handler that the activity calls from
|
||||
// onUserLeaveHint(); the handler captures currentPosition so the audio
|
||||
// service resumes from the same point. Same code path that the explicit
|
||||
// 🎧 button uses.
|
||||
val resolvedState = androidx.compose.runtime.rememberUpdatedState(state.resolved)
|
||||
DisposableEffect(Unit) {
|
||||
PlayerLeaveHandler.handler = handler@{
|
||||
val r = resolvedState.value ?: return@handler
|
||||
val audio = r.audioUrl ?: r.combinedUrl ?: return@handler
|
||||
val position = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||
runCatching { exoPlayer.stop() }
|
||||
runCatching { exoPlayer.clearMediaItems() }
|
||||
val intent = Intent(context, PlaybackService::class.java).apply {
|
||||
component = ComponentName(context, PlaybackService::class.java)
|
||||
putExtra(PlaybackService.EXTRA_URL, audio)
|
||||
putExtra(PlaybackService.EXTRA_TITLE, title)
|
||||
putExtra(PlaybackService.EXTRA_POSITION_MS, position)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
onDispose { PlayerLeaveHandler.handler = null }
|
||||
}
|
||||
|
||||
// AUD-MED: pause playback when app goes to background. Without this,
|
||||
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
|
||||
// from the notification shade. EXCEPTION: don't pause when entering
|
||||
// Picture-in-Picture mode (that's the whole point of PiP).
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
val activity = context as? Activity
|
||||
if (activity?.isInPictureInPictureMode != true) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
// Drag-to-minimize: vertical offset accumulated during the gesture.
|
||||
// On release, if past threshold we dismiss into the minibar.
|
||||
val density = LocalDensity.current
|
||||
val dismissThresholdPx = with(density) { 200.dp.toPx() }
|
||||
val dragY = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Push the resolved video into the shared controller as soon as we
|
||||
// have stream URLs. If something else is already playing the same
|
||||
// streamUrl, just seek instead of re-loading.
|
||||
// For metadata that vm.resolve doesn't return (uploader / thumbnail) we
|
||||
// try to lift them from the matching VideoDetail item if it's open in
|
||||
// the same nav stack; otherwise fall back to whatever NowPlaying
|
||||
// already has. Either way the minibar gets enough to render.
|
||||
val detailVm: com.sulkta.straw.feature.detail.VideoDetailViewModel = viewModel()
|
||||
LaunchedEffect(streamUrl) { detailVm.load(streamUrl) }
|
||||
val detailState by detailVm.ui.collectAsStateWithLifecycle()
|
||||
val resolved = state.resolved
|
||||
|
||||
LaunchedEffect(resolved) {
|
||||
LaunchedEffect(controller, resolved, detailState.detail) {
|
||||
val c = controller ?: return@LaunchedEffect
|
||||
val r = resolved ?: return@LaunchedEffect
|
||||
// Path C-7: chunk open-ended Range requests so iOS googlevideo URLs
|
||||
// don't 403 on first byte.
|
||||
val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory(
|
||||
DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(STRAW_USER_AGENT)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
|
||||
val source = when {
|
||||
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
|
||||
|
||||
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
|
||||
|
||||
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
|
||||
|
||||
r.videoUrl != null && r.audioUrl != null -> {
|
||||
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.audioUrl))
|
||||
MergingMediaSource(v, a)
|
||||
}
|
||||
|
||||
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(r.videoUrl))
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (source != null) {
|
||||
exoPlayer.setMediaSource(source)
|
||||
// V-2: when we navigate here from an inline player that was
|
||||
// already playing, pick up at the same position instead of
|
||||
// restarting. seekTo() before prepare() is allowed; the seek
|
||||
// is queued and applied once the player is ready.
|
||||
if (startPositionMs > 0) {
|
||||
exoPlayer.seekTo(startPositionMs)
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// SponsorBlock auto-skip — poll position every 150ms, seek past any segment.
|
||||
// AUD-HIGH fixes vs initial impl:
|
||||
// - dedup skipped segments via UUID so re-listen doesn't fight the user
|
||||
// - tighter poll (150ms) reduces sponsor leak through buffering window
|
||||
// - check playbackState != IDLE/ENDED (was isPlaying, which is false
|
||||
// during buffering and missed the skip window)
|
||||
// - clamp seek target away from duration boundary to avoid jank
|
||||
val skippedUuids = remember { mutableSetOf<String>() }
|
||||
LaunchedEffect(resolved?.segments) {
|
||||
val segments = resolved?.segments ?: return@LaunchedEffect
|
||||
if (segments.isEmpty()) return@LaunchedEffect
|
||||
skippedUuids.clear()
|
||||
while (true) {
|
||||
delay(150)
|
||||
val state = exoPlayer.playbackState
|
||||
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
|
||||
val posSec = exoPlayer.currentPosition / 1000.0
|
||||
val segment = pickActiveSegment(segments, posSec, skippedUuids) ?: continue
|
||||
strawLogI(
|
||||
"StrawSb",
|
||||
"skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)",
|
||||
val d = detailState.detail
|
||||
val uploader = d?.uploader ?: NowPlaying.current.value?.uploader.orEmpty()
|
||||
val thumbnail = d?.thumbnail ?: NowPlaying.current.value?.thumbnail
|
||||
val sameVideo = NowPlaying.current.value?.streamUrl == streamUrl
|
||||
val currentTitle = c.mediaMetadata.title?.toString()
|
||||
if (sameVideo && currentTitle == title) {
|
||||
if (startPositionMs > 0) c.seekTo(startPositionMs)
|
||||
if (!c.isPlaying) c.play()
|
||||
NowPlaying.set(
|
||||
NowPlayingItem(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumbnail,
|
||||
segments = r.segments,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
c.setPlayingFrom(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumbnail,
|
||||
resolved = r,
|
||||
startPositionMs = startPositionMs,
|
||||
)
|
||||
val targetMs = (segment.endSec * 1000).toLong()
|
||||
val durationMs = exoPlayer.duration
|
||||
if (durationMs > 0 && targetMs >= durationMs - 500) {
|
||||
// Past end — let it end naturally rather than seeking past content.
|
||||
exoPlayer.seekTo(durationMs - 1)
|
||||
} else {
|
||||
exoPlayer.seekTo(targetMs)
|
||||
}
|
||||
segment.UUID?.let { skippedUuids.add(it) }
|
||||
Toast.makeText(context, "skipped ${segment.category}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Surface ExoPlayer failures from the service into the UI.
|
||||
var playbackError by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(controller) {
|
||||
val c = controller
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
playbackError = "${error.errorCodeName}: ${error.message ?: "(no message)"}"
|
||||
}
|
||||
}
|
||||
c?.addListener(listener)
|
||||
onDispose { c?.removeListener(listener) }
|
||||
}
|
||||
|
||||
// Manual-PiP wiring (the ⊟ overlay button). The activity is the PiP
|
||||
// host; we just feed it the right params. Auto-enter-on-home stays
|
||||
// disabled — HOME triggers seamless minibar/background per #255.
|
||||
val activity = context as? Activity
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset { IntOffset(0, dragY.value.roundToInt()) }
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onDragEnd = {
|
||||
if (dragY.value > dismissThresholdPx) {
|
||||
// Snap to dismiss + pop into minibar.
|
||||
onMinimize()
|
||||
} else {
|
||||
scope.launch { dragY.animateTo(0f, tween(180)) }
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
scope.launch { dragY.animateTo(0f, tween(180)) }
|
||||
},
|
||||
onVerticalDrag = { _, dy ->
|
||||
scope.launch {
|
||||
// Clamp to non-negative — upward drag has no effect.
|
||||
dragY.snapTo((dragY.value + dy).coerceAtLeast(0f))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
state.loading -> CircularProgressIndicator()
|
||||
state.loading || controller == null -> CircularProgressIndicator()
|
||||
|
||||
state.error != null -> Text(
|
||||
"playback error: ${state.error}",
|
||||
|
|
@ -292,14 +217,15 @@ fun PlayerScreen(
|
|||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
player = controller
|
||||
useController = true
|
||||
}
|
||||
},
|
||||
update = { it.player = controller },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// SponsorBlock segment count badge — small overlay top-left.
|
||||
resolved?.let { r ->
|
||||
resolved.let { r ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
|
|
@ -315,20 +241,17 @@ fun PlayerScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
// Top-right overlay — speed / audio-only / share / PiP.
|
||||
// Top-right overlay — speed / audio-only / share / PiP / minimize.
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Playback speed
|
||||
OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") {
|
||||
showSpeedDialog = true
|
||||
}
|
||||
// Audio-only toggle
|
||||
OverlayButton(label = if (audioOnly) "📻" else "📺") {
|
||||
audioOnly = !audioOnly
|
||||
// Disable / enable video renderer via track-selection params.
|
||||
exoPlayer.trackSelectionParameters = TrackSelectionParameters.Builder(context)
|
||||
controller.trackSelectionParameters = TrackSelectionParameters.Builder(context)
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly)
|
||||
.build()
|
||||
Toast.makeText(
|
||||
|
|
@ -337,7 +260,6 @@ fun PlayerScreen(
|
|||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
// Share
|
||||
OverlayButton(label = "↗") {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
|
|
@ -346,11 +268,8 @@ fun PlayerScreen(
|
|||
}
|
||||
context.startActivity(Intent.createChooser(send, "Share video"))
|
||||
}
|
||||
// PiP — manual entry (auto-enter on home gesture is wired
|
||||
// up via the DisposableEffect above on Android 12+).
|
||||
OverlayButton(label = "⊟") {
|
||||
val act = (context as? Activity)
|
||||
if (act == null) {
|
||||
if (activity == null) {
|
||||
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
|
||||
return@OverlayButton
|
||||
}
|
||||
|
|
@ -361,51 +280,16 @@ fun PlayerScreen(
|
|||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
.build()
|
||||
val result = runCatching { act.enterPictureInPictureMode(params) }
|
||||
result.onSuccess { ok ->
|
||||
if (!ok) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"PiP refused — check Settings > Apps > Straw > PiP",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
runCatching { activity.enterPictureInPictureMode(params) }
|
||||
.onSuccess { ok ->
|
||||
if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
.onFailure { t ->
|
||||
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
result.onFailure { t ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"PiP failed: ${t.message ?: t.javaClass.simpleName}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
// Background audio (phase S) — independent foreground-service playback.
|
||||
// Audit HIGH-1: handing off, not dual-hosting. Stop activity's player
|
||||
// first so the OS sees a single MediaSession (cleaner lockscreen +
|
||||
// audio focus) and we don't leak two active ExoPlayers.
|
||||
OverlayButton(label = "🎧") {
|
||||
val r = resolved ?: return@OverlayButton
|
||||
val audio = r.audioUrl ?: r.combinedUrl
|
||||
if (audio == null) {
|
||||
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
|
||||
return@OverlayButton
|
||||
}
|
||||
val position = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||
runCatching { exoPlayer.stop() }
|
||||
runCatching { exoPlayer.clearMediaItems() }
|
||||
val intent = Intent(context, PlaybackService::class.java).apply {
|
||||
component = ComponentName(context, PlaybackService::class.java)
|
||||
putExtra(PlaybackService.EXTRA_URL, audio)
|
||||
putExtra(PlaybackService.EXTRA_TITLE, title)
|
||||
putExtra(PlaybackService.EXTRA_POSITION_MS, position)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"background audio started — close the app whenever",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
// Explicit minimize button — same effect as drag-down.
|
||||
OverlayButton(label = "⌄") { onMinimize() }
|
||||
}
|
||||
|
||||
if (showSpeedDialog) {
|
||||
|
|
@ -413,7 +297,7 @@ fun PlayerScreen(
|
|||
current = playbackSpeed,
|
||||
onPick = { s ->
|
||||
playbackSpeed = s
|
||||
exoPlayer.playbackParameters = PlaybackParameters(s)
|
||||
controller.playbackParameters = PlaybackParameters(s)
|
||||
showSpeedDialog = false
|
||||
},
|
||||
onDismiss = { showSpeedDialog = false },
|
||||
|
|
@ -475,9 +359,45 @@ private fun SpeedPickerDialog(
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the segment whose interval contains [posSec], if any, skipping
|
||||
* UUIDs in [skipped]. Filters out POI-style point segments (start == end).
|
||||
* SponsorBlock skip loop driven by the controller's currentPosition.
|
||||
* Runs at the activity composition root (not per-screen) so it skips
|
||||
* segments whether the user is fullscreen, in the minibar, or away from
|
||||
* the player surface.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
fun SponsorBlockSkipLoop() {
|
||||
val controller = LocalStrawController.current
|
||||
val context = LocalContext.current
|
||||
val item by NowPlaying.current.collectAsStateWithLifecycle()
|
||||
val cur = item ?: return
|
||||
val segments = cur.segments
|
||||
if (segments.isEmpty() || controller == null) return
|
||||
val skipped = remember(cur.streamUrl) { mutableSetOf<String>() }
|
||||
LaunchedEffect(cur.streamUrl, controller) {
|
||||
while (true) {
|
||||
delay(150)
|
||||
val state = controller.playbackState
|
||||
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
|
||||
val posSec = controller.currentPosition / 1000.0
|
||||
val s = pickActiveSegment(segments, posSec, skipped) ?: continue
|
||||
strawLogI(
|
||||
"StrawSb",
|
||||
"skip: ${s.category} ${s.startSec}s..${s.endSec}s (pos=$posSec)",
|
||||
)
|
||||
val targetMs = (s.endSec * 1000).toLong()
|
||||
val durationMs = controller.duration
|
||||
if (durationMs > 0 && targetMs >= durationMs - 500) {
|
||||
controller.seekTo(durationMs - 1)
|
||||
} else {
|
||||
controller.seekTo(targetMs)
|
||||
}
|
||||
s.UUID?.let { skipped.add(it) }
|
||||
Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickActiveSegment(
|
||||
segments: List<SbSegment>,
|
||||
posSec: Double,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Composable bridge to the PlaybackService MediaController.
|
||||
*
|
||||
* Why this file exists: every UI surface in Straw (inline player on the
|
||||
* detail screen, the fullscreen Player, the minibar overlay) renders the
|
||||
* same single underlying MediaController. We expose it via a
|
||||
* CompositionLocal so the screens don't have to know how to connect.
|
||||
*
|
||||
* The controller is built async — SessionToken bind happens on a
|
||||
* background thread, the controller future resolves once the service is
|
||||
* up. Until then `LocalStrawController.current` is null; consumers
|
||||
* should render placeholder UI in that brief window.
|
||||
*
|
||||
* Lifecycle: tied to the activity's composition. When the activity
|
||||
* finishes the DisposableEffect cleanup releases the future. The
|
||||
* MediaSessionService stays alive iff there's still something playing
|
||||
* (its own onTaskRemoved + STATE_ENDED logic handles that).
|
||||
*
|
||||
* Also: a small helper, [setPlayingFrom], that knows how to convert
|
||||
* Straw's domain ResolvedPlayback (DASH URL / HLS URL / combined URL /
|
||||
* video+audio pair) into a single MediaItem the service understands.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.player
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
||||
val LocalStrawController = compositionLocalOf<MediaController?> { null }
|
||||
|
||||
@Composable
|
||||
fun rememberStrawController(): MediaController? {
|
||||
val context = LocalContext.current
|
||||
val state = remember { mutableStateOf<MediaController?>(null) }
|
||||
DisposableEffect(Unit) {
|
||||
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val future = MediaController.Builder(context, token).buildAsync()
|
||||
future.addListener({
|
||||
// future.get() throws if the build failed; treat as null in that case.
|
||||
state.value = runCatching { future.get() }.getOrNull()
|
||||
}, MoreExecutors.directExecutor())
|
||||
onDispose {
|
||||
MediaController.releaseFuture(future)
|
||||
state.value = null
|
||||
}
|
||||
}
|
||||
return state.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a resolved video into the controller and update NowPlaying.
|
||||
*
|
||||
* Stream-shape preference matches the previous activity-side picker:
|
||||
* DASH (full quality + adaptive) > HLS > combined progressive > merged
|
||||
* video+audio progressives > video-only progressive. The
|
||||
* [StrawMediaSourceFactory] on the service end picks the right inner
|
||||
* MediaSource based on MIME + the EXTRA_AUDIO_URL bundle.
|
||||
*/
|
||||
@UnstableApi
|
||||
fun MediaController.setPlayingFrom(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
resolved: ResolvedPlayback,
|
||||
startPositionMs: Long = 0L,
|
||||
) {
|
||||
val item = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
|
||||
setMediaItem(item, startPositionMs)
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
NowPlaying.set(
|
||||
NowPlayingItem(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
thumbnail = thumbnail,
|
||||
segments = resolved.segments,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
private fun buildMediaItem(
|
||||
title: String,
|
||||
uploader: String,
|
||||
thumbnail: String?,
|
||||
r: ResolvedPlayback,
|
||||
): MediaItem? {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(uploader)
|
||||
.apply {
|
||||
thumbnail?.let { setArtworkUri(android.net.Uri.parse(it)) }
|
||||
}
|
||||
.build()
|
||||
val baseBuilder = MediaItem.Builder().setMediaMetadata(metadata)
|
||||
return when {
|
||||
!r.dashMpdUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.dashMpdUrl)
|
||||
.setMimeType(MimeTypes.APPLICATION_MPD)
|
||||
.build()
|
||||
!r.hlsUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.hlsUrl)
|
||||
.setMimeType(MimeTypes.APPLICATION_M3U8)
|
||||
.build()
|
||||
!r.combinedUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.combinedUrl)
|
||||
.build()
|
||||
!r.videoUrl.isNullOrBlank() && !r.audioUrl.isNullOrBlank() -> {
|
||||
val extras = Bundle().apply {
|
||||
putString(PlaybackService.EXTRA_AUDIO_URL, r.audioUrl)
|
||||
}
|
||||
baseBuilder
|
||||
.setUri(r.videoUrl)
|
||||
.setRequestMetadata(
|
||||
MediaItem.RequestMetadata.Builder()
|
||||
.setExtras(extras)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
!r.videoUrl.isNullOrBlank() -> baseBuilder
|
||||
.setUri(r.videoUrl)
|
||||
.build()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue