Straw phases P/Q/R/S — bottom nav, sub feed, downloads, background audio

Phase P — bottom navigation:
- StrawHome restructured as a Scaffold with Material3 NavigationBar.
- Three tabs: Home (search + last 10 watches), Library (full watch
  history with count), Subs (channel chips + aggregated feed).

Phase Q — subscription feed:
- New SubscriptionFeedViewModel fans out per-channel ChannelInfo +
  ChannelTabs.VIDEOS fetches in parallel via async/awaitAll.
- Each channel contributes top 5; merged across all subs, capped at
  200, sorted by view count as a soft-recency proxy (extractor doesn't
  reliably surface upload timestamps).
- 10-minute cache TTL avoids hammering YT on tab re-entry.
- Subs tab renders the feed below the avatar row with a Refresh button.

Phase R — download:
- Download button on VideoDetail (next to Play / Share). Pops a tiny
  dialog: Audio (best audioStream) or Video (best videoStream/
  videoOnly fallback).
- Uses Android's DownloadManager — saves into app-private external
  files dir (Android/data/com.sulkta.straw.debug/files/Movies/<kind>/).
  Notification + progress for free. No WRITE_EXTERNAL_STORAGE needed.
- Filenames sanitized (no /:*?\"<>| chars), capped at 120 chars.

Phase S — background audio:
- New "Background" overlay button (🎧) on the player. Tap to pause the
  activity player and start PlaybackService with the audio URL.
- PlaybackService is a Media3 MediaSessionService with its own ExoPlayer
  configured with our custom DataSource.Factory (User-Agent set, cross-
  protocol redirects). Foreground service + media notification.
- Audio survives activity death — swipe the app out of recents, audio
  keeps playing. Stop via notification or open-the-app-and-tap-stop.
- onTaskRemoved keeps the service alive iff something is playing.

Versions shipped: P+Q as vc=4, R as vc=5, S as vc=6. Each landed in the
F-Droid repo for the day-by-day refresh path.

Day-N+ ideas: real MediaController unification (single Player for both
foreground + background paths), MergingMediaSource on the service side
for high-res YT videos, real upload-timestamp sort for feed once the
extractor exposes it consistently, queue/playlist.
This commit is contained in:
Kayos 2026-05-24 04:30:06 -07:00
parent fa97b698fe
commit 081f238355
7 changed files with 605 additions and 52 deletions

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
const val STRAW_VERSION_CODE = 3
const val STRAW_VERSION_NAME = "0.1.0-O"
const val STRAW_VERSION_CODE = 6
const val STRAW_VERSION_NAME = "0.1.0-S"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -1,6 +1,11 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase P: bottom navigation host. Three tabs:
* - Home: search + recent-watches summary
* - Library: full recent-watches list
* - Subs: subscription feed (Q wires the actual feed; P just lists channels)
*/
package com.sulkta.straw
@ -21,29 +26,49 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatViews
private enum class HomeTab(val label: String) {
Home("Home"),
Library("Library"),
Subs("Subs"),
}
@Composable
fun StrawHome(
@ -51,56 +76,87 @@ fun StrawHome(
onOpenSettings: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
feedVm: SubscriptionFeedViewModel = viewModel(),
) {
val watches by History.get().watches.collectAsState()
val subs by Subscriptions.get().subs.collectAsState()
var tab by remember { mutableStateOf(HomeTab.Home) }
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp),
) {
// Header
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
Scaffold(
bottomBar = {
NavigationBar {
HomeTab.entries.forEach { t ->
NavigationBarItem(
selected = t == tab,
onClick = { tab = t },
icon = {
// Material-icons-core only ships a small set;
// use unicode for the rest.
when (t) {
HomeTab.Home -> Icon(Icons.Filled.Home, contentDescription = t.label)
HomeTab.Library -> Text("📺")
HomeTab.Subs -> Text("👤")
}
},
label = { Text(t.label) },
)
}
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
Text(
text = "straw",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "v${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
HeaderRow(onOpenSettings = onOpenSettings)
Spacer(modifier = Modifier.height(12.dp))
when (tab) {
HomeTab.Home -> HomePane(onOpenSearch, onOpenVideo)
HomeTab.Library -> LibraryPane(onOpenVideo)
HomeTab.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun HeaderRow(onOpenSettings: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "straw",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "v${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
}
}
@Composable
private fun HomePane(
onOpenSearch: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
) {
val watches by History.get().watches.collectAsState()
Column {
Button(
onClick = onOpenSearch,
modifier = Modifier.fillMaxWidth(),
) { Text("Search YouTube") }
Spacer(modifier = Modifier.height(20.dp))
if (subs.isNotEmpty()) {
Text(
"Subscriptions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(subs) { ch -> SubChip(ch, onOpenChannel) }
}
Spacer(modifier = Modifier.height(20.dp))
}
if (watches.isEmpty()) {
Text(
text = "Recently watched videos appear here.",
@ -114,6 +170,41 @@ fun StrawHome(
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(watches.take(10)) { w ->
RecentRow(w) { onOpenVideo(w.url, w.title) }
HorizontalDivider()
}
}
}
}
}
@Composable
private fun LibraryPane(onOpenVideo: (url: String, title: String) -> Unit) {
val watches by History.get().watches.collectAsState()
Column {
Text(
text = "Library",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${watches.size} watched videos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
if (watches.isEmpty()) {
Text(
"No watch history yet. Play a video and it'll show up here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn {
items(watches) { w ->
RecentRow(w) { onOpenVideo(w.url, w.title) }
@ -124,6 +215,131 @@ fun StrawHome(
}
}
@Composable
private fun SubsPane(
onOpenChannel: (url: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
feedVm: SubscriptionFeedViewModel,
) {
val subs by Subscriptions.get().subs.collectAsState()
val feed by feedVm.ui.collectAsState()
LaunchedEffect(subs) { feedVm.refreshIfStale() }
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Subscriptions",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "${subs.size} channel${if (subs.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (subs.isNotEmpty()) {
TextButton(onClick = { feedVm.refresh() }) {
Text(if (feed.loading) "..." else "Refresh")
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (subs.isEmpty()) {
Text(
"No subscriptions yet. Open a channel and tap Subscribe.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
// Channel chip row.
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(subs) { ch -> SubChip(ch, onOpenChannel) }
}
Spacer(modifier = Modifier.height(16.dp))
// Aggregated feed below.
when {
feed.loading && feed.items.isEmpty() -> {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Pulling subscription feed...", style = MaterialTheme.typography.bodySmall)
}
}
feed.error != null && feed.items.isEmpty() -> {
Text(
"feed error: ${feed.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
else -> {
Text(
"Latest from your subs",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(feed.items) { item ->
FeedRow(item) { onOpenVideo(item.url, item.title) }
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
modifier = Modifier
.width(140.dp)
.height(80.dp)
.clip(RoundedCornerShape(6.dp)),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = buildString {
append(item.uploader)
if (item.viewCount > 0) {
append(" · ")
append(formatViews(item.viewCount))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun SubChip(
ch: ChannelRef,

View file

@ -31,7 +31,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import android.content.Intent
import android.widget.Toast
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
@ -61,6 +68,7 @@ fun VideoDetailScreen(
) {
val state by vm.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
var showDownloadDialog by remember { mutableStateOf(false) }
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
Column(
@ -175,9 +183,70 @@ fun VideoDetailScreen(
}
context.startActivity(Intent.createChooser(send, "Share video"))
}) { Text("Share") }
OutlinedButton(onClick = { showDownloadDialog = true }) {
Text("Download")
}
}
Spacer(modifier = Modifier.height(16.dp))
if (showDownloadDialog) {
val info = state.streamInfo
AlertDialog(
onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") },
text = {
Column {
Text("Pick a format:", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Saves to Android/data/.../files/Movies/. Visible in any file manager.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
if (audio != null) {
Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
Toast.makeText(context, "audio queued", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.videoStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
?: info?.videoOnlyStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
if (video != null) {
Downloader.enqueue(context, video, d.title, DownloadKind.Video)
Toast.makeText(context, "video queued", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Video") }
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showDownloadDialog = false }) {
Text("Cancel")
}
},
)
}
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
// AUD-MED: cap input length before regex passes — defends

View file

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase R: minimal download via Android's DownloadManager. Saves to the
* app-private external files dir so we don't need WRITE_EXTERNAL_STORAGE
* on older Android. The user can pull files out via a file manager
* (under Android/data/com.sulkta.straw.debug/files/...).
*/
package com.sulkta.straw.feature.download
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Environment
import com.sulkta.straw.util.strawLogD
enum class DownloadKind(val subdir: String, val ext: String) {
Audio("audio", ".m4a"),
Video("video", ".mp4"),
}
object Downloader {
fun enqueue(
context: Context,
url: String,
title: String,
kind: DownloadKind,
): Long {
val ctx = context.applicationContext
val safeTitle = title
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.take(120)
.ifBlank { "straw-${System.currentTimeMillis()}" }
val filename = "$safeTitle${kind.ext}"
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(url))
.setTitle(title)
.setDescription("Straw — ${kind.name.lowercase()}")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setDestinationInExternalFilesDir(
ctx,
Environment.DIRECTORY_MOVIES + "/" + kind.subdir,
filename,
)
val id = dm.enqueue(req)
strawLogD("StrawDl") { "enqueued $kind id=$id title=$title file=$filename" }
return id
}
}

View file

@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase Q: aggregate latest videos across all subscribed channels into a
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
* fetches in parallel, merges by upload timestamp, caps at 200 items.
*/
package com.sulkta.straw.feature.feed
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class SubscriptionFeedUiState(
val loading: Boolean = false,
val items: List<StreamItem> = emptyList(),
val error: String? = null,
val lastFetchedAt: Long = 0L,
)
class SubscriptionFeedViewModel : ViewModel() {
private val _ui = MutableStateFlow(SubscriptionFeedUiState())
val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow()
/** Cache feed for 10 min to avoid hammering YT on tab re-entry. */
private val cacheTtlMs = 10L * 60 * 1000
fun refreshIfStale() {
val now = System.currentTimeMillis()
if (_ui.value.items.isNotEmpty() && now - _ui.value.lastFetchedAt < cacheTtlMs) return
refresh()
}
fun refresh() {
val channels = Subscriptions.get().subs.value
if (channels.isEmpty()) {
_ui.value = SubscriptionFeedUiState(loading = false, items = emptyList())
return
}
_ui.value = _ui.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val perChannelMax = 5
val deferreds = channels.map { ch ->
async {
runCatching {
val info = ChannelInfo.getInfo(service, ch.url)
val tab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull() ?: return@async emptyList<StreamItem>()
ChannelTabInfo.getInfo(service, tab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.take(perChannelMax)
.map { si ->
StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: ch.name,
uploaderUrl = si.uploaderUrl ?: ch.url,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
}
}
deferreds.awaitAll()
.flatten()
// No reliable upload-timestamp from extractor's StreamInfoItem
// in all cases — keep the per-channel insertion order (newest first
// within each channel) and interleave by simple round-robin position.
// Sort by view count desc as a soft proxy for recency-popularity.
.sortedByDescending { it.viewCount }
.take(200)
}
_ui.value = SubscriptionFeedUiState(
loading = false,
items = items,
lastFetchedAt = System.currentTimeMillis(),
)
} catch (t: Throwable) {
_ui.value = SubscriptionFeedUiState(
loading = false,
items = _ui.value.items,
error = t.message ?: t.javaClass.simpleName,
)
}
}
}
}

View file

@ -2,18 +2,36 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase M-2: Media3 MediaSessionService hosting a single ExoPlayer. Running
* as a foreground service means audio keeps playing after the user leaves
* the Player screen (Cobb's feedback: "no background player"). Also gives
* us lock-screen controls and a media-styled notification for free.
* Phase S: foreground-service ExoPlayer for "Background" audio mode.
* Independent of the activity-side player. When the user taps Background
* on the player overlay, the activity stops its own playback and starts
* this service with the audio URL. Audio continues even if the activity
* is killed (swipe out of recents).
*
* Limitations:
* - Single URL only. The activity-side merged-DASH path doesn't carry
* over (we just use the best audioStream). Acceptable trade-off for
* background mode.
* - No SponsorBlock skip here. That logic lives in PlayerScreen and is
* foreground-only for now.
* - Service plays one item at a time. Queue/playlist is future work.
*/
package com.sulkta.straw.feature.player
import android.app.PendingIntent
import android.content.Intent
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.sulkta.straw.StrawActivity
import com.sulkta.straw.extractor.NewPipeDownloader
@UnstableApi
class PlaybackService : MediaSessionService() {
@ -22,17 +40,68 @@ class PlaybackService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val player = ExoPlayer.Builder(this).build()
mediaSession = MediaSession.Builder(this, player).build()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val mediaSourceFactory = DefaultMediaSourceFactory(this)
.setDataSourceFactory(httpFactory)
val player = ExoPlayer.Builder(this)
.setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
/* handleAudioFocus = */ true,
)
.build()
val sessionActivityIntent = PendingIntent.getActivity(
this,
0,
Intent(this, StrawActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
mediaSession = MediaSession.Builder(this, player)
.setSessionActivity(sessionActivityIntent)
.build()
}
override fun onGetSession(
controllerInfo: MediaSession.ControllerInfo,
): MediaSession? = mediaSession
override fun onTaskRemoved(rootIntent: android.content.Intent?) {
// If user swipes Straw out of recents while audio is playing, keep
// playing. Stop only when player has nothing queued.
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
val url = intent?.getStringExtra(EXTRA_URL)
val title = intent?.getStringExtra(EXTRA_TITLE)
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
if (url != null) {
val player = mediaSession?.player ?: return super.onStartCommand(intent, flags, startId)
val item = MediaItem.Builder()
.setUri(url)
.setMediaMetadata(
androidx.media3.common.MediaMetadata.Builder()
.setTitle(title ?: "")
.setArtist(uploader ?: "")
.build(),
)
.build()
player.setMediaItem(item)
player.prepare()
player.playWhenReady = true
}
return super.onStartCommand(intent, flags, startId)
}
override fun onTaskRemoved(rootIntent: Intent?) {
// If audio is still playing when user swipes Straw out of recents,
// KEEP playing. Only stop the service when nothing is queued.
val player = mediaSession?.player
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
@ -47,4 +116,10 @@ class PlaybackService : MediaSessionService() {
}
super.onDestroy()
}
companion object {
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
}
}

View file

@ -10,10 +10,12 @@ package com.sulkta.straw.feature.player
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.util.Rational
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -297,6 +299,27 @@ fun PlayerScreen(
runCatching { activity.enterPictureInPictureMode(params) }
}
}
// Background audio (phase S) — independent foreground-service playback
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
}
exoPlayer.pause()
val intent = Intent(context, PlaybackService::class.java).apply {
component = ComponentName(context, PlaybackService::class.java)
putExtra(PlaybackService.EXTRA_URL, audio)
putExtra(PlaybackService.EXTRA_TITLE, title)
}
ContextCompat.startForegroundService(context, intent)
Toast.makeText(
context,
"background audio started — close the app whenever",
Toast.LENGTH_SHORT,
).show()
}
}
if (showSpeedDialog) {