vc=23: minibar + MediaController unification + Downloads UI + green theme
Real MediaController/MediaSessionService unification: a single ExoPlayer owned by PlaybackService, every UI surface is a MediaController client. Playback never restarts on screen transitions. Drops the per-screen ExoPlayer instances; drops the EXTRA_URL Intent-based handoff from vc=21. Minibar overlay: persistent strip pinned to the bottom of every non-Player screen whenever something is loaded. Tap to expand to fullscreen, x to stop and clear, play/pause toggles. Drag-down on the fullscreen player or the down-chevron overlay button minimizes into the minibar. Single source of truth for what is playing is NowPlaying — a process-wide StateFlow refreshed by whichever surface calls setPlayingFrom. Custom MediaSource.Factory in the service routes DASH/HLS/progressive by MIME, and merges video+audio progressives via a side-channel EXTRA_AUDIO_URL bundle on the MediaItem. SponsorBlock skip loop is now activity-scoped, hoisted out of PlayerScreen, so segments are skipped in minibar mode too. Downloads tab wired into the drawer. Reads DownloadManager every second, shows status + progress, tap to open, x to remove. Theme: forest-green primary palette replaces the M3 default lavender / NewPipe red. Modern, clean, distinct.
This commit is contained in:
parent
e7d45aa6b4
commit
1be4c4265f
12 changed files with 1067 additions and 494 deletions
|
|
@ -16,6 +16,20 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// 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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") },
|
||||||
|
|
|
||||||
64
strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt
Normal file
64
strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Sulkta green palette for Straw. Replaces the M3 default lavender/red
|
||||||
|
* tints with a clean forest green primary — modern, clean, distinct from
|
||||||
|
* NewPipe / Tubular's red. Same Tonal Palette structure Material 3 uses
|
||||||
|
* internally so all the derived surfaces stay in harmony.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
private val GreenPrimary = Color(0xFF386A1F)
|
||||||
|
private val GreenOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
private val GreenPrimaryContainer = Color(0xFFB6F397)
|
||||||
|
private val GreenOnPrimaryContainer = Color(0xFF0B2000)
|
||||||
|
private val GreenSecondary = Color(0xFF55624C)
|
||||||
|
private val GreenOnSecondary = Color(0xFFFFFFFF)
|
||||||
|
private val GreenSecondaryContainer = Color(0xFFD8E7CB)
|
||||||
|
private val GreenOnSecondaryContainer = Color(0xFF131F0D)
|
||||||
|
private val GreenTertiary = Color(0xFF386666)
|
||||||
|
private val GreenOnTertiary = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
private val DarkGreenPrimary = Color(0xFF9BD67D)
|
||||||
|
private val DarkGreenOnPrimary = Color(0xFF153800)
|
||||||
|
private val DarkGreenPrimaryContainer = Color(0xFF205107)
|
||||||
|
private val DarkGreenOnPrimaryContainer = Color(0xFFB6F397)
|
||||||
|
private val DarkGreenSecondary = Color(0xFFBDCBB0)
|
||||||
|
private val DarkGreenOnSecondary = Color(0xFF283420)
|
||||||
|
private val DarkGreenSecondaryContainer = Color(0xFF3E4A35)
|
||||||
|
private val DarkGreenOnSecondaryContainer = Color(0xFFD8E7CB)
|
||||||
|
private val DarkGreenTertiary = Color(0xFFA0CFD0)
|
||||||
|
private val DarkGreenOnTertiary = Color(0xFF003738)
|
||||||
|
|
||||||
|
fun strawLightColors(): ColorScheme = lightColorScheme(
|
||||||
|
primary = GreenPrimary,
|
||||||
|
onPrimary = GreenOnPrimary,
|
||||||
|
primaryContainer = GreenPrimaryContainer,
|
||||||
|
onPrimaryContainer = GreenOnPrimaryContainer,
|
||||||
|
secondary = GreenSecondary,
|
||||||
|
onSecondary = GreenOnSecondary,
|
||||||
|
secondaryContainer = GreenSecondaryContainer,
|
||||||
|
onSecondaryContainer = GreenOnSecondaryContainer,
|
||||||
|
tertiary = GreenTertiary,
|
||||||
|
onTertiary = GreenOnTertiary,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun strawDarkColors(): ColorScheme = darkColorScheme(
|
||||||
|
primary = DarkGreenPrimary,
|
||||||
|
onPrimary = DarkGreenOnPrimary,
|
||||||
|
primaryContainer = DarkGreenPrimaryContainer,
|
||||||
|
onPrimaryContainer = DarkGreenOnPrimaryContainer,
|
||||||
|
secondary = DarkGreenSecondary,
|
||||||
|
onSecondary = DarkGreenOnSecondary,
|
||||||
|
secondaryContainer = DarkGreenSecondaryContainer,
|
||||||
|
onSecondaryContainer = DarkGreenOnSecondaryContainer,
|
||||||
|
tertiary = DarkGreenTertiary,
|
||||||
|
onTertiary = DarkGreenOnTertiary,
|
||||||
|
)
|
||||||
|
|
@ -64,19 +64,13 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Downloads tab — lists everything Phase R's Downloader handed off to
|
||||||
|
* Android's DownloadManager. Reads live from DownloadManager.query()
|
||||||
|
* keyed by package owner, so we naturally show only this app's queue.
|
||||||
|
*
|
||||||
|
* Row shows: title, kind (audio / video), state (running / completed /
|
||||||
|
* failed), and progress / size. Tap a completed row → ACTION_VIEW
|
||||||
|
* intent to whatever player the user has registered. × removes the
|
||||||
|
* entry from the queue (and the file, per DownloadManager.remove
|
||||||
|
* semantics).
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.feature.download
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
data class DownloadRow(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val localUri: String?,
|
||||||
|
val mediaType: String?,
|
||||||
|
val status: Int,
|
||||||
|
val reason: Int,
|
||||||
|
val bytesSoFar: Long,
|
||||||
|
val totalBytes: Long,
|
||||||
|
) {
|
||||||
|
val progressFraction: Float?
|
||||||
|
get() = if (totalBytes > 0) (bytesSoFar.toFloat() / totalBytes).coerceIn(0f, 1f) else null
|
||||||
|
|
||||||
|
val statusLabel: String
|
||||||
|
get() = when (status) {
|
||||||
|
DownloadManager.STATUS_RUNNING -> "downloading"
|
||||||
|
DownloadManager.STATUS_PENDING -> "pending"
|
||||||
|
DownloadManager.STATUS_PAUSED -> "paused"
|
||||||
|
DownloadManager.STATUS_SUCCESSFUL -> "done"
|
||||||
|
DownloadManager.STATUS_FAILED -> "failed"
|
||||||
|
else -> "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DownloadsScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var rows by remember { mutableStateOf<List<DownloadRow>>(emptyList()) }
|
||||||
|
|
||||||
|
// Poll DownloadManager every second while the screen is visible.
|
||||||
|
// DownloadManager doesn't broadcast progress, so polling is the
|
||||||
|
// standard pattern. Cheap query — single cursor across the app's own
|
||||||
|
// download queue.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
rows = queryDownloads(context)
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Downloads",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"${rows.size} item${if (rows.size == 1) "" else "s"} · saved to app private storage",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Nothing here yet. Tap Download on any video.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn {
|
||||||
|
items(rows, key = { it.id }) { row ->
|
||||||
|
DownloadRowView(row, context, onRemove = {
|
||||||
|
runCatching {
|
||||||
|
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
|
||||||
|
.remove(row.id)
|
||||||
|
}
|
||||||
|
rows = rows.filterNot { it.id == row.id }
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DownloadRowView(
|
||||||
|
row: DownloadRow,
|
||||||
|
context: Context,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
val openable = row.status == DownloadManager.STATUS_SUCCESSFUL && !row.localUri.isNullOrBlank()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = openable) {
|
||||||
|
row.localUri?.let { uri ->
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(Uri.parse(uri), row.mediaType ?: "*/*")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 56.dp, height = 56.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(if (row.mediaType?.startsWith("audio") == true) "🎵" else "🎬")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
row.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
append(row.statusLabel)
|
||||||
|
if (row.totalBytes > 0) {
|
||||||
|
append(" · ")
|
||||||
|
append(formatBytes(row.bytesSoFar))
|
||||||
|
append(" / ")
|
||||||
|
append(formatBytes(row.totalBytes))
|
||||||
|
} else if (row.bytesSoFar > 0) {
|
||||||
|
append(" · ")
|
||||||
|
append(formatBytes(row.bytesSoFar))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
row.progressFraction?.takeIf { row.status != DownloadManager.STATUS_SUCCESSFUL }
|
||||||
|
?.let { p ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { p },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = onRemove) { Text("×") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryDownloads(context: Context): List<DownloadRow> {
|
||||||
|
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
|
||||||
|
?: return emptyList()
|
||||||
|
val query = DownloadManager.Query()
|
||||||
|
val out = mutableListOf<DownloadRow>()
|
||||||
|
runCatching { dm.query(query) }.getOrNull()?.use { c ->
|
||||||
|
val idIdx = c.getColumnIndex(DownloadManager.COLUMN_ID)
|
||||||
|
val titleIdx = c.getColumnIndex(DownloadManager.COLUMN_TITLE)
|
||||||
|
val uriIdx = c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||||
|
val mimeIdx = c.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)
|
||||||
|
val statusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||||
|
val reasonIdx = c.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||||
|
val soFarIdx = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||||
|
val totalIdx = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
out += DownloadRow(
|
||||||
|
id = c.getLong(idIdx),
|
||||||
|
title = c.getString(titleIdx) ?: "(no title)",
|
||||||
|
localUri = c.getString(uriIdx),
|
||||||
|
mediaType = c.getString(mimeIdx),
|
||||||
|
status = c.getInt(statusIdx),
|
||||||
|
reason = c.getInt(reasonIdx),
|
||||||
|
bytesSoFar = c.getLong(soFarIdx),
|
||||||
|
totalBytes = c.getLong(totalIdx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sortedByDescending { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBytes(b: Long): String = when {
|
||||||
|
b < 1024 -> "$b B"
|
||||||
|
b < 1024L * 1024 -> "${b / 1024} KB"
|
||||||
|
b < 1024L * 1024 * 1024 -> "%.1f MB".format(b.toDouble() / (1024 * 1024))
|
||||||
|
else -> "%.2f GB".format(b.toDouble() / (1024L * 1024 * 1024))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* The minibar: a thin persistent strip pinned to the bottom of every
|
||||||
|
* non-Player screen whenever a video is loaded into the MediaController.
|
||||||
|
* Tap to expand back to fullscreen. The × clears playback and dismisses.
|
||||||
|
*
|
||||||
|
* The actual player + audio lives in PlaybackService — this composable
|
||||||
|
* is purely UI on top of the MediaController. Pause/play toggles the
|
||||||
|
* controller, which is the same player feeding the fullscreen surface
|
||||||
|
* and the inline detail player. There is only ever one player.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.feature.player
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MinibarOverlay(
|
||||||
|
onExpand: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val controller = LocalStrawController.current
|
||||||
|
val item by NowPlaying.current.collectAsStateWithLifecycle()
|
||||||
|
if (controller == null || item == null) return
|
||||||
|
val cur = item ?: return
|
||||||
|
|
||||||
|
// Reflect the controller's play state in the play/pause icon. Listening
|
||||||
|
// is the only reliable way; isPlaying snapshots stale between events.
|
||||||
|
var isPlaying by remember { mutableStateOf(controller.isPlaying) }
|
||||||
|
DisposableEffect(controller) {
|
||||||
|
val listener = object : Player.Listener {
|
||||||
|
override fun onIsPlayingChanged(playing: Boolean) {
|
||||||
|
isPlaying = playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.addListener(listener)
|
||||||
|
isPlaying = controller.isPlaying
|
||||||
|
onDispose { controller.removeListener(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp)
|
||||||
|
.clickable(onClick = onExpand),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = cur.thumbnail,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 80.dp, height = 48.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(Color.Black),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
cur.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
cur.uploader,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
MinibarIconButton(label = if (isPlaying) "⏸" else "▶") {
|
||||||
|
if (controller.isPlaying) controller.pause() else controller.play()
|
||||||
|
}
|
||||||
|
MinibarIconButton(label = "×") {
|
||||||
|
controller.stop()
|
||||||
|
controller.clearMediaItems()
|
||||||
|
NowPlaying.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MinibarIconButton(label: String, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Singleton "currently active video" state — drives the minibar overlay
|
||||||
|
* and tells screens whether their video matches what's playing. Updated
|
||||||
|
* by whichever surface starts playback (VideoDetail tap, Player Play
|
||||||
|
* button, playlist item tap). Cleared by the minibar's × button.
|
||||||
|
*
|
||||||
|
* Why a process-wide singleton instead of a ViewModel: the minibar is
|
||||||
|
* rendered at the activity layout level and needs to outlive any
|
||||||
|
* specific Screen.* composable. Same shape as Subscriptions / Playlists
|
||||||
|
* — runtime-only here since there's no persistence (session-scoped).
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.feature.player
|
||||||
|
|
||||||
|
import com.sulkta.straw.net.SbSegment
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
data class NowPlayingItem(
|
||||||
|
val streamUrl: String,
|
||||||
|
val title: String,
|
||||||
|
val uploader: String,
|
||||||
|
val thumbnail: String?,
|
||||||
|
val segments: List<SbSegment> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
object NowPlaying {
|
||||||
|
private val _current = MutableStateFlow<NowPlayingItem?>(null)
|
||||||
|
val current: StateFlow<NowPlayingItem?> = _current.asStateFlow()
|
||||||
|
|
||||||
|
fun set(item: NowPlayingItem?) {
|
||||||
|
_current.value = item
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_current.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,51 +2,46 @@
|
||||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
* SPDX-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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Composable bridge to the PlaybackService MediaController.
|
||||||
|
*
|
||||||
|
* Why this file exists: every UI surface in Straw (inline player on the
|
||||||
|
* detail screen, the fullscreen Player, the minibar overlay) renders the
|
||||||
|
* same single underlying MediaController. We expose it via a
|
||||||
|
* CompositionLocal so the screens don't have to know how to connect.
|
||||||
|
*
|
||||||
|
* The controller is built async — SessionToken bind happens on a
|
||||||
|
* background thread, the controller future resolves once the service is
|
||||||
|
* up. Until then `LocalStrawController.current` is null; consumers
|
||||||
|
* should render placeholder UI in that brief window.
|
||||||
|
*
|
||||||
|
* Lifecycle: tied to the activity's composition. When the activity
|
||||||
|
* finishes the DisposableEffect cleanup releases the future. The
|
||||||
|
* MediaSessionService stays alive iff there's still something playing
|
||||||
|
* (its own onTaskRemoved + STATE_ENDED logic handles that).
|
||||||
|
*
|
||||||
|
* Also: a small helper, [setPlayingFrom], that knows how to convert
|
||||||
|
* Straw's domain ResolvedPlayback (DASH URL / HLS URL / combined URL /
|
||||||
|
* video+audio pair) into a single MediaItem the service understands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.feature.player
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import androidx.media3.session.SessionToken
|
||||||
|
import com.google.common.util.concurrent.MoreExecutors
|
||||||
|
|
||||||
|
val LocalStrawController = compositionLocalOf<MediaController?> { null }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberStrawController(): MediaController? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state = remember { mutableStateOf<MediaController?>(null) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||||
|
val future = MediaController.Builder(context, token).buildAsync()
|
||||||
|
future.addListener({
|
||||||
|
// future.get() throws if the build failed; treat as null in that case.
|
||||||
|
state.value = runCatching { future.get() }.getOrNull()
|
||||||
|
}, MoreExecutors.directExecutor())
|
||||||
|
onDispose {
|
||||||
|
MediaController.releaseFuture(future)
|
||||||
|
state.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a resolved video into the controller and update NowPlaying.
|
||||||
|
*
|
||||||
|
* Stream-shape preference matches the previous activity-side picker:
|
||||||
|
* DASH (full quality + adaptive) > HLS > combined progressive > merged
|
||||||
|
* video+audio progressives > video-only progressive. The
|
||||||
|
* [StrawMediaSourceFactory] on the service end picks the right inner
|
||||||
|
* MediaSource based on MIME + the EXTRA_AUDIO_URL bundle.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
fun MediaController.setPlayingFrom(
|
||||||
|
streamUrl: String,
|
||||||
|
title: String,
|
||||||
|
uploader: String,
|
||||||
|
thumbnail: String?,
|
||||||
|
resolved: ResolvedPlayback,
|
||||||
|
startPositionMs: Long = 0L,
|
||||||
|
) {
|
||||||
|
val item = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
|
||||||
|
setMediaItem(item, startPositionMs)
|
||||||
|
prepare()
|
||||||
|
playWhenReady = true
|
||||||
|
NowPlaying.set(
|
||||||
|
NowPlayingItem(
|
||||||
|
streamUrl = streamUrl,
|
||||||
|
title = title,
|
||||||
|
uploader = uploader,
|
||||||
|
thumbnail = thumbnail,
|
||||||
|
segments = resolved.segments,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
private fun buildMediaItem(
|
||||||
|
title: String,
|
||||||
|
uploader: String,
|
||||||
|
thumbnail: String?,
|
||||||
|
r: ResolvedPlayback,
|
||||||
|
): MediaItem? {
|
||||||
|
val metadata = MediaMetadata.Builder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setArtist(uploader)
|
||||||
|
.apply {
|
||||||
|
thumbnail?.let { setArtworkUri(android.net.Uri.parse(it)) }
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
val baseBuilder = MediaItem.Builder().setMediaMetadata(metadata)
|
||||||
|
return when {
|
||||||
|
!r.dashMpdUrl.isNullOrBlank() -> baseBuilder
|
||||||
|
.setUri(r.dashMpdUrl)
|
||||||
|
.setMimeType(MimeTypes.APPLICATION_MPD)
|
||||||
|
.build()
|
||||||
|
!r.hlsUrl.isNullOrBlank() -> baseBuilder
|
||||||
|
.setUri(r.hlsUrl)
|
||||||
|
.setMimeType(MimeTypes.APPLICATION_M3U8)
|
||||||
|
.build()
|
||||||
|
!r.combinedUrl.isNullOrBlank() -> baseBuilder
|
||||||
|
.setUri(r.combinedUrl)
|
||||||
|
.build()
|
||||||
|
!r.videoUrl.isNullOrBlank() && !r.audioUrl.isNullOrBlank() -> {
|
||||||
|
val extras = Bundle().apply {
|
||||||
|
putString(PlaybackService.EXTRA_AUDIO_URL, r.audioUrl)
|
||||||
|
}
|
||||||
|
baseBuilder
|
||||||
|
.setUri(r.videoUrl)
|
||||||
|
.setRequestMetadata(
|
||||||
|
MediaItem.RequestMetadata.Builder()
|
||||||
|
.setExtras(extras)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
!r.videoUrl.isNullOrBlank() -> baseBuilder
|
||||||
|
.setUri(r.videoUrl)
|
||||||
|
.build()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue