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:
parent
fa97b698fe
commit
081f238355
7 changed files with 605 additions and 52 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue