Straw phase H: Settings screen + SponsorBlock category toggles
New: SettingsStore (SharedPreferences-lite, same pattern as HistoryStore) exposes a Set<SbCategory> StateFlow. 7 SponsorBlock categories surfaced as toggle rows in a new Settings screen: sponsor (on by default), selfpromo, intro, outro, interaction (reminders), music_offtopic (talking in music videos), filler. Wiring: - StrawApp.onCreate: Settings.init(this) - StrawHome: new Settings gear icon next to "straw v0.1.0" header. Tap routes to Screen.Settings. - PlayerViewModel.resolve: reads Settings.get().sbCategories on resolve. Empty set = SponsorBlock skip disabled (no API call). Material-icons-core dep added (1.7.5) for the Icons.Filled.Settings glyph. Day-4 ideas: theme override, default audio-only playback, preferred quality, clear history buttons.
This commit is contained in:
parent
0343959c95
commit
15b81303e2
8 changed files with 198 additions and 5 deletions
|
|
@ -81,6 +81,7 @@ dependencies {
|
||||||
implementation(libs.jetbrains.compose.foundation)
|
implementation(libs.jetbrains.compose.foundation)
|
||||||
implementation(libs.jetbrains.compose.material3)
|
implementation(libs.jetbrains.compose.material3)
|
||||||
implementation(libs.jetbrains.compose.ui)
|
implementation(libs.jetbrains.compose.ui)
|
||||||
|
implementation("androidx.compose.material:material-icons-core:1.7.5")
|
||||||
|
|
||||||
// Lifecycle + ViewModel for Compose
|
// Lifecycle + ViewModel for Compose
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
|
||||||
sealed interface Screen {
|
sealed interface Screen {
|
||||||
data object Home : Screen
|
data object Home : Screen
|
||||||
data object Search : Screen
|
data object Search : Screen
|
||||||
|
data object Settings : Screen
|
||||||
data class VideoDetail(val streamUrl: String, val title: String) : Screen
|
data class VideoDetail(val streamUrl: String, val title: String) : Screen
|
||||||
data class Player(val streamUrl: String, val title: String) : Screen
|
data class Player(val streamUrl: String, val title: String) : Screen
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
|
||||||
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
||||||
import com.sulkta.straw.feature.player.PlayerScreen
|
import com.sulkta.straw.feature.player.PlayerScreen
|
||||||
import com.sulkta.straw.feature.search.SearchScreen
|
import com.sulkta.straw.feature.search.SearchScreen
|
||||||
|
import com.sulkta.straw.feature.settings.SettingsScreen
|
||||||
|
|
||||||
private val YT_HOSTS = setOf("youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be")
|
private val YT_HOSTS = setOf("youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be")
|
||||||
private val YT_URL_RE = Regex("https?://(?:www\\.|m\\.)?(?:youtube\\.com/[^\\s]+|youtu\\.be/[A-Za-z0-9_-]+)")
|
private val YT_URL_RE = Regex("https?://(?:www\\.|m\\.)?(?:youtube\\.com/[^\\s]+|youtu\\.be/[A-Za-z0-9_-]+)")
|
||||||
|
|
@ -57,10 +58,12 @@ class StrawActivity : ComponentActivity() {
|
||||||
when (val s = nav.current) {
|
when (val s = nav.current) {
|
||||||
is Screen.Home -> StrawHome(
|
is Screen.Home -> StrawHome(
|
||||||
onOpenSearch = { nav.push(Screen.Search) },
|
onOpenSearch = { nav.push(Screen.Search) },
|
||||||
|
onOpenSettings = { nav.push(Screen.Settings) },
|
||||||
onOpenVideo = { url, title ->
|
onOpenVideo = { url, title ->
|
||||||
nav.push(Screen.VideoDetail(url, title))
|
nav.push(Screen.VideoDetail(url, title))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
is Screen.Settings -> SettingsScreen()
|
||||||
is Screen.Search -> SearchScreen(
|
is Screen.Search -> SearchScreen(
|
||||||
onOpenVideo = { url, title ->
|
onOpenVideo = { url, title ->
|
||||||
nav.push(Screen.VideoDetail(url, title))
|
nav.push(Screen.VideoDetail(url, title))
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ package com.sulkta.straw
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
import org.schabi.newpipe.extractor.localization.ContentCountry
|
import org.schabi.newpipe.extractor.localization.ContentCountry
|
||||||
|
|
@ -21,5 +22,6 @@ class StrawApp : Application() {
|
||||||
ContentCountry("US"),
|
ContentCountry("US"),
|
||||||
)
|
)
|
||||||
History.init(this)
|
History.init(this)
|
||||||
|
Settings.init(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,12 @@ import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
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
|
||||||
|
|
@ -39,6 +43,7 @@ import com.sulkta.straw.data.WatchHistoryItem
|
||||||
@Composable
|
@Composable
|
||||||
fun StrawHome(
|
fun StrawHome(
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
onOpenVideo: (url: String, title: String) -> Unit,
|
onOpenVideo: (url: String, title: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val watches by History.get().watches.collectAsState()
|
val watches by History.get().watches.collectAsState()
|
||||||
|
|
@ -48,7 +53,10 @@ fun StrawHome(
|
||||||
) {
|
) {
|
||||||
// Header
|
// Header
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "straw",
|
text = "straw",
|
||||||
style = MaterialTheme.typography.displayMedium,
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
|
@ -59,7 +67,11 @@ fun StrawHome(
|
||||||
text = "v0.1.0",
|
text = "v0.1.0",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Button(
|
Button(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Sulkta Straw — user settings (SharedPreferences-lite).
|
||||||
|
* Day-3 starts with SponsorBlock category selection. Other settings
|
||||||
|
* (theme override, default player quality, watch-as-audio default) land
|
||||||
|
* here later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/** SponsorBlock category keys — must match server-side IDs. */
|
||||||
|
enum class SbCategory(val key: String, val label: String, val help: String) {
|
||||||
|
Sponsor("sponsor", "Sponsor", "Paid promotion / ad reads — skipped by default."),
|
||||||
|
SelfPromo("selfpromo", "Self-promo", "\"Like and subscribe\", merch plugs."),
|
||||||
|
Intro("intro", "Intro", "Channel logos / theme music."),
|
||||||
|
Outro("outro", "Outro", "End cards, credits."),
|
||||||
|
Interaction("interaction", "Reminder", "\"Don't forget to subscribe\" mid-roll."),
|
||||||
|
MusicOfftopic("music_offtopic", "Non-music in music vid", "Talking sections in a music video."),
|
||||||
|
Filler("filler", "Filler / tangent", "Pulled out of the flow — opt-in only."),
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PREFS = "straw_settings"
|
||||||
|
private const val KEY_SB_CATS = "sb_categories_v1"
|
||||||
|
|
||||||
|
class SettingsStore(context: Context) {
|
||||||
|
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _sbCategories = MutableStateFlow(loadCategories())
|
||||||
|
val sbCategories: StateFlow<Set<SbCategory>> = _sbCategories.asStateFlow()
|
||||||
|
|
||||||
|
fun toggle(cat: SbCategory) {
|
||||||
|
val cur = _sbCategories.value
|
||||||
|
val next = if (cat in cur) cur - cat else cur + cat
|
||||||
|
_sbCategories.value = next
|
||||||
|
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCategories(): Set<SbCategory> {
|
||||||
|
val raw = sp.getStringSet(KEY_SB_CATS, null)
|
||||||
|
return if (raw == null) {
|
||||||
|
// Default: sponsor only.
|
||||||
|
setOf(SbCategory.Sponsor)
|
||||||
|
} else {
|
||||||
|
raw.mapNotNull { key -> SbCategory.entries.firstOrNull { it.key == key } }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Settings {
|
||||||
|
@Volatile private var instance: SettingsStore? = null
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (instance == null) instance = SettingsStore(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(): SettingsStore = instance
|
||||||
|
?: error("SettingsStore not initialized — call Settings.init(context)")
|
||||||
|
}
|
||||||
|
|
@ -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.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
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -42,15 +43,20 @@ class PlayerViewModel : ViewModel() {
|
||||||
private val _ui = MutableStateFlow(PlayerUiState())
|
private val _ui = MutableStateFlow(PlayerUiState())
|
||||||
val ui: StateFlow<PlayerUiState> = _ui.asStateFlow()
|
val ui: StateFlow<PlayerUiState> = _ui.asStateFlow()
|
||||||
|
|
||||||
fun resolve(streamUrl: String, sbCategories: List<String> = listOf("sponsor")) {
|
fun resolve(streamUrl: String) {
|
||||||
_ui.value = PlayerUiState(loading = true)
|
_ui.value = PlayerUiState(loading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
|
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
|
||||||
val videoId = info.id
|
val videoId = info.id
|
||||||
val segments = withContext(Dispatchers.IO) {
|
val sbCategories = Settings.get().sbCategories.value.map { it.key }
|
||||||
runCatching { SponsorBlockClient.fetch(videoId, sbCategories) }
|
val segments = if (sbCategories.isEmpty()) {
|
||||||
.getOrDefault(emptyList())
|
emptyList()
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching { SponsorBlockClient.fetch(videoId, sbCategories) }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val combined = info.videoStreams
|
val combined = info.videoStreams
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.feature.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.sulkta.straw.data.SbCategory
|
||||||
|
import com.sulkta.straw.data.Settings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen() {
|
||||||
|
val store = Settings.get()
|
||||||
|
val cats by store.sbCategories.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Settings",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"SponsorBlock — auto-skip categories",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"Segments tagged by SponsorBlock are skipped during playback. Toggle which categories you want.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
SbCategory.entries.forEach { cat ->
|
||||||
|
CategoryRow(
|
||||||
|
cat = cat,
|
||||||
|
enabled = cat in cats,
|
||||||
|
onToggle = { store.toggle(cat) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CategoryRow(
|
||||||
|
cat: SbCategory,
|
||||||
|
enabled: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(cat.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
cat.help,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = enabled, onCheckedChange = { onToggle() })
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue