diff --git a/docs/sulkta/DAY2.md b/docs/sulkta/DAY2.md new file mode 100644 index 000000000..400996b1f --- /dev/null +++ b/docs/sulkta/DAY2.md @@ -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 ` 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. diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index da46f7b54..68d40114b 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -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 { 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") } diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index 6d4c7aec8..603cf8b03 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -5,10 +5,12 @@ (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) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 01acef496..bb0471331 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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, + ) + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt new file mode 100644 index 000000000..2cc81045c --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -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"), + ) + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 24b16fcb8..8c926cdc8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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") + } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt new file mode 100644 index 000000000..65b86d220 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt @@ -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 + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt new file mode 100644 index 000000000..62e15073e --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt new file mode 100644 index 000000000..689241f84 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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 = _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, + ) + } + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt new file mode 100644 index 000000000..d2ee7ff5b --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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, posSec: Double): SbSegment? = + segments.firstOrNull { posSec >= it.startSec && posSec < it.endSec } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt new file mode 100644 index 000000000..c7360ca11 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt @@ -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 = 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 = _ui.asStateFlow() + + fun resolve(streamUrl: String, sbCategories: List = 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, + ) + } + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt new file mode 100644 index 000000000..b3839ecd3 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -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" +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt new file mode 100644 index 000000000..e0ab94edb --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -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 = 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 = _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 { + val service = NewPipe.getService(ServiceList.YouTube.serviceId) + val qh = service.searchQHFactory.fromQuery(query, emptyList(), "") + val info = SearchInfo.getInfo(service, qh) + return info.relatedItems + .filterIsInstance() + .map { + StreamItem( + url = it.url, + title = it.name ?: "(no title)", + uploader = it.uploaderName ?: "", + thumbnail = it.thumbnails?.firstOrNull()?.url, + durationSeconds = it.duration, + viewCount = it.viewCount, + ) + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt new file mode 100644 index 000000000..1cfa6a7e9 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -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= + */ + +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(body) }.getOrNull() + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt new file mode 100644 index 000000000..70d664ef0 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -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/?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 = emptyList(), +) + +@Serializable +data class SbSegment( + val UUID: String? = null, + val category: String, + val segment: List, + 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 = listOf("sponsor"), + ): List { + 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>(body) + }.getOrDefault(emptyList()) + all.firstOrNull { it.videoID == videoId }?.segments.orEmpty() + } + } + + private fun buildJsonArray(items: List): 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) } + } +}