Straw phase O: related videos + max-resolution picker (v0.1.0-O / vc=3)

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.
This commit is contained in:
Kayos 2026-05-24 03:44:54 -07:00
parent 253c5e268b
commit fa97b698fe
7 changed files with 150 additions and 4 deletions

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
const val STRAW_VERSION_CODE = 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"

View file

@ -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,

View file

@ -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<Set<SbCategory>> = _sbCategories.asStateFlow()
private val _maxResolution = MutableStateFlow(loadMaxResolution())
val maxResolution: StateFlow<MaxResolution> = _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<SbCategory> {
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 {

View file

@ -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,
)
}
}
}

View file

@ -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<com.sulkta.straw.feature.search.StreamItem> = 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<StreamInfoItem>()
?.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,
)

View file

@ -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

View file

@ -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",