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:
Kayos 2026-05-25 16:23:05 +00:00
parent e7d45aa6b4
commit 1be4c4265f
12 changed files with 1067 additions and 494 deletions

View file

@ -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"

View file

@ -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,

View file

@ -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))
},
)
}
}

View file

@ -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") },

View 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,
)

View file

@ -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

View file

@ -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))
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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
}
}