Sulkta day-2: search → detail → player → SponsorBlock + RYD

Phase A: NewPipeExtractor + OkHttp Downloader wired in. Search bar +
LazyColumn results. Tap = navigate to detail.

Phase B: VideoDetail screen — StreamInfo metadata + Return YouTube
Dislike chips + description.

Phase C: Media3 ExoPlayer in Compose. Resolves StreamInfo to best
playable: DASH MPD → HLS → combined progressive → merged
videoOnly+audio.

Phase D: SponsorBlock SHA-256 prefix lookup. 250ms position-poll loop
inside PlayerScreen — exoPlayer.seekTo(segment.end) when entering
a sponsor segment. Toast on skip.

Phase E: Verified live on Android 14 emulator. linus tech tips search
returns real results with thumbnails; tapped result opens detail;
hit Play → video plays through ExoPlayer.

Architecture: everything in :strawApp for now (not pushed into :shared
yet — KMP refactor is day-3). Pure-state nav (sealed Screen + stack,
no nav library).

Known polish gaps (day-3): RYD chips render empty on some videos,
description has raw HTML (markdown render needed), no Koin DI yet,
no persistence.

GPL-3.0-or-later per upstream NewPipe.
This commit is contained in:
Kayos 2026-05-23 19:22:52 -07:00
parent ff4dc6f121
commit 496ed30bda
16 changed files with 1125 additions and 6 deletions

79
docs/sulkta/DAY2.md Normal file
View file

@ -0,0 +1,79 @@
# Straw day-2 — search → detail → player → SponsorBlock + RYD
Loop pass through phases A → E in one session. Built on the day-1 `:strawApp`
module without touching `:shared` or `:app`.
## What landed
| Phase | Component | Location |
|---|---|---|
| A | Search bar + LazyColumn results | `feature/search/Search{ViewModel,Screen}.kt` |
| A | NewPipeExtractor + OkHttp Downloader | `extractor/NewPipeDownloader.kt`, `StrawApp.kt` |
| B | Video detail screen + RYD chips | `feature/detail/VideoDetail{ViewModel,Screen}.kt` |
| B | Return YouTube Dislike client | `net/RydClient.kt` |
| C | Media3 ExoPlayer (DASH / HLS / progressive / merge fallback) | `feature/player/PlayerScreen.kt` |
| C | Player view-model resolves StreamInfo | `feature/player/PlayerViewModel.kt` |
| D | SponsorBlock client (SHA-256 prefix lookup) | `net/SponsorBlockClient.kt` |
| D | Auto-skip via position-poll loop | inside `PlayerScreen.kt` |
| E | Repo committed, APK rebuilt | this commit |
## Navigation
Home → Search → VideoDetail → Player.
Pure-state nav (sealed `Screen` + `Navigator`, no nav library). Back button
unwinds the stack; falling off the root exits the app. Day-3 will switch to
`androidx-navigation3` to match upstream's KMP scaffold.
## Architecture notes
- **All code lives in `:strawApp` for now** — not pushed into `:shared`. The
KMP-skill canonical shape would be domain/data/presentation per feature in
`:shared/commonMain`. We'll refactor later; day-2 is about shipping the
vertical slice fast.
- **No DI yet** — ViewModels are `viewModel()`-constructed with no
constructor args. Day-3 will introduce Koin for the OkHttp client,
RydClient, SponsorBlockClient.
- **No persistence** — search history, watch history, subs all live in
memory and die on app close. Day-3 = Room/DataStore.
- **No iOS/Desktop**`:strawApp` is Android-only because NewPipeExtractor
is JVM-only. KMP-ification of the extractor is a multi-week project
upstream is presumably already eyeing.
## Player path
NewPipeExtractor returns a `StreamInfo` with a few possible playback shapes.
We try them in this preference order:
1. **DASH MPD URL**`info.dashMpdUrl``DashMediaSource`. Best quality
when YouTube serves it.
2. **HLS URL**`info.hlsUrl``HlsMediaSource`. Mostly for live streams.
3. **Combined video+audio**`info.videoStreams.maxByBitrate`
`ProgressiveMediaSource`. Rare on modern YouTube; older clients only.
4. **Merged DASH-chunks**`info.videoOnlyStreams.maxByBitrate` +
`info.audioStreams.maxByBitrate``MergingMediaSource`. The fallback.
5. **Video-only** — last-resort, silent playback.
## SponsorBlock auto-skip
Phase D wires `SponsorBlockClient.fetch(videoId, ["sponsor"])` into
`PlayerViewModel` and runs a 250ms position-poll loop in `PlayerScreen`
that calls `exoPlayer.seekTo(segment.endSec * 1000)` when the playhead
enters a segment. A Toast says `skipped <category>` each time.
Categories default to `sponsor` only — matches siku2 defaults. User-settable
categories are day-3.
## Known limitations / day-3 items
- ExoPlayer reuse across video changes: each VideoDetail → Player is a fresh
resolve. Quick navigation can leave stranded resolves.
- No background audio / PiP.
- No screen-rotation handling (config-changes are caught so the activity
isn't recreated, but the player UI doesn't lock landscape on play).
- No watch history / subscriptions.
- No channel browse / playlist browse.
- No proxy / Tor support.
- No "open with Straw" intent filter for YouTube URLs (low-hanging Day-3).
- DI via Koin (skill recommends it).
- Move logic into `:shared` per KMP best-practices.

View file

@ -2,8 +2,11 @@
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* :strawApp — thin Android application shell around :shared (the KMP Compose
* code). Lives alongside the legacy :app module so we don't break it.
* :strawApp — thin Android application shell. Day-2: pulls NewPipeExtractor,
* Media3, Ktor-style HTTP-via-OkHttp + kotlinx-serialization JSON for the
* search → detail → player → SponsorBlock + RYD flow. We keep our deps in
* this module, NOT in the shared libs.versions.toml, so upstream NewPipe
* stays cleanly mergeable.
*/
import com.android.build.api.dsl.ApplicationExtension
@ -56,13 +59,50 @@ configure<ApplicationExtension> {
buildConfig = true
resValues = true
}
packaging {
resources {
excludes += setOf(
"META-INF/README.md",
"META-INF/CHANGES",
"META-INF/COPYRIGHT",
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties",
)
}
}
}
dependencies {
// Compose + AndroidX core
implementation(libs.androidx.activity)
implementation(libs.androidx.core)
implementation(libs.jetbrains.compose.runtime)
implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui)
// Lifecycle + ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Image loading
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep
implementation(libs.newpipe.extractor)
implementation(libs.squareup.okhttp)
// JSON for SponsorBlock + Return YouTube Dislike clients
implementation(libs.kotlinx.serialization.json)
// Media3 ExoPlayer
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-exoplayer-dash:1.4.1")
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
}

View file

@ -5,10 +5,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".StrawApp"
android:label="@string/app_name"
android:icon="@android:drawable/sym_def_app_icon"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".StrawActivity"

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Tiny in-app nav model sealed Screen + a stack. No nav library; pure
* state. Good enough for day-2's home search detail player flow.
*/
package com.sulkta.straw
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
sealed interface Screen {
data object Home : Screen
data object Search : Screen
data class VideoDetail(val streamUrl: String, val title: String) : Screen
data class Player(val streamUrl: String, val title: String) : Screen
}
class Navigator(initial: Screen) {
val stack = mutableStateListOf<Screen>(initial)
val current: Screen get() = stack.last()
fun push(s: Screen) {
stack.add(s)
}
/** @return false if we couldn't pop (root), true otherwise. */
fun pop(): Boolean {
if (stack.size <= 1) return false
stack.removeAt(stack.lastIndex)
return true
}
}
@Composable
fun rememberNavigator(initial: Screen = Screen.Home): Navigator =
remember { Navigator(initial) }

View file

@ -7,15 +7,20 @@ package com.sulkta.straw
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.search.SearchScreen
class StrawActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -25,7 +30,42 @@ class StrawActivity : ComponentActivity() {
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = scheme) {
Surface(modifier = Modifier.fillMaxSize()) {
StrawHome()
val nav = rememberNavigator()
DisposableEffect(nav) {
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!nav.pop()) {
isEnabled = false
this@StrawActivity.onBackPressedDispatcher.onBackPressed()
}
}
}
onBackPressedDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
when (val s = nav.current) {
is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) },
)
is Screen.Search -> SearchScreen(
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
)
is Screen.VideoDetail -> VideoDetailScreen(
streamUrl = s.streamUrl,
initialTitle = s.title,
onPlay = {
nav.push(Screen.Player(s.streamUrl, s.title))
},
)
is Screen.Player -> PlayerScreen(
streamUrl = s.streamUrl,
title = s.title,
)
}
}
}
}

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.sulkta.straw
import android.app.Application
import com.sulkta.straw.extractor.NewPipeDownloader
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.localization.ContentCountry
import org.schabi.newpipe.extractor.localization.Localization
class StrawApp : Application() {
override fun onCreate() {
super.onCreate()
NewPipe.init(
NewPipeDownloader.init(),
Localization("en", "US"),
ContentCountry("US"),
)
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -19,7 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun StrawHome() {
fun StrawHome(onOpenSearch: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@ -34,9 +35,13 @@ fun StrawHome() {
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "v0.1.0-day1 — Sulkta-Coop",
text = "v0.1.0 — Sulkta-Coop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(48.dp))
Button(onClick = onOpenSearch) {
Text("Search YouTube")
}
}
}

View file

@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Minimal OkHttp-backed implementation of NewPipeExtractor's Downloader.
* No cookies, no recaptcha handling anonymous browsing only. Modeled after
* NewPipe's DownloaderImpl but trimmed down for fork scope.
*/
package com.sulkta.straw.extractor
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
class NewPipeDownloader private constructor(
private val client: OkHttpClient,
) : Downloader() {
override fun execute(request: Request): Response {
val httpMethod = request.httpMethod()
val url = request.url()
val headers = request.headers()
val data: ByteArray? = request.dataToSend()
val requestBody: RequestBody? = if (data != null) {
RequestBody.create(null, data)
} else null
val okBuilder = okhttp3.Request.Builder()
.method(httpMethod, requestBody)
.url(url)
.addHeader("User-Agent", USER_AGENT)
headers.forEach { (name, values) ->
okBuilder.removeHeader(name)
values.forEach { okBuilder.addHeader(name, it) }
}
val okResponse = client.newCall(okBuilder.build()).execute()
val body = okResponse.body
val bodyString = body?.string() ?: ""
val responseHeaders = okResponse.headers.toMultimap()
val latestUrl = okResponse.request.url.toString()
if (okResponse.code == 429) {
okResponse.close()
throw IOException("HTTP 429 — rate limited")
}
okResponse.close()
return Response(
okResponse.code,
okResponse.message,
responseHeaders,
bodyString,
latestUrl,
)
}
companion object {
const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.0.0 Mobile Safari/537.36"
@Volatile private var instance: NewPipeDownloader? = null
fun init(builder: OkHttpClient.Builder? = null): NewPipeDownloader {
val client = (builder ?: OkHttpClient.Builder())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val d = NewPipeDownloader(client)
instance = d
return d
}
fun get(): NewPipeDownloader = instance
?: error("NewPipeDownloader not initialized — call init() first")
fun client(): OkHttpClient = get().client
}
}

View file

@ -0,0 +1,141 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.sulkta.straw.feature.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
@Composable
fun VideoDetailScreen(
streamUrl: String,
initialTitle: String,
onPlay: () -> Unit,
vm: VideoDetailViewModel = viewModel(),
) {
val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
when {
state.loading -> Box(
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.error != null -> Text(
"error: ${state.error}",
color = MaterialTheme.colorScheme.error,
)
else -> {
val d = state.detail ?: return@Column
AsyncImage(
model = d.thumbnail,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp)),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = d.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = d.uploader,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
// Engagement row: views + RYD likes/dislikes
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AssistChip(
onClick = {},
label = { Text(formatViews(d.viewCount)) },
)
d.ryd?.let { ryd ->
AssistChip(
onClick = {},
label = { Text("👍 ${formatCount(ryd.likes)}") },
colors = AssistChipDefaults.assistChipColors(
labelColor = Color(0xFF2E7D32),
),
)
AssistChip(
onClick = {},
label = { Text("👎 ${formatCount(ryd.dislikes)}") },
colors = AssistChipDefaults.assistChipColors(
labelColor = Color(0xFFC62828),
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onPlay) { Text("Play") }
Spacer(modifier = Modifier.height(16.dp))
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = d.description.take(2000),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
private fun formatCount(n: Long): String = when {
n >= 1_000_000_000 -> "%.1fB".format(n / 1_000_000_000.0)
n >= 1_000_000 -> "%.1fM".format(n / 1_000_000.0)
n >= 1_000 -> "%.1fK".format(n / 1_000.0)
else -> "$n"
}
private fun formatViews(v: Long): String = "${formatCount(v)} views"

View file

@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.sulkta.straw.feature.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
data class VideoDetail(
val id: String,
val title: String,
val uploader: String,
val viewCount: Long,
val description: String,
val thumbnail: String?,
val ryd: RydVotes? = null,
)
data class VideoDetailUiState(
val loading: Boolean = true,
val detail: VideoDetail? = null,
val error: String? = null,
// Stored on success for handoff to player. Not in UI.
val streamInfo: StreamInfo? = null,
)
class VideoDetailViewModel : ViewModel() {
private val _ui = MutableStateFlow(VideoDetailUiState())
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
fun load(streamUrl: String) {
if (_ui.value.detail != null || _ui.value.loading.not()) {
// Already loaded or already loading once
}
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id
val ryd = withContext(Dispatchers.IO) {
runCatching { RydClient.fetch(videoId) }.getOrNull()
}
_ui.value = VideoDetailUiState(
loading = false,
detail = VideoDetail(
id = videoId,
title = info.name ?: "(no title)",
uploader = info.uploaderName ?: "",
viewCount = info.viewCount,
description = info.description?.content ?: "",
thumbnail = info.thumbnails?.firstOrNull()?.url,
ryd = ryd,
),
streamInfo = info,
)
} catch (t: Throwable) {
_ui.value = VideoDetailUiState(
loading = false,
error = t.message ?: t.javaClass.simpleName,
)
}
}
}
}

View file

@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase C: Media3 PlayerView embedded in Compose.
* Phase D: SponsorBlock auto-skip wired in via position-poll loop.
*/
package com.sulkta.straw.feature.player
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.net.SbSegment
import kotlinx.coroutines.delay
@OptIn(UnstableApi::class)
@Composable
fun PlayerScreen(
streamUrl: String,
title: String,
vm: PlayerViewModel = viewModel(),
) {
val context = LocalContext.current
val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { vm.resolve(streamUrl) }
val exoPlayer = remember {
ExoPlayer.Builder(context).build()
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
val resolved = state.resolved
LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
r.videoUrl != null && r.audioUrl != null -> {
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.audioUrl))
MergingMediaSource(v, a)
}
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
else -> null
}
if (source != null) {
exoPlayer.setMediaSource(source)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
// SponsorBlock auto-skip — poll position every 250ms, seek past any segment.
LaunchedEffect(resolved?.segments) {
val segments = resolved?.segments ?: return@LaunchedEffect
if (segments.isEmpty()) return@LaunchedEffect
while (true) {
delay(250)
if (!exoPlayer.isPlaying) continue
val posSec = exoPlayer.currentPosition / 1000.0
val segment = pickActiveSegment(segments, posSec)
if (segment != null) {
exoPlayer.seekTo((segment.endSec * 1000).toLong())
Toast.makeText(
context,
"skipped ${segment.category}",
Toast.LENGTH_SHORT,
).show()
}
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when {
state.loading -> CircularProgressIndicator()
state.error != null -> Text(
"playback error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
resolved?.isPlayable != true -> Text(
"no playable stream found",
modifier = Modifier.padding(16.dp),
)
else -> AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = true
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
/** Returns the segment whose interval contains [posSec], if any. */
private fun pickActiveSegment(segments: List<SbSegment>, posSec: Double): SbSegment? =
segments.firstOrNull { posSec >= it.startSec && posSec < it.endSec }

View file

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.sulkta.straw.feature.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
data class ResolvedPlayback(
val title: String,
val videoUrl: String?,
val audioUrl: String?,
val combinedUrl: String?,
val dashMpdUrl: String?,
val hlsUrl: String?,
val segments: List<SbSegment> = emptyList(),
) {
/** Have anything playable? */
val isPlayable: Boolean
get() = !combinedUrl.isNullOrBlank() || !videoUrl.isNullOrBlank() ||
!dashMpdUrl.isNullOrBlank() || !hlsUrl.isNullOrBlank()
}
data class PlayerUiState(
val loading: Boolean = true,
val resolved: ResolvedPlayback? = null,
val error: String? = null,
)
class PlayerViewModel : ViewModel() {
private val _ui = MutableStateFlow(PlayerUiState())
val ui: StateFlow<PlayerUiState> = _ui.asStateFlow()
fun resolve(streamUrl: String, sbCategories: List<String> = listOf("sponsor")) {
_ui.value = PlayerUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id
val segments = withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCategories) }
.getOrDefault(emptyList())
}
val combined = info.videoStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val videoOnly = info.videoOnlyStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val audioOnly = info.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
_ui.value = PlayerUiState(
loading = false,
resolved = ResolvedPlayback(
title = info.name ?: "",
videoUrl = videoOnly,
audioUrl = audioOnly,
combinedUrl = combined,
dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() },
hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() },
segments = segments,
),
)
} catch (t: Throwable) {
_ui.value = PlayerUiState(
loading = false,
error = t.message ?: t.javaClass.simpleName,
)
}
}
}
}

View file

@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.sulkta.straw.feature.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
@Composable
fun SearchScreen(
onOpenVideo: (url: String, title: String) -> Unit,
vm: SearchViewModel = viewModel(),
) {
val state by vm.ui.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = vm::onQueryChange,
label = { Text("Search YouTube") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { vm.submit() }),
)
Spacer(modifier = Modifier.height(12.dp))
when {
state.loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.error != null -> Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "error: ${state.error}",
color = MaterialTheme.colorScheme.error,
)
}
state.results.isEmpty() && state.query.isNotBlank() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { Text("hit enter to search") }
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.results) { item ->
ResultRow(item = item) { onOpenVideo(item.url, item.title) }
HorizontalDivider()
}
}
}
}
}
@Composable
private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
modifier = Modifier
.width(160.dp)
.height(90.dp)
.clip(RoundedCornerShape(6.dp)),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = buildString {
append(item.uploader)
if (item.viewCount > 0) {
append(" · ")
append(formatViews(item.viewCount))
}
if (item.durationSeconds > 0) {
append(" · ")
append(formatDuration(item.durationSeconds))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun formatDuration(sec: Long): String {
val h = sec / 3600
val m = (sec % 3600) / 60
val s = sec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}
private fun formatViews(views: Long): String = when {
views >= 1_000_000_000 -> "%.1fB views".format(views / 1_000_000_000.0)
views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0)
views >= 1_000 -> "%.1fK views".format(views / 1_000.0)
else -> "$views views"
}

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.search.SearchInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class SearchUiState(
val query: String = "",
val results: List<StreamItem> = emptyList(),
val loading: Boolean = false,
val error: String? = null,
)
data class StreamItem(
val url: String,
val title: String,
val uploader: String,
val thumbnail: String?,
val durationSeconds: Long,
val viewCount: Long,
)
class SearchViewModel : ViewModel() {
private val _ui = MutableStateFlow(SearchUiState())
val ui: StateFlow<SearchUiState> = _ui.asStateFlow()
fun onQueryChange(q: String) {
_ui.value = _ui.value.copy(query = q)
}
fun submit() {
val q = _ui.value.query.trim()
if (q.isEmpty()) return
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) { search(q) }
_ui.value = _ui.value.copy(loading = false, results = items)
} catch (t: Throwable) {
_ui.value = _ui.value.copy(
loading = false,
error = t.message ?: t.javaClass.simpleName,
)
}
}
}
private fun search(query: String): List<StreamItem> {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val qh = service.searchQHFactory.fromQuery(query, emptyList(), "")
val info = SearchInfo.getInfo(service, qh)
return info.relatedItems
.filterIsInstance<StreamInfoItem>()
.map {
StreamItem(
url = it.url,
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: "",
thumbnail = it.thumbnails?.firstOrNull()?.url,
durationSeconds = it.duration,
viewCount = it.viewCount,
)
}
}
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Return YouTube Dislike client.
* API: GET https://returnyoutubedislike.com/votes?videoId=<id>
*/
package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Request
@Serializable
data class RydVotes(
val id: String,
val likes: Long = 0,
val dislikes: Long = 0,
val rating: Double = 0.0,
val viewCount: Long = 0,
)
object RydClient {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
/** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? {
val req = Request.Builder()
.url("https://returnyoutubedislike.com/votes?videoId=$videoId")
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.build()
return NewPipeDownloader.client().newCall(req).execute().use { r ->
if (!r.isSuccessful) return@use null
val body = r.body?.string() ?: return@use null
runCatching { json.decodeFromString<RydVotes>(body) }.getOrNull()
}
}
}

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SponsorBlock client SHA-256 prefix lookup.
* API: GET https://sponsor.ajay.app/api/skipSegments/<prefix4>?categories=[...]
*/
package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Request
import java.security.MessageDigest
@Serializable
data class SbVideoSegments(
val videoID: String,
val segments: List<SbSegment> = emptyList(),
)
@Serializable
data class SbSegment(
val UUID: String? = null,
val category: String,
val segment: List<Double>,
val actionType: String? = null,
) {
val startSec: Double get() = segment.getOrNull(0) ?: 0.0
val endSec: Double get() = segment.getOrNull(1) ?: 0.0
}
object SponsorBlockClient {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
/**
* Fetch SponsorBlock segments for [videoId] limited to [categories].
* Returns segments matching the exact video ID (the API returns matches
* for the prefix, so we filter client-side).
*/
fun fetch(
videoId: String,
categories: List<String> = listOf("sponsor"),
): List<SbSegment> {
val prefix = sha256Hex(videoId).substring(0, 4)
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
"categories=" + buildJsonArray(categories)
val req = Request.Builder()
.url(urlStr)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.build()
return NewPipeDownloader.client().newCall(req).execute().use { r ->
if (!r.isSuccessful) return@use emptyList()
val body = r.body?.string() ?: return@use emptyList()
val all = runCatching {
json.decodeFromString<List<SbVideoSegments>>(body)
}.getOrDefault(emptyList())
all.firstOrNull { it.videoID == videoId }?.segments.orEmpty()
}
}
private fun buildJsonArray(items: List<String>): String =
items.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" }
private fun sha256Hex(s: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(s.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}