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:
parent
ff4dc6f121
commit
496ed30bda
16 changed files with 1125 additions and 6 deletions
79
docs/sulkta/DAY2.md
Normal file
79
docs/sulkta/DAY2.md
Normal 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.
|
||||||
|
|
@ -2,8 +2,11 @@
|
||||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* :strawApp — thin Android application shell around :shared (the KMP Compose
|
* :strawApp — thin Android application shell. Day-2: pulls NewPipeExtractor,
|
||||||
* code). Lives alongside the legacy :app module so we don't break it.
|
* 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
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
|
@ -56,13 +59,50 @@ configure<ApplicationExtension> {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
resValues = 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 {
|
dependencies {
|
||||||
|
// Compose + AndroidX core
|
||||||
implementation(libs.androidx.activity)
|
implementation(libs.androidx.activity)
|
||||||
implementation(libs.androidx.core)
|
implementation(libs.androidx.core)
|
||||||
implementation(libs.jetbrains.compose.runtime)
|
implementation(libs.jetbrains.compose.runtime)
|
||||||
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)
|
||||||
|
|
||||||
|
// 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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".StrawApp"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:icon="@android:drawable/sym_def_app_icon"
|
android:icon="@android:drawable/sym_def_app_icon"
|
||||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
<activity
|
<activity
|
||||||
android:name=".StrawActivity"
|
android:name=".StrawActivity"
|
||||||
|
|
|
||||||
40
strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt
Normal file
40
strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt
Normal 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) }
|
||||||
|
|
@ -7,15 +7,20 @@ package com.sulkta.straw
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
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() {
|
class StrawActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|
@ -25,7 +30,42 @@ class StrawActivity : ComponentActivity() {
|
||||||
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
|
val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
|
||||||
MaterialTheme(colorScheme = scheme) {
|
MaterialTheme(colorScheme = scheme) {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
Normal file
23
strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
Normal 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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -19,7 +20,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StrawHome() {
|
fun StrawHome(onOpenSearch: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -34,9 +35,13 @@ fun StrawHome() {
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "v0.1.0-day1 — Sulkta-Coop",
|
text = "v0.1.0 — Sulkta-Coop",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
Button(onClick = onOpenSearch) {
|
||||||
|
Text("Search YouTube")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt
Normal file
40
strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue