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
7c31ced8e3
commit
1fcc46862c
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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue