From 081f23835532a96fed7ce43baa1c5ce7b053333a Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 04:30:06 -0700 Subject: [PATCH] =?UTF-8?q?Straw=20phases=20P/Q/R/S=20=E2=80=94=20bottom?= =?UTF-8?q?=20nav,=20sub=20feed,=20downloads,=20background=20audio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//). 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. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 298 +++++++++++++++--- .../straw/feature/detail/VideoDetailScreen.kt | 69 ++++ .../straw/feature/download/Downloader.kt | 55 ++++ .../feature/feed/SubscriptionFeedViewModel.kt | 115 +++++++ .../straw/feature/player/PlaybackService.kt | 93 +++++- .../straw/feature/player/PlayerScreen.kt | 23 ++ 7 files changed, 605 insertions(+), 52 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 1cea030d0..1d9d2ad77 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index dfee148ab..5575f42c9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index f85a21d52..ee364603b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt new file mode 100644 index 000000000..b3284f5c3 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt @@ -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 + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt new file mode 100644 index 000000000..32b5c2d9b --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -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 = emptyList(), + val error: String? = null, + val lastFetchedAt: Long = 0L, +) + +class SubscriptionFeedViewModel : ViewModel() { + private val _ui = MutableStateFlow(SubscriptionFeedUiState()) + val ui: StateFlow = _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() + ChannelTabInfo.getInfo(service, tab) + .relatedItems + .filterIsInstance() + .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, + ) + } + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 4d2f770a2..a4b118dbe 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -2,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" + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 6ae8fd696..f9fb84830 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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) {