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:
parent
253c5e268b
commit
fa97b698fe
7 changed files with 150 additions and 4 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue