From fa97b698fe4999462609c00f050ee674f966bd2b Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 03:44:54 -0700 Subject: [PATCH] Straw phase O: related videos + max-resolution picker (v0.1.0-O / vc=3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VideoDetail screen: - New "Related" section at the bottom — pulls StreamInfo.relatedItems, filters to StreamInfoItem, renders as inline thumbnail rows. Tap → push another VideoDetail. Up to 20 items shown. Each row uses bestThumbnail() for hi-res. Settings screen + PlayerViewModel: - New "Playback" section with a Max-Resolution picker: Auto / 1080p / 720p / 480p / 360p / 144p. Persisted to SharedPreferences (KEY_MAX_RES) via SettingsStore.maxResolution StateFlow. - PlayerViewModel.resolve filters videoStreams + videoOnlyStreams by the ceiling before picking the max-bitrate one. Auto (Int.MAX_VALUE) is unchanged behavior. Choosing 720p caps the renderer so 1080p/4K streams are skipped — saves bandwidth on mobile + helps low-end decoders. Phase P next ideas: bottom navigation tabs (Home / Subs feed / Library), Download (audio + video), the MediaSessionService refactor for true background audio after activity death. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../kotlin/com/sulkta/straw/StrawActivity.kt | 3 + .../com/sulkta/straw/data/SettingsStore.kt | 23 +++++++ .../straw/feature/detail/VideoDetailScreen.kt | 62 +++++++++++++++++++ .../feature/detail/VideoDetailViewModel.kt | 17 +++++ .../straw/feature/player/PlayerViewModel.kt | 9 ++- .../straw/feature/settings/SettingsScreen.kt | 36 +++++++++++ 7 files changed, 150 insertions(+), 4 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index cdfaaf1e4..1cea030d0 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 = 2 -const val STRAW_VERSION_NAME = "0.1.0-N" +const val STRAW_VERSION_CODE = 3 +const val STRAW_VERSION_NAME = "0.1.0-O" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 50f53a227..670db54e3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -88,6 +88,9 @@ class StrawActivity : ComponentActivity() { onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) }, + onOpenVideo = { url, title -> + nav.push(Screen.VideoDetail(url, title)) + }, ) is Screen.Channel -> ChannelScreen( channelUrl = s.channelUrl, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index a35a27130..484cfdfe8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -28,8 +28,18 @@ enum class SbCategory(val key: String, val label: String, val help: String) { Filler("filler", "Filler / tangent", "Pulled out of the flow — opt-in only."), } +enum class MaxResolution(val label: String, val ceiling: Int) { + Auto("Auto (best)", Int.MAX_VALUE), + P1080("1080p", 1080), + P720("720p", 720), + P480("480p", 480), + P360("360p", 360), + P144("144p", 144), +} + private const val PREFS = "straw_settings" private const val KEY_SB_CATS = "sb_categories_v1" +private const val KEY_MAX_RES = "max_resolution_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -37,6 +47,9 @@ class SettingsStore(context: Context) { private val _sbCategories = MutableStateFlow(loadCategories()) val sbCategories: StateFlow> = _sbCategories.asStateFlow() + private val _maxResolution = MutableStateFlow(loadMaxResolution()) + val maxResolution: StateFlow = _maxResolution.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -45,6 +58,11 @@ class SettingsStore(context: Context) { sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply() } + fun setMaxResolution(r: MaxResolution) { + _maxResolution.value = r + sp.edit().putString(KEY_MAX_RES, r.name).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { @@ -54,6 +72,11 @@ class SettingsStore(context: Context) { raw.mapNotNull { key -> SbCategory.entries.firstOrNull { it.key == key } }.toSet() } } + + private fun loadMaxResolution(): MaxResolution { + val name = sp.getString(KEY_MAX_RES, null) ?: return MaxResolution.Auto + return MaxResolution.entries.firstOrNull { it.name == name } ?: MaxResolution.Auto + } } object Settings { 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 1d0d40155..f85a21d52 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 @@ -56,6 +56,7 @@ fun VideoDetailScreen( initialTitle: String, onPlay: () -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit, + onOpenVideo: (url: String, title: String) -> Unit, vm: VideoDetailViewModel = viewModel(), ) { val state by vm.ui.collectAsStateWithLifecycle() @@ -185,8 +186,69 @@ fun VideoDetailScreen( text = stripHtml(d.description.take(20_000)).take(2000), style = MaterialTheme.typography.bodySmall, ) + + if (d.related.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + "Related", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + d.related.take(20).forEach { rel -> + RelatedRow(rel) { onOpenVideo(rel.url, rel.title) } + androidx.compose.material3.HorizontalDivider() + } + } } } } } +@Composable +private fun RelatedRow( + item: com.sulkta.straw.feature.search.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 = androidx.compose.ui.text.style.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 = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + } +} + diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index d1ce7fa43..4061b6abe 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem data class VideoDetail( val id: String, @@ -32,6 +33,7 @@ data class VideoDetail( val thumbnail: String?, val ryd: RydVotes? = null, val sbSegmentCount: Int = 0, + val related: List = emptyList(), ) data class VideoDetailUiState( @@ -82,6 +84,20 @@ class VideoDetailViewModel : ViewModel() { val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) { runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0) } + val related = info.relatedItems + ?.filterIsInstance() + ?.map { it -> + com.sulkta.straw.feature.search.StreamItem( + url = it.url, + title = it.name ?: "(no title)", + uploader = it.uploaderName ?: "", + uploaderUrl = it.uploaderUrl, + thumbnail = bestThumbnail(it.thumbnails), + durationSeconds = it.duration, + viewCount = it.viewCount, + ) + } ?: emptyList() + _ui.value = VideoDetailUiState( loading = false, detail = VideoDetail( @@ -94,6 +110,7 @@ class VideoDetailViewModel : ViewModel() { thumbnail = thumb, ryd = ryd, sbSegmentCount = sbCount, + related = related, ), streamInfo = info, ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt index 847bfbe6a..c6bf0ba22 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt @@ -7,6 +7,7 @@ package com.sulkta.straw.feature.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sulkta.straw.data.MaxResolution import com.sulkta.straw.data.Settings import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SponsorBlockClient @@ -59,12 +60,16 @@ class PlayerViewModel : ViewModel() { } } + val maxRes = Settings.get().maxResolution.value.ceiling + fun heightOf(q: String?): Int = + q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 + val combined = info.videoStreams - ?.filter { it.content?.isNotBlank() == true } + ?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes } ?.maxByOrNull { it.bitrate ?: 0 } ?.content val videoOnly = info.videoOnlyStreams - ?.filter { it.content?.isNotBlank() == true } + ?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes } ?.maxByOrNull { it.bitrate ?: 0 } ?.content val audioOnly = info.audioStreams diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index fd96ea6e7..b426a9d9e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -20,6 +20,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -28,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.sulkta.straw.data.History +import com.sulkta.straw.data.MaxResolution import com.sulkta.straw.data.SbCategory import com.sulkta.straw.data.Settings @@ -71,6 +75,38 @@ fun SettingsScreen() { HorizontalDivider() } + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Playback", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Max video resolution. \"Auto\" picks the highest available.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + val maxRes by store.maxResolution.collectAsState() + MaxResolution.entries.forEach { r -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { store.setMaxResolution(r) } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (r == maxRes) "• ${r.label}" else " ${r.label}", + style = MaterialTheme.typography.bodyLarge, + color = if (r == maxRes) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + ) + } + HorizontalDivider() + } + Spacer(modifier = Modifier.height(32.dp)) Text( "History",