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
1fcc46862c
commit
798c5d1c92
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"
|
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// Sulkta fork — Straw
|
||||||
const val STRAW_VERSION_CODE = 3
|
const val STRAW_VERSION_CODE = 6
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-O"
|
const val STRAW_VERSION_NAME = "0.1.0-S"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Sulkta
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* 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
|
package com.sulkta.straw
|
||||||
|
|
@ -21,29 +26,49 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.Text
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.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.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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 coil3.compose.AsyncImage
|
||||||
import com.sulkta.straw.BuildConfig
|
import com.sulkta.straw.BuildConfig
|
||||||
import com.sulkta.straw.data.ChannelRef
|
import com.sulkta.straw.data.ChannelRef
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.data.WatchHistoryItem
|
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
|
@Composable
|
||||||
fun StrawHome(
|
fun StrawHome(
|
||||||
|
|
@ -51,56 +76,87 @@ fun StrawHome(
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> 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(),
|
||||||
) {
|
) {
|
||||||
val watches by History.get().watches.collectAsState()
|
var tab by remember { mutableStateOf(HomeTab.Home) }
|
||||||
val subs by Subscriptions.get().subs.collectAsState()
|
|
||||||
|
|
||||||
Column(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp),
|
bottomBar = {
|
||||||
) {
|
NavigationBar {
|
||||||
// Header
|
HomeTab.entries.forEach { t ->
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
NavigationBarItem(
|
||||||
Row(
|
selected = t == tab,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = { tab = t },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
HeaderRow(onOpenSettings = onOpenSettings)
|
||||||
text = "straw",
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
style = MaterialTheme.typography.displayMedium,
|
when (tab) {
|
||||||
color = MaterialTheme.colorScheme.primary,
|
HomeTab.Home -> HomePane(onOpenSearch, onOpenVideo)
|
||||||
)
|
HomeTab.Library -> LibraryPane(onOpenVideo)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
HomeTab.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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(
|
Button(
|
||||||
onClick = onOpenSearch,
|
onClick = onOpenSearch,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Search YouTube") }
|
) { Text("Search YouTube") }
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
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()) {
|
if (watches.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Recently watched videos appear here.",
|
text = "Recently watched videos appear here.",
|
||||||
|
|
@ -114,6 +170,41 @@ fun StrawHome(
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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 {
|
LazyColumn {
|
||||||
items(watches) { w ->
|
items(watches) { w ->
|
||||||
RecentRow(w) { onOpenVideo(w.url, w.title) }
|
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
|
@Composable
|
||||||
private fun SubChip(
|
private fun SubChip(
|
||||||
ch: ChannelRef,
|
ch: ChannelRef,
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,14 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import android.content.Intent
|
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 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.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -61,6 +68,7 @@ fun VideoDetailScreen(
|
||||||
) {
|
) {
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
|
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -175,9 +183,70 @@ fun VideoDetailScreen(
|
||||||
}
|
}
|
||||||
context.startActivity(Intent.createChooser(send, "Share video"))
|
context.startActivity(Intent.createChooser(send, "Share video"))
|
||||||
}) { Text("Share") }
|
}) { Text("Share") }
|
||||||
|
OutlinedButton(onClick = { showDownloadDialog = true }) {
|
||||||
|
Text("Download")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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)
|
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
// AUD-MED: cap input length before regex passes — defends
|
// AUD-MED: cap input length before regex passes — defends
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
|
* 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
|
||||||
|
* 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
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* Phase M-2: Media3 MediaSessionService hosting a single ExoPlayer. Running
|
* Phase S: foreground-service ExoPlayer for "Background" audio mode.
|
||||||
* as a foreground service means audio keeps playing after the user leaves
|
* Independent of the activity-side player. When the user taps Background
|
||||||
* the Player screen (a tester's feedback: "no background player"). Also gives
|
* on the player overlay, the activity stops its own playback and starts
|
||||||
* us lock-screen controls and a media-styled notification for free.
|
* 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
|
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.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
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.extractor.NewPipeDownloader
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
|
|
@ -22,17 +40,68 @@ class PlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
val player = ExoPlayer.Builder(this).build()
|
val httpFactory = DefaultHttpDataSource.Factory()
|
||||||
mediaSession = MediaSession.Builder(this, player).build()
|
.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(
|
override fun onGetSession(
|
||||||
controllerInfo: MediaSession.ControllerInfo,
|
controllerInfo: MediaSession.ControllerInfo,
|
||||||
): MediaSession? = mediaSession
|
): MediaSession? = mediaSession
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: android.content.Intent?) {
|
override fun onStartCommand(
|
||||||
// If user swipes Straw out of recents while audio is playing, keep
|
intent: Intent?,
|
||||||
// playing. Stop only when player has nothing queued.
|
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
|
val player = mediaSession?.player
|
||||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
|
@ -47,4 +116,10 @@ class PlaybackService : MediaSessionService() {
|
||||||
}
|
}
|
||||||
super.onDestroy()
|
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.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.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -297,6 +299,27 @@ fun PlayerScreen(
|
||||||
runCatching { activity.enterPictureInPictureMode(params) }
|
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) {
|
if (showSpeedDialog) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue