From 1be4c4265f2bce68dd53258b2803a23c6bbda35b Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 16:23:05 +0000 Subject: [PATCH] vc=23: minibar + MediaController unification + Downloads UI + green theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 18 +- .../src/main/kotlin/com/sulkta/straw/Nav.kt | 1 + .../kotlin/com/sulkta/straw/StrawActivity.kt | 78 +++- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 11 + .../kotlin/com/sulkta/straw/StrawTheme.kt | 64 +++ .../straw/feature/detail/VideoDetailScreen.kt | 124 ++---- .../straw/feature/download/DownloadsScreen.kt | 247 ++++++++++ .../straw/feature/player/MinibarOverlay.kt | 141 ++++++ .../sulkta/straw/feature/player/NowPlaying.kt | 42 ++ .../straw/feature/player/PlaybackService.kt | 271 +++++------ .../straw/feature/player/PlayerScreen.kt | 420 +++++++----------- .../feature/player/StrawMediaController.kt | 144 ++++++ 12 files changed, 1067 insertions(+), 494 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 3106cdb6b..14969bfa5 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 3e5c7edf6..bf91616fa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 340dcf846..7d16e4650 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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)) + }, + ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 2e72d6ab6..0512de4aa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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") }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt new file mode 100644 index 000000000..74ad7be59 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt @@ -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, +) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 01ea63ef1..3f93f1df6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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(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(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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt new file mode 100644 index 000000000..f3262cd41 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt @@ -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>(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 { + val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager + ?: return emptyList() + val query = DownloadManager.Query() + val out = mutableListOf() + 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)) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt new file mode 100644 index 000000000..b1d98bcb6 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt @@ -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) + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt new file mode 100644 index 000000000..75e00f29c --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/NowPlaying.kt @@ -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 = emptyList(), +) + +object NowPlaying { + private val _current = MutableStateFlow(null) + val current: StateFlow = _current.asStateFlow() + + fun set(item: NowPlayingItem?) { + _current.value = item + } + + fun clear() { + _current.value = null + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 8e20dfb04..61e0f1c47 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -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) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 748c61379..173718f7a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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(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() } - 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(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() } + 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, posSec: Double, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt new file mode 100644 index 000000000..6ad3d376f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -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 { null } + +@Composable +fun rememberStrawController(): MediaController? { + val context = LocalContext.current + val state = remember { mutableStateOf(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 + } +}