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:
Sulkta 2026-05-24 03:44:54 -07:00
parent 7c31ced8e3
commit 1fcc46862c
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" const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw // Sulkta fork — Straw
const val STRAW_VERSION_CODE = 2 const val STRAW_VERSION_CODE = 3
const val STRAW_VERSION_NAME = "0.1.0-N" const val STRAW_VERSION_NAME = "0.1.0-O"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -88,6 +88,9 @@ class StrawActivity : ComponentActivity() {
onOpenChannel = { url, name -> onOpenChannel = { url, name ->
nav.push(Screen.Channel(url, name)) nav.push(Screen.Channel(url, name))
}, },
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
) )
is Screen.Channel -> ChannelScreen( is Screen.Channel -> ChannelScreen(
channelUrl = s.channelUrl, 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."), 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 PREFS = "straw_settings"
private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_SB_CATS = "sb_categories_v1"
private const val KEY_MAX_RES = "max_resolution_v1"
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -37,6 +47,9 @@ class SettingsStore(context: Context) {
private val _sbCategories = MutableStateFlow(loadCategories()) private val _sbCategories = MutableStateFlow(loadCategories())
val sbCategories: StateFlow<Set<SbCategory>> = _sbCategories.asStateFlow() val sbCategories: StateFlow<Set<SbCategory>> = _sbCategories.asStateFlow()
private val _maxResolution = MutableStateFlow(loadMaxResolution())
val maxResolution: StateFlow<MaxResolution> = _maxResolution.asStateFlow()
fun toggle(cat: SbCategory) { fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur -> 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() 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> { private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null) val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) { return if (raw == null) {
@ -54,6 +72,11 @@ class SettingsStore(context: Context) {
raw.mapNotNull { key -> SbCategory.entries.firstOrNull { it.key == key } }.toSet() 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 { object Settings {

View file

@ -56,6 +56,7 @@ fun VideoDetailScreen(
initialTitle: String, initialTitle: String,
onPlay: () -> Unit, onPlay: () -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit, onOpenChannel: (channelUrl: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
vm: VideoDetailViewModel = viewModel(), vm: VideoDetailViewModel = viewModel(),
) { ) {
val state by vm.ui.collectAsStateWithLifecycle() val state by vm.ui.collectAsStateWithLifecycle()
@ -185,8 +186,69 @@ fun VideoDetailScreen(
text = stripHtml(d.description.take(20_000)).take(2000), text = stripHtml(d.description.take(20_000)).take(2000),
style = MaterialTheme.typography.bodySmall, 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail( data class VideoDetail(
val id: String, val id: String,
@ -32,6 +33,7 @@ data class VideoDetail(
val thumbnail: String?, val thumbnail: String?,
val ryd: RydVotes? = null, val ryd: RydVotes? = null,
val sbSegmentCount: Int = 0, val sbSegmentCount: Int = 0,
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
) )
data class VideoDetailUiState( data class VideoDetailUiState(
@ -82,6 +84,20 @@ class VideoDetailViewModel : ViewModel() {
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) { val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0) 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( _ui.value = VideoDetailUiState(
loading = false, loading = false,
detail = VideoDetail( detail = VideoDetail(
@ -94,6 +110,7 @@ class VideoDetailViewModel : ViewModel() {
thumbnail = thumb, thumbnail = thumb,
ryd = ryd, ryd = ryd,
sbSegmentCount = sbCount, sbSegmentCount = sbCount,
related = related,
), ),
streamInfo = info, streamInfo = info,
) )

View file

@ -7,6 +7,7 @@ package com.sulkta.straw.feature.player
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient 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 val combined = info.videoStreams
?.filter { it.content?.isNotBlank() == true } ?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes }
?.maxByOrNull { it.bitrate ?: 0 } ?.maxByOrNull { it.bitrate ?: 0 }
?.content ?.content
val videoOnly = info.videoOnlyStreams val videoOnly = info.videoOnlyStreams
?.filter { it.content?.isNotBlank() == true } ?.filter { it.content?.isNotBlank() == true && heightOf(it.getResolution()) <= maxRes }
?.maxByOrNull { it.bitrate ?: 0 } ?.maxByOrNull { it.bitrate ?: 0 }
?.content ?.content
val audioOnly = info.audioStreams val audioOnly = info.audioStreams

View file

@ -20,6 +20,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.SbCategory import com.sulkta.straw.data.SbCategory
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
@ -71,6 +75,38 @@ fun SettingsScreen() {
HorizontalDivider() 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)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
"History", "History",