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 // 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: // vc=22 / 0.1.0-AH — V-2 player polish + local playlists:
// * Inline → fullscreen now hands off seek position. Tap Play (or the // * Inline → fullscreen now hands off seek position. Tap Play (or the
// ⛶ pill on the inline player) while the inline is mid-track and // ⛶ 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 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 22 const val STRAW_VERSION_CODE = 23
const val STRAW_VERSION_NAME = "0.1.0-AH" const val STRAW_VERSION_NAME = "0.1.0-AI"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -17,6 +17,7 @@ sealed interface Screen {
data object Search : Screen data object Search : Screen
data object Settings : Screen data object Settings : Screen
data object Playlists : Screen data object Playlists : Screen
data object Downloads : Screen
data class VideoDetail(val streamUrl: String, val title: String) : Screen data class VideoDetail(val streamUrl: String, val title: String) : Screen
data class Player( data class Player(
val streamUrl: String, val streamUrl: String,

View file

@ -12,17 +12,25 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable
import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import com.sulkta.straw.feature.channel.ChannelScreen import com.sulkta.straw.feature.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen 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.PlayerLeaveHandler
import com.sulkta.straw.feature.player.PlayerScreen 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.PlaylistViewScreen
import com.sulkta.straw.feature.playlist.PlaylistsScreen import com.sulkta.straw.feature.playlist.PlaylistsScreen
import com.sulkta.straw.feature.search.SearchScreen import com.sulkta.straw.feature.search.SearchScreen
@ -38,6 +46,7 @@ private val YT_URL_RE = Regex(
) )
class StrawActivity : ComponentActivity() { class StrawActivity : ComponentActivity() {
@OptIn(UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -45,8 +54,14 @@ class StrawActivity : ComponentActivity() {
val startUrl = pickYouTubeUrl(intent) val startUrl = pickYouTubeUrl(intent)
setContent { 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) { MaterialTheme(colorScheme = scheme) {
CompositionLocalProvider(LocalStrawController provides controller) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
val initial: Screen = val initial: Screen =
if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home
@ -65,11 +80,47 @@ class StrawActivity : ComponentActivity() {
onDispose { cb.remove() } 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( is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) }, onOpenSearch = { nav.push(Screen.Search) },
onOpenSettings = { nav.push(Screen.Settings) }, onOpenSettings = { nav.push(Screen.Settings) },
onOpenPlaylists = { nav.push(Screen.Playlists) }, onOpenPlaylists = { nav.push(Screen.Playlists) },
onOpenDownloads = { nav.push(Screen.Downloads) },
onOpenVideo = { url, title -> onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title)) nav.push(Screen.VideoDetail(url, title))
}, },
@ -77,6 +128,7 @@ class StrawActivity : ComponentActivity() {
nav.push(Screen.Channel(url, name)) nav.push(Screen.Channel(url, name))
}, },
) )
is Screen.Downloads -> DownloadsScreen()
is Screen.Settings -> SettingsScreen() is Screen.Settings -> SettingsScreen()
is Screen.Search -> SearchScreen( is Screen.Search -> SearchScreen(
onOpenVideo = { url, title -> onOpenVideo = { url, title ->
@ -107,22 +159,20 @@ class StrawActivity : ComponentActivity() {
streamUrl = s.streamUrl, streamUrl = s.streamUrl,
title = s.title, title = s.title,
startPositionMs = s.startPositionMs, startPositionMs = s.startPositionMs,
onMinimize = { nav.pop() },
) )
is Screen.Playlists -> PlaylistsScreen( is Screen.Playlists -> PlaylistsScreen(
onOpenPlaylist = { id, name -> onOpenPlaylist = { id, name ->
nav.push(Screen.PlaylistView(id, name)) nav.push(Screen.PlaylistView(id, name))
}, },
) )
is Screen.PlaylistView -> PlaylistViewScreen( is Screen.PlaylistView -> PlaylistViewScreen(
playlistId = s.playlistId, playlistId = s.playlistId,
initialName = s.name, initialName = s.name,
onOpenVideo = { url, title -> onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title)) nav.push(Screen.VideoDetail(url, title))
}, },
) )
}
}
}
} }
} }

View file

@ -79,6 +79,7 @@ fun StrawHome(
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenPlaylists: () -> Unit, onOpenPlaylists: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit, onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit,
feedVm: SubscriptionFeedViewModel = viewModel(), feedVm: SubscriptionFeedViewModel = viewModel(),
@ -136,6 +137,16 @@ fun StrawHome(
}, },
modifier = Modifier.padding(horizontal = 12.dp), 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)) HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Settings") }, 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.AudioAttributes import androidx.media3.common.Player
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi 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 androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage 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.formatCount
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml import com.sulkta.straw.util.stripHtml
@ -129,6 +123,9 @@ fun VideoDetailScreen(
if (inlinePlaying) { if (inlinePlaying) {
InlinePlayer( InlinePlayer(
streamUrl = streamUrl, streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
onFullscreen = { onPlay(inlinePositionMs) }, onFullscreen = { onPlay(inlinePositionMs) },
onPositionChanged = { inlinePositionMs = it }, onPositionChanged = { inlinePositionMs = it },
modifier = Modifier modifier = Modifier
@ -493,93 +490,63 @@ private fun SaveToPlaylistDialog(
@Composable @Composable
private fun InlinePlayer( private fun InlinePlayer(
streamUrl: String, streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
onFullscreen: () -> Unit, onFullscreen: () -> Unit,
onPositionChanged: (Long) -> Unit, onPositionChanged: (Long) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val controller = LocalStrawController.current
val playerVm: PlayerViewModel = viewModel() val playerVm: PlayerViewModel = viewModel()
val state by playerVm.ui.collectAsStateWithLifecycle() val state by playerVm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) } LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) }
val exoPlayer = remember { // As soon as we have a resolved stream AND the active video isn't
ExoPlayer.Builder(context) // already this URL, push it into the shared controller. The controller
.setAudioAttributes( // is the same one driving the fullscreen Player + the minibar overlay,
AudioAttributes.Builder() // so playback survives any nav transition unchanged.
.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()
}
}
val resolved = state.resolved val resolved = state.resolved
LaunchedEffect(resolved) { LaunchedEffect(controller, resolved, streamUrl) {
val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect
// Path C-7: chunk open-ended Range requests so iOS googlevideo URLs val activeUrl = NowPlaying.current.value?.streamUrl
// don't 403 on first byte. See net/IosSafeHttpDataSource.kt. if (activeUrl != streamUrl) {
val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( c.setPlayingFrom(
DefaultHttpDataSource.Factory() streamUrl = streamUrl,
.setUserAgent(STRAW_USER_AGENT) title = title,
.setAllowCrossProtocolRedirects(true) uploader = uploader,
) thumbnail = thumbnail,
val source = when { resolved = r,
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
} }
} }
// V-2: report inline position to the parent so the Play / ⛶ button // Report position to the parent on every tick so a Play / ⛶ tap picks
// can pick up where playback was when the user goes fullscreen. // up at the right spot if the active video is somehow desynced.
LaunchedEffect(exoPlayer) { LaunchedEffect(controller) {
val c = controller ?: return@LaunchedEffect
while (true) { while (true) {
onPositionChanged(exoPlayer.currentPosition.coerceAtLeast(0L)) onPositionChanged(c.currentPosition.coerceAtLeast(0L))
delay(500) 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) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
when { when {
state.loading -> CircularProgressIndicator(color = Color.White) controller == null || state.loading -> CircularProgressIndicator(color = Color.White)
state.error != null -> Text( state.error != null -> Text(
"playback error: ${state.error}", "playback error: ${state.error}",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
@ -599,10 +566,11 @@ private fun InlinePlayer(
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PlayerView(ctx).apply { PlayerView(ctx).apply {
player = exoPlayer player = controller
useController = true useController = true
} }
}, },
update = { it.player = controller },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Top-right fullscreen pill — hops to the fullscreen // 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-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Phase S: foreground-service ExoPlayer for "Background" audio mode. * Universal player for Straw. Owns the single ExoPlayer + MediaSession.
* Independent of the activity-side player. When the user taps Background * Every UI surface (inline player on VideoDetail, fullscreen PlayerScreen,
* on the player overlay, the activity stops its own playback and starts * the minibar overlay) is a MediaController client talking to this
* this service with the audio URL. Audio continues even if the activity * session so playback never restarts on a screen transition and a
* is killed (swipe out of recents). * dragged-down player just keeps going at the bottom of the layout.
* *
* Audit fixes (2026-05-24 pass #2): * The service is brought up automatically the first time the activity
* CRIT-1: call startForeground() immediately on first onStartCommand so * builds a MediaController against `SessionToken(ctx, ComponentName)`.
* Android 12+ doesn't kill the process with * It transitions to foreground when playback starts (Media3 handles the
* ForegroundServiceDidNotStartInTimeException after the 5s window. * required notification); it stops itself when idle (no controllers
* HIGH-2: return START_NOT_STICKY when there is no playable URL the * connected AND nothing in the queue).
* 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.
* *
* Limitations: * Media source dispatch lives in [StrawMediaSourceFactory] below. It
* - Single URL only. The activity-side merged-DASH path doesn't carry * routes by MIME type for DASH / HLS / progressive and merges video +
* over (we just use the best audioStream). Acceptable trade-off for * audio when the audio URL is carried in the MediaItem's
* background mode. * `requestMetadata.extras[EXTRA_AUDIO_URL]`.
* - 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.
*/ */
package com.sulkta.straw.feature.player package com.sulkta.straw.feature.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer 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.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.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.sulkta.straw.StrawActivity import com.sulkta.straw.StrawActivity
@ -57,54 +52,51 @@ import com.sulkta.straw.net.STRAW_USER_AGENT
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
private var foregroundStarted = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ensureChannel()
// Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended // Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended
// Range requests get chunked into bounded reads. iOS-bound googlevideo // Range requests get chunked into bounded reads. iOS-bound
// URLs 403 on `Range: bytes=N-` but accept `Range: bytes=N-M`. // googlevideo URLs 403 on `Range: bytes=N-` but accept `Range:
// bytes=N-M`.
val httpFactory = IosSafeHttpDataSource.Factory( val httpFactory = IosSafeHttpDataSource.Factory(
DefaultHttpDataSource.Factory() DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT) .setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true) .setAllowCrossProtocolRedirects(true)
) )
val mediaSourceFactory = DefaultMediaSourceFactory(this)
.setDataSourceFactory(httpFactory) val mediaSourceFactory = StrawMediaSourceFactory(httpFactory)
val player = ExoPlayer.Builder(this) val player = ExoPlayer.Builder(this)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(), .build(),
/* handleAudioFocus = */ true, /* handleAudioFocus = */ true,
) )
.build() .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 { player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) { if (state == Player.STATE_ENDED) stopSelfWhenIdle()
stopSelf()
}
} }
}) })
val sessionActivityIntent = PendingIntent.getActivity( val sessionActivityIntent = PendingIntent.getActivity(
this, this,
0, 0,
Intent(this, StrawActivity::class.java), Intent(this, StrawActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_IMMUTABLE, 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) mediaSession = MediaSession.Builder(this, player)
.setId(MEDIA_SESSION_ID) .setId(MEDIA_SESSION_ID)
.setSessionActivity(sessionActivityIntent) .setSessionActivity(sessionActivityIntent)
@ -115,58 +107,24 @@ class PlaybackService : MediaSessionService() {
controllerInfo: MediaSession.ControllerInfo, controllerInfo: MediaSession.ControllerInfo,
): MediaSession? = mediaSession ): MediaSession? = mediaSession
override fun onStartCommand( /**
intent: Intent?, * When the user swipes the app out of Recents, only kill the service
flags: Int, * if playback isn't running. If the user is intentionally backgrounding
startId: Int, * to keep music going, we stay alive.
): 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
}
override fun onTaskRemoved(rootIntent: Intent?) { 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 p = mediaSession?.player
val keep = p != null && val keepAlive = p != null &&
p.playWhenReady && p.playWhenReady &&
p.mediaItemCount > 0 && p.mediaItemCount > 0 &&
p.playbackState != Player.STATE_IDLE && p.playbackState != Player.STATE_IDLE &&
p.playbackState != Player.STATE_ENDED p.playbackState != Player.STATE_ENDED
if (!keep) stopSelf() if (!keepAlive) stopSelf()
} }
override fun onDestroy() { override fun onDestroy() {
// MED-1: null the field first so a late onGetSession from the // Null the field first so a late onGetSession during teardown gets
// controller-binding teardown gets null instead of a released session. // null rather than a released session.
val s = mediaSession val s = mediaSession
mediaSession = null mediaSession = null
s?.player?.release() s?.player?.release()
@ -174,73 +132,86 @@ class PlaybackService : MediaSessionService() {
super.onDestroy() super.onDestroy()
} }
private fun startForegroundCompat() { private fun stopSelfWhenIdle() {
if (foregroundStarted) return val p = mediaSession?.player ?: return
val tap = PendingIntent.getActivity( if (p.mediaItemCount == 0 || p.playbackState == Player.STATE_IDLE) {
this, stopSelf()
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)
} }
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 { companion object {
const val EXTRA_URL = "com.sulkta.straw.extra.URL" const val MEDIA_SESSION_ID = "straw"
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"
private const val NOTIF_CHANNEL_ID = "straw.playback" /**
private const val NOTIF_ID = 4242 * Bundle key when set on a MediaItem's `requestMetadata.extras`,
private const val MEDIA_SESSION_ID = "straw-bg" * 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-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Phase C: Media3 PlayerView embedded in Compose. * Fullscreen player surface. After the V-2 unification, the player
* Phase D: SponsorBlock auto-skip wired in via position-poll loop. * 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 package com.sulkta.straw.feature.player
import android.app.Activity import android.app.Activity
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Rational import android.util.Rational
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.annotation.OptIn 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.background
import androidx.compose.foundation.clickable 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.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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.compose.foundation.layout.offset
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.TrackGroup as Media3TrackGroup
import androidx.media3.common.util.UnstableApi 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 androidx.media3.ui.PlayerView
import com.sulkta.straw.net.STRAW_USER_AGENT
import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.strawLogI import com.sulkta.straw.util.strawLogI
import kotlin.math.roundToInt
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
@ -81,195 +86,115 @@ fun PlayerScreen(
streamUrl: String, streamUrl: String,
title: String, title: String,
startPositionMs: Long = 0L, startPositionMs: Long = 0L,
onMinimize: () -> Unit = {},
vm: PlayerViewModel = viewModel(), vm: PlayerViewModel = viewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val controller = LocalStrawController.current
val state by vm.ui.collectAsStateWithLifecycle() val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { vm.resolve(streamUrl) } LaunchedEffect(streamUrl) { vm.resolve(streamUrl) }
// Local UI state for speed / audio-only / dialog open.
var playbackSpeed by remember { mutableStateOf(1.0f) } var playbackSpeed by remember { mutableStateOf(1.0f) }
var audioOnly by remember { mutableStateOf(false) } var audioOnly by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) }
val exoPlayer = remember { // Drag-to-minimize: vertical offset accumulated during the gesture.
ExoPlayer.Builder(context) // On release, if past threshold we dismiss into the minibar.
.setAudioAttributes( val density = LocalDensity.current
// Tell the system we're playing media so audio focus + val dismissThresholdPx = with(density) { 200.dp.toPx() }
// ducking + Bluetooth routing work, and notifications can val dragY = remember { Animatable(0f) }
// sit alongside other media apps. val scope = rememberCoroutineScope()
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) }
}
// 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 val resolved = state.resolved
LaunchedEffect(controller, resolved, detailState.detail) {
LaunchedEffect(resolved) { val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect
// Path C-7: chunk open-ended Range requests so iOS googlevideo URLs val d = detailState.detail
// don't 403 on first byte. val uploader = d?.uploader ?: NowPlaying.current.value?.uploader.orEmpty()
val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( val thumbnail = d?.thumbnail ?: NowPlaying.current.value?.thumbnail
DefaultHttpDataSource.Factory() val sameVideo = NowPlaying.current.value?.streamUrl == streamUrl
.setUserAgent(STRAW_USER_AGENT) val currentTitle = c.mediaMetadata.title?.toString()
.setAllowCrossProtocolRedirects(true) if (sameVideo && currentTitle == title) {
) if (startPositionMs > 0) c.seekTo(startPositionMs)
if (!c.isPlaying) c.play()
val source = when { NowPlaying.set(
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) NowPlayingItem(
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl)) streamUrl = streamUrl,
title = title,
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory) uploader = uploader,
.createMediaSource(MediaItem.fromUri(r.hlsUrl)) thumbnail = thumbnail,
segments = r.segments,
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory) ),
.createMediaSource(MediaItem.fromUri(r.combinedUrl)) )
} else {
r.videoUrl != null && r.audioUrl != null -> { c.setPlayingFrom(
val v = ProgressiveMediaSource.Factory(dataSourceFactory) streamUrl = streamUrl,
.createMediaSource(MediaItem.fromUri(r.videoUrl)) title = title,
val a = ProgressiveMediaSource.Factory(dataSourceFactory) uploader = uploader,
.createMediaSource(MediaItem.fromUri(r.audioUrl)) thumbnail = thumbnail,
MergingMediaSource(v, a) resolved = r,
} startPositionMs = startPositionMs,
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 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( 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, contentAlignment = Alignment.Center,
) { ) {
when { when {
state.loading -> CircularProgressIndicator() state.loading || controller == null -> CircularProgressIndicator()
state.error != null -> Text( state.error != null -> Text(
"playback error: ${state.error}", "playback error: ${state.error}",
@ -292,14 +217,15 @@ fun PlayerScreen(
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PlayerView(ctx).apply { PlayerView(ctx).apply {
player = exoPlayer player = controller
useController = true useController = true
} }
}, },
update = { it.player = controller },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// SponsorBlock segment count badge — small overlay top-left. // SponsorBlock segment count badge — small overlay top-left.
resolved?.let { r -> resolved.let { r ->
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .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( Row(
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// Playback speed
OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") { OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") {
showSpeedDialog = true showSpeedDialog = true
} }
// Audio-only toggle
OverlayButton(label = if (audioOnly) "📻" else "📺") { OverlayButton(label = if (audioOnly) "📻" else "📺") {
audioOnly = !audioOnly audioOnly = !audioOnly
// Disable / enable video renderer via track-selection params. controller.trackSelectionParameters = TrackSelectionParameters.Builder(context)
exoPlayer.trackSelectionParameters = TrackSelectionParameters.Builder(context)
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly) .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly)
.build() .build()
Toast.makeText( Toast.makeText(
@ -337,7 +260,6 @@ fun PlayerScreen(
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
} }
// Share
OverlayButton(label = "") { OverlayButton(label = "") {
val send = Intent(Intent.ACTION_SEND).apply { val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
@ -346,11 +268,8 @@ fun PlayerScreen(
} }
context.startActivity(Intent.createChooser(send, "Share video")) 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 = "") { OverlayButton(label = "") {
val act = (context as? Activity) if (activity == null) {
if (act == null) {
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show() Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
return@OverlayButton return@OverlayButton
} }
@ -361,51 +280,16 @@ fun PlayerScreen(
val params = PictureInPictureParams.Builder() val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9)) .setAspectRatio(Rational(16, 9))
.build() .build()
val result = runCatching { act.enterPictureInPictureMode(params) } runCatching { activity.enterPictureInPictureMode(params) }
result.onSuccess { ok -> .onSuccess { ok ->
if (!ok) { if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show()
Toast.makeText( }
context, .onFailure { t ->
"PiP refused — check Settings > Apps > Straw > PiP", Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
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) { if (showSpeedDialog) {
@ -413,7 +297,7 @@ fun PlayerScreen(
current = playbackSpeed, current = playbackSpeed,
onPick = { s -> onPick = { s ->
playbackSpeed = s playbackSpeed = s
exoPlayer.playbackParameters = PlaybackParameters(s) controller.playbackParameters = PlaybackParameters(s)
showSpeedDialog = false showSpeedDialog = false
}, },
onDismiss = { showSpeedDialog = false }, onDismiss = { showSpeedDialog = false },
@ -475,9 +359,45 @@ private fun SpeedPickerDialog(
} }
/** /**
* Returns the segment whose interval contains [posSec], if any, skipping * SponsorBlock skip loop driven by the controller's currentPosition.
* UUIDs in [skipped]. Filters out POI-style point segments (start == end). * 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( private fun pickActiveSegment(
segments: List<SbSegment>, segments: List<SbSegment>,
posSec: Double, 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
}
}