Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
e410d0e92d v0.1.0-AB (vc=16): Path C — rustypipe via UniFFI is the extractor
End state of the path C re-flip. strawApp Kotlin is now ~UI + thin
glue to strawcore (Rust). The Rust core does:

  - search  (rustypipe.search_videos)
  - streamInfo  (rustypipe.player → pre-signed iOS InnerTube URLs)
  - channelInfo + channel videos (rustypipe.channel + channel_videos)

All via UniFFI suspend fns. NewPipeExtractor is zero-import in
straw Kotlin. libstrawcore.so packs rustypipe + reqwest + tokio +
rustls + rquickjs per ABI (~3-7MB each).

Built against the audit-hardened Sulkta-Coop/rustypipe fork
v0.11.5-sulkta.2 — iOS-first default client order, soft-fail sig
deobf, Deobfuscation errors switchable, observability via Reporter.

Verified end-to-end on emulator:
  - C-3: search returns Darknet Diaries results
  - C-4: VideoDetail loads (title/uploader/views/RYD/description),
         inline ExoPlayer plays the iOS-fetched stream URL
  - C-5: Channel page (NCS) shows banner + avatar + 34.3M subscribers
         + full video list
2026-05-24 13:31:10 -07:00
979b4021b0 Path C-6: rip NewPipeExtractor
Zero org.schabi.newpipe classes in straw Kotlin. strawcore (Rust +
rustypipe via UniFFI) is the only extractor.

Deletions:
- strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt
  (was the OkHttp adapter; STRAW_USER_AGENT + strawHttpClient() in
  net/Http.kt cover its role)
- strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt
  (rustypipe surfaces a pre-picked thumbnail URL; no helper needed)

Build:
- libs.newpipe.extractor dependency removed from strawApp/build.gradle.kts

Call-site swaps:
- net/RydClient.kt, net/SponsorBlockClient.kt: NewPipeDownloader.client()
  + .USER_AGENT → strawHttpClient() + STRAW_USER_AGENT
- feature/player/PlayerScreen.kt, feature/player/PlaybackService.kt,
  feature/detail/VideoDetailScreen.kt: ExoPlayer
  DefaultHttpDataSource.Factory now reads STRAW_USER_AGENT from net/Http
- StrawApp.kt: NewPipe.init() call gone; History/Settings/Subscriptions
  bootstrap unchanged

net/Http.kt gains STRAW_USER_AGENT const + strawHttpClient() lazy
OkHttpClient with the same shape NewPipeDownloader had (15s connect,
30s read, follow redirects).
2026-05-24 13:29:19 -07:00
b95565bec7 C-5 fix: pin uploaderUrl to local val for Kotlin smart-cast
info.uploaderUrl is a nullable String on a uniffi-generated Record;
Kotlin's smart-cast can't prove the second access isn't null after
isNullOrBlank() on a mutable property. Pin to a local val so the
non-null cast carries through the uniffi.strawcore.channelInfo() call.
2026-05-24 13:23:54 -07:00
90930ade11 Path C-5: channel + sub feed + moreFromChannel swap to strawcore
Three ViewModels move from NewPipeExtractor (Java) to strawcore (rustypipe):

- ChannelViewModel.load — ChannelInfo.getInfo + ChannelTabInfo.getInfo
  collapse into one uniffi.strawcore.channelInfo() round-trip.

- SubscriptionFeedViewModel.refresh — per-channel parallel fan-out now
  fires uniffi.strawcore.channelInfo() per sub instead of two NPE round-
  trips. Halves the network work for the home sub-feed. Semaphore +
  timeout + cancel-on-respawn audit guards preserved.

- VideoDetailViewModel.moreFromChannel — was the last NPE call site in
  the load() path. Now strawcore.channelInfo(uploaderUrl).videos filtered
  + mapped. The unused withContext(Dispatchers.IO) wrapper for the
  channel fetch is gone (strawcore is suspend on tokio).

NewPipeExtractor is now reachable only from non-ViewModel code:
NewPipeDownloader.kt (OkHttp adapter), StrawApp.NewPipe.init(),
util/Thumbnails.kt. C-6 deletes all three.
2026-05-24 13:21:33 -07:00
198d2a9066 Path C-4 fix: stream_info uses fork's iOS-first default client order
Caught on first emulator smoke: stream_info hardcoded
`player_from_clients(&[Android, Ios])` from U-3 era. Android first
trips YT's "Precondition check failed" because needs_po_token
doesn't flag Android — request fires unsigned and YT rejects it.

The fork's audit-fixed player_client_order is [Ios, Tv] without
botguard (HIGH-3 in the audit). Use rp.query().player(id) directly
so we inherit that order and pick up future tweaks automatically.
2026-05-24 13:15:19 -07:00
47e037ee62 Path C-4: PlayerViewModel + VideoDetailViewModel swap to uniffi.strawcore.streamInfo
Drops NewPipeExtractor's StreamInfo.getInfo() from the player resolve
path and the video-detail load path. strawcore.streamInfo() is a
single Rust round-trip backed by rustypipe via UniFFI; returns the
adaptive video / video-only / audio-only lists, DASH MPD + HLS URLs,
description, view/like counts, thumbnail, and related-video list.

VideoDetailUiState.streamInfo flips from org.schabi.newpipe.extractor.stream.StreamInfo
to uniffi.strawcore.StreamInfo — used by the Download dialog in
VideoDetailScreen. Dialog field accesses updated accordingly.

moreFromChannel still uses NewPipeExtractor's ChannelInfo until C-5
swaps it to strawcore.channelInfo(). Keeps blast radius surgical.
2026-05-24 13:13:04 -07:00
7968bbb8e6 Path C-2 fix: uniffiBindgen honors CARGO_TARGET_DIR
The crafting-table container's rootfs hits 100% disk before cross-
compile finishes for 4 ABIs, so we redirect CARGO_TARGET_DIR to
/caches. The uniffiBindgen task was looking for libstrawcore.so at
target/debug/ (default) and failed when it had been written to
$CARGO_TARGET_DIR/debug/ instead. Honor the env var with a fallback
to the workspace-relative default.
2026-05-24 12:59:59 -07:00
93297ad0a0 Path C-3: SearchViewModel swap to uniffi.strawcore.search
Drops NewPipeExtractor from the search code path. The bindgen-generated
`search()` is a Kotlin suspend fun running on the tokio runtime baked
into libstrawcore.so — no Dispatchers.IO wrapper needed.

NPE still drives VideoDetail / Player / Channel / sub feed; those move
to rustypipe in C-4 / C-5 / C-6.
2026-05-24 12:56:32 -07:00
54458f3d40 Path C-1/C-2: rustypipe fork dep + Gradle Rust pipeline
C-1: rust/strawcore/Cargo.toml now points at Sulkta-Coop/rustypipe
     v0.11.5-sulkta.2 (kayos/m1-sig-port) instead of upstream 0.11.4.
     Upstream's sig-regex hard-fails on current YT player c2f7551f;
     the fork soft-fails, defaults to iOS-first, makes Deobfuscation
     errors switchable through the client-fallback chain, and adds
     observability via Reporter Level::WRN.

C-2: strawApp/build.gradle.kts restores the U-1 era Gradle Rust glue:
     - cargoBuild     → cross-compile strawcore for 4 Android ABIs
     - cargoBuildHost → host-arch debug build for uniffi-bindgen
                        to read metadata from
     - uniffiBindgen  → generate Kotlin bindings from libstrawcore.so
     Wired into the Android build via mergeXxxJniLibFolders +
     compileXxxKotlin task dependencies.

Bumps + gitignores:
     - .gitignore now excludes rust/target, strawApp/src/main/jniLibs,
       and strawApp/src/main/java/uniffi (generated)
     - jna 5.14.0 added as the JNI bridge runtime

Next: cargoBuildHost + uniffiBindgen verification in crafting-table,
then C-3 swaps SearchViewModel back to uniffi.strawcore.search().
2026-05-24 12:44:17 -07:00
19 changed files with 315 additions and 376 deletions

6
.gitignore vendored
View file

@ -38,3 +38,9 @@ rust/target/
strawApp/src/main/jniLibs/
# UniFFI-generated Kotlin bindings (regen'd from .so on every build)
strawApp/src/main/java/uniffi/
# Rust build artifacts
rust/target/
strawApp/src/main/jniLibs/
# UniFFI-generated Kotlin bindings (regen'd from .so on every build)
strawApp/src/main/java/uniffi/

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
const val STRAW_VERSION_CODE = 15
const val STRAW_VERSION_NAME = "0.1.0-AA"
const val STRAW_VERSION_CODE = 16
const val STRAW_VERSION_NAME = "0.1.0-AB"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -19,10 +19,27 @@ crate-type = ["cdylib", "staticlib"]
uniffi = { version = "0.28", features = ["cli", "tokio"] }
# Tokio multi-thread runtime — rustypipe is async-first.
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
# rustypipe — the actual YouTube Innertube client. Phase U-2 wires search.
# rustypipe — the actual YouTube Innertube client. Phase U-2 wires search,
# U-3 wires streamInfo, U-4 wires channels.
#
# Points at the Sulkta-Coop fork (kayos/m1-sig-port branch, tag v0.11.5-sulkta.2)
# because upstream 0.11.4 hard-failed at init when YT rotated the
# player.js to a shape its sig-regex doesn't recognise (player c2f7551f, May 2026).
# The fork:
# - skips player.js deobf entirely for the iOS/Android client paths
# (pre-signed URLs, no &s= cipher, no &n= throttle param)
# - soft-fails sig_fn/nsig_fn extraction with a switchable error class
# so the player_from_clients chain falls through to iOS instead of
# killing the call
# - defaults to iOS-first client order
# - emits Level::WRN reporter event on partial extraction
#
# Force rustls + webpki-roots so we don't pull openssl-sys (cross-compiling
# system OpenSSL to four Android ABIs is a tarpit; rustls is pure-Rust).
rustypipe = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"] }
#
# When YT rotates back to a sig shape both upstream and our fork recognise,
# we can flip back to crates.io. Until then, the fork is the only working dep.
rustypipe = { git = "http://192.168.0.5:3001/Sulkta-Coop/rustypipe.git", tag = "v0.11.5-sulkta.2", default-features = false, features = ["rustls-tls-webpki-roots"] }
# rquickjs-sys (transitive dep of rustypipe for YT signature decryption JS)
# doesn't ship prebuilt Android bindings. Direct-depend with `bindgen` feature
# so it generates them at build time. Crafting-table has libclang preinstalled.

View file

@ -129,19 +129,15 @@ pub async fn stream_info(url: String) -> Result<StreamInfo, StrawcoreError> {
log::info!("strawcore::stream_info id={}", id);
let rp = RustyPipe::new();
// rustypipe's default `player()` uses the Web client first, which
// returns signed URLs that need JS deobfuscation. Even the TV (TVHTML5)
// client signs URLs nowadays, so deobfuscation runs and currently
// fails ("could not extract sig fn name") because YT changed the
// obfuscation pattern after rustypipe 0.11.4's last cut.
//
// Android and iOS YT-app clients serve URLs UNSIGNED — no sig
// decryption needed, ExoPlayer plays them directly. This is the same
// path NewPipe uses for its mobile + iOS-embed strategies.
let player = rp
.query()
.player_from_clients(&id, &[ClientType::Android, ClientType::Ios])
.await?;
// Use the fork's audit-fixed default client order. As of
// v0.11.5-sulkta.2 that's [Ios, Tv] without botguard — iOS first
// because it skips player.js deobfuscation AND doesn't require
// device attestation. Android is intentionally NOT in the default
// order: needs_po_token doesn't flag Android, so unsigned requests
// get YT's "Precondition check failed" / "Sign in to confirm
// you're not a bot" rejection, which is environmental-non-switchable.
// Re-add Android when a real po_token strategy lands.
let player = rp.query().player(&id).await?;
let details = &player.details;
// Progressive (combined audio+video) goes through video_streams; the

View file

@ -95,7 +95,9 @@ dependencies {
implementation(libs.coil.network.okhttp)
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep
implementation(libs.newpipe.extractor)
// libs.newpipe.extractor — REMOVED in Path C-6. Extractor is now strawcore
// (Rust + rustypipe via UniFFI). See rust/strawcore/ + the cargoBuild +
// uniffiBindgen Gradle tasks below.
implementation(libs.squareup.okhttp)
// JSON for SponsorBlock + Return YouTube Dislike clients
@ -110,4 +112,95 @@ dependencies {
implementation("androidx.media3:media3-session:1.4.1")
// Guava ListenableFuture support for awaiting MediaController connect.
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
// strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor.
implementation("net.java.dev.jna:jna:5.14.0@aar")
}
// =============================================================================
// Phase U-1 / Path-C-2 — Rust core build glue.
//
// Two tasks chain into the Android build:
// cargoBuild — cross-compiles rust/strawcore for the four Android ABIs
// via cargo-ndk and drops the .so files in strawApp/src/main/jniLibs/.
// uniffiBindgen — generates the Kotlin bindings from the freshly-built lib
// into strawApp/src/main/java/uniffi/strawcore/.
//
// Both depend on:
// - cargo + rustup with the four Android targets installed
// - cargo-ndk on PATH
// - ANDROID_NDK_HOME pointing at an NDK with the right toolchains
// All of that lives in the crafting-table container.
// =============================================================================
val rustRoot = file("../rust").absolutePath
val jniLibsDir = file("src/main/jniLibs").absolutePath
val bindingsDir = file("src/main/java").absolutePath
val cargoHome: String = System.getenv("CARGO_HOME") ?: "/caches/cargo"
val cargoBin: String = "$cargoHome/bin/cargo"
val ndkHome: String = System.getenv("ANDROID_NDK_HOME")
?: System.getenv("ANDROID_NDK_ROOT")
?: "/caches/android-sdk/ndk/27.2.12479018"
// Honor CARGO_TARGET_DIR if set (we redirect it to /caches on crafting-table
// because the container's writable rootfs hits 100% before the cross-compile
// for 4 ABIs finishes). Falls back to the default `<workspace>/target`.
val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR")
?: "$rustRoot/target"
val cargoBuild by tasks.registering(Exec::class) {
group = "rust"
description = "Cross-compile strawcore for all Android ABIs via cargo-ndk."
workingDir = file(rustRoot)
environment("ANDROID_NDK_HOME", ndkHome)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "ndk",
"-t", "arm64-v8a",
"-t", "armeabi-v7a",
"-t", "x86",
"-t", "x86_64",
"-o", jniLibsDir,
"build", "--release", "-p", "strawcore",
)
standardOutput = System.out
errorOutput = System.err
}
val cargoBuildHost by tasks.registering(Exec::class) {
group = "rust"
description = "Build host-arch debug strawcore so bindgen can read its UniFFI metadata."
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(cargoBin, "build", "-p", "strawcore")
standardOutput = System.out
errorOutput = System.err
}
val uniffiBindgen by tasks.registering(Exec::class) {
group = "rust"
description = "Generate Kotlin bindings for strawcore via uniffi-bindgen."
dependsOn(cargoBuildHost)
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "run", "--quiet", "--bin", "uniffi-bindgen", "--",
"generate",
"--library", "$cargoTargetDir/debug/libstrawcore.so",
"--crate", "strawcore",
"--language", "kotlin",
"--no-format",
"--out-dir", bindingsDir,
)
standardOutput = System.out
errorOutput = System.err
}
// Make sure Android's JNI-libs merge picks up the freshly built .so files,
// and Kotlin compilation can resolve the generated bindings.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
.configureEach { dependsOn(cargoBuild) }
tasks.matching { it.name.startsWith("compile") && it.name.endsWith("Kotlin") }
.configureEach { dependsOn(uniffiBindgen) }

View file

@ -9,19 +9,13 @@ import android.app.Application
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
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"),
)
// Path C-6 / Phase U-5: NewPipeExtractor is out. strawcore (Rust)
// loads its own libstrawcore.so via JNA when first called — no
// explicit init needed here. Just bootstrap the local stores.
History.init(this)
Settings.init(this)
Subscriptions.init(this)

View file

@ -1,96 +0,0 @@
/*
* 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 com.sulkta.straw.net.NEWPIPE_MAX_BYTES
import com.sulkta.straw.net.cappedString
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
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 = data?.toRequestBody(null)
val okBuilder = okhttp3.Request.Builder()
.method(httpMethod, requestBody)
.url(url)
// AUD-HIGH: copy NPE headers BEFORE adding our explicit UA so the
// explicit UA wins; guard against header values containing \r/\n
// which OkHttp's addHeader rejects via IAE (turning a poisoned
// response into an app crash).
headers.forEach { (name, values) ->
if (name.equals("User-Agent", ignoreCase = true)) return@forEach
okBuilder.removeHeader(name)
values.forEach { value ->
runCatching { okBuilder.addHeader(name, value) }
}
}
okBuilder.removeHeader("User-Agent")
okBuilder.addHeader("User-Agent", USER_AGENT)
val okResponse = client.newCall(okBuilder.build()).execute()
val body = okResponse.body
// AUD-HIGH: bounded read to defend against OOM via gigabyte response.
val bodyString = body?.cappedString(NEWPIPE_MAX_BYTES) ?: ""
val responseHeaders = okResponse.headers.toMultimap()
val latestUrl = okResponse.request.url.toString()
if (okResponse.code == 429) {
okResponse.close()
throw IOException("HTTP 429 — rate limited")
}
okResponse.close()
return Response(
okResponse.code,
okResponse.message,
responseHeaders,
bodyString,
latestUrl,
)
}
companion object {
const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.0.0 Mobile Safari/537.36"
@Volatile private var instance: NewPipeDownloader? = null
fun init(builder: OkHttpClient.Builder? = null): NewPipeDownloader {
val client = (builder ?: OkHttpClient.Builder())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val d = NewPipeDownloader(client)
instance = d
return d
}
fun get(): NewPipeDownloader = instance
?: error("NewPipeDownloader not initialized — call init() first")
fun client(): OkHttpClient = get().client
}
}

View file

@ -1,6 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-4 / Path C-5: ChannelInfo + Videos tab moved to strawcore
* (rustypipe). The two separate ChannelInfo.getInfo + ChannelTabInfo.getInfo
* calls collapse into one Rust round-trip.
*/
package com.sulkta.straw.feature.channel
@ -8,19 +12,10 @@ package com.sulkta.straw.feature.channel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
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.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class ChannelUiState(
val loading: Boolean = true,
@ -40,43 +35,24 @@ class ChannelViewModel : ViewModel() {
_ui.value = ChannelUiState(loading = true)
viewModelScope.launch {
try {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val info = withContext(Dispatchers.IO) {
ChannelInfo.getInfo(service, channelUrl)
val ch = uniffi.strawcore.channelInfo(channelUrl)
val videos = ch.videos.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader,
uploaderUrl = v.uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
// AUD-HIGH: pick the Videos tab specifically rather than
// info.tabs.firstOrNull() which is YouTube's "Home" (a
// curated mix that mostly drops via filterIsInstance).
val videosTab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull()
val videos: List<StreamItem> = if (videosTab != null) {
withContext(Dispatchers.IO) {
runCatching {
ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.map {
StreamItem(
url = it.url,
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: info.name ?: "",
uploaderUrl = it.uploaderUrl ?: channelUrl,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
}
}.getOrDefault(emptyList())
}
} else emptyList()
_ui.value = ChannelUiState(
loading = false,
name = info.name ?: "",
subscriberCount = info.subscriberCount,
banner = bestThumbnail(info.banners),
avatar = bestThumbnail(info.avatars),
name = ch.name,
subscriberCount = ch.subscriberCount,
banner = ch.banner,
avatar = ch.avatar,
videos = videos,
)
} catch (t: Throwable) {

View file

@ -69,7 +69,7 @@ import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.net.STRAW_USER_AGENT
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml
@ -284,10 +284,8 @@ fun VideoDetailScreen(
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
// info is now uniffi.strawcore.StreamInfo (Path C-4).
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
@ -298,14 +296,8 @@ fun VideoDetailScreen(
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.videoStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
?: info?.videoOnlyStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
@ -417,7 +409,7 @@ private fun InlinePlayer(
LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)

View file

@ -1,6 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3 / Path C-4: streamInfo() moves from NewPipeExtractor (Java) to
* strawcore (Rust + rustypipe via UniFFI). Channel fetch for
* `moreFromChannel` stays on NPE until C-5.
*/
package com.sulkta.straw.feature.detail
@ -13,20 +17,12 @@ import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
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.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail(
val id: String,
@ -48,8 +44,8 @@ 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,
// Stored on success for handoff to the player + Download dialog. Not in UI.
val streamInfo: uniffi.strawcore.StreamInfo? = null,
)
class VideoDetailViewModel : ViewModel() {
@ -66,11 +62,12 @@ class VideoDetailViewModel : ViewModel() {
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
// strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap.
val info = uniffi.strawcore.streamInfo(streamUrl)
val videoId = info.id
val thumb = bestThumbnail(info.thumbnails)
val title = info.name ?: "(no title)"
val uploader = info.uploaderName ?: ""
val thumb = info.thumbnail
val title = info.title.ifBlank { "(no title)" }
val uploader = info.uploader
runCatching {
History.get().recordWatch(
@ -92,51 +89,43 @@ class VideoDetailViewModel : ViewModel() {
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0)
}
val related = info.relatedItems
?.filterIsInstance<StreamInfoItem>()
?.map { it ->
com.sulkta.straw.feature.search.StreamItem(
url = it.url,
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: "",
uploaderUrl = it.uploaderUrl,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
} ?: emptyList()
// More from this channel — anchored to the uploader the user
// already chose. Best-effort: empty if the fetch fails so the
// detail screen still renders. Filters out the current video.
// strawcore returns `related` as List<SearchItem>. Map to the
// Kotlin StreamItem shape used elsewhere.
val related = info.related.map { r ->
com.sulkta.straw.feature.search.StreamItem(
url = r.url,
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
)
}
// More from this channel via strawcore.channelInfo — one
// Rust round-trip returns the channel's Videos tab pre-mapped.
val uploaderUrl = info.uploaderUrl
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
if (info.uploaderUrl.isNullOrBlank()) emptyList()
else withContext(Dispatchers.IO) {
runCatching {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val ch = ChannelInfo.getInfo(service, info.uploaderUrl)
val videosTab = ch.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: ch.tabs.firstOrNull()
if (videosTab == null) emptyList()
else ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.filter { it.url != streamUrl }
.take(20)
.map { si ->
com.sulkta.straw.feature.search.StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: uploader,
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.getOrDefault(emptyList())
}
if (uploaderUrl.isNullOrBlank()) emptyList()
else runCatching {
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
ch.videos
.filter { it.url != streamUrl }
.take(20)
.map { v ->
com.sulkta.straw.feature.search.StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { uploader },
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
}.getOrDefault(emptyList())
_ui.value = VideoDetailUiState(
loading = false,
@ -146,7 +135,7 @@ class VideoDetailViewModel : ViewModel() {
uploader = uploader,
uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount,
description = info.description?.content ?: "",
description = info.description,
thumbnail = thumb,
ryd = ryd,
sbSegmentCount = sbCount,

View file

@ -3,8 +3,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase Q: aggregate latest videos across all subscribed channels into a
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
* fetches in parallel, merges by view count desc, caps at 200 items.
* single feed. Fans out per-channel channelInfo() fetches in parallel,
* merges by view count desc, caps at 200 items.
*
* Path C-5: each per-channel fetch is now ONE strawcore.channelInfo()
* call instead of two NewPipeExtractor round-trips (ChannelInfo.getInfo +
* ChannelTabInfo.getInfo). Halves the network work for the feed.
*
* Audit fixes (2026-05-24 pass #2):
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap
@ -20,9 +24,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -34,14 +36,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class SubscriptionFeedUiState(
val loading: Boolean = false,
@ -82,55 +77,46 @@ class SubscriptionFeedViewModel : ViewModel() {
_ui.update { it.copy(loading = true, error = null) }
inFlight = viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val perChannelMax = 5
val gate = Semaphore(parallelism)
coroutineScope {
val deferreds = channels.map { ch ->
async {
gate.withPermit {
withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = ChannelInfo.getInfo(service, ch.url)
val tab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull()
?: return@runCatching emptyList<StreamItem>()
ChannelTabInfo.getInfo(service, tab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.take(perChannelMax)
.map { si ->
StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: ch.name,
uploaderUrl = si.uploaderUrl ?: ch.url,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
val perChannelMax = 5
val gate = Semaphore(parallelism)
val items = coroutineScope {
val deferreds = channels.map { ch ->
async {
gate.withPermit {
withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = uniffi.strawcore.channelInfo(ch.url)
info.videos
.take(perChannelMax)
.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { ch.name },
uploaderUrl = v.uploaderUrl ?: ch.url,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
}
}
deferreds.awaitAll()
}
.flatten()
// No reliable upload-timestamp from extractor's StreamInfoItem
// in all cases — sort by view count desc as a soft proxy for
// recency-popularity within the recent window.
.sortedByDescending { it.viewCount }
.take(200)
deferreds.awaitAll()
}
.flatten()
// No reliable upload-timestamp on the search-item shape — sort
// by view count desc as a soft proxy for recency-popularity
// within the recent window.
.sortedByDescending { it.viewCount }
.take(200)
_ui.update {
SubscriptionFeedUiState(
loading = false,

View file

@ -50,7 +50,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.sulkta.straw.StrawActivity
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.net.STRAW_USER_AGENT
@UnstableApi
class PlaybackService : MediaSessionService() {
@ -63,7 +63,7 @@ class PlaybackService : MediaSessionService() {
ensureChannel()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val mediaSourceFactory = DefaultMediaSourceFactory(this)
.setDataSourceFactory(httpFactory)

View file

@ -70,7 +70,7 @@ 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.STRAW_USER_AGENT
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.strawLogI
import kotlinx.coroutines.delay
@ -169,7 +169,7 @@ fun PlayerScreen(
LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val source = when {

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3 / Path C-4: extractor moved from NewPipeExtractor (Java) to
* strawcore (Rust + rustypipe via UniFFI). PlayerScreen still calls
* vm.resolve(url) the same way the engine underneath flipped.
*/
package com.sulkta.straw.feature.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient
@ -17,7 +20,6 @@ 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,
@ -48,8 +50,11 @@ class PlayerViewModel : ViewModel() {
_ui.value = PlayerUiState(loading = true)
viewModelScope.launch {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
// strawcore.streamInfo is a suspend fun running on the tokio
// runtime baked into libstrawcore.so — no Dispatchers.IO needed.
val info = uniffi.strawcore.streamInfo(streamUrl)
val videoId = info.id
val sbCategories = Settings.get().sbCategories.value.map { it.key }
val segments = if (sbCategories.isEmpty()) {
emptyList()
@ -61,32 +66,24 @@ class PlayerViewModel : ViewModel() {
}
val maxRes = Settings.get().maxResolution.value.ceiling
fun heightOf(q: String?): Int =
q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0
// Audit HIGH-8: when no stream is under the resolution ceiling
// (e.g. user picked 144p but the video only has 360p+), fall
// back to the lowest-resolution available instead of returning
// null and showing a black-screen player.
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
if (streams.isNullOrEmpty()) return null
val withContent = streams.filter { it.content?.isNotBlank() == true }
val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes }
val pool = filtered.ifEmpty { withContent }
return pool.maxByOrNull { it.bitrate ?: 0 }?.content
// Audit HIGH-8 carry-over: filter by max resolution but fall
// back to lowest available if the ceiling excludes everything.
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
if (streams.isEmpty()) return null
val filtered = streams.filter { it.height <= maxRes }
val pool = filtered.ifEmpty { streams }
return pool.maxByOrNull { it.bitrate }?.url
}
val combined = pickVideo(info.videoStreams)
val videoOnly = pickVideo(info.videoOnlyStreams)
val audioOnly = info.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val combined = pickVideo(info.combined)
val videoOnly = pickVideo(info.videoOnly)
val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url
_ui.value = PlayerUiState(
loading = false,
resolved = ResolvedPlayback(
title = info.name ?: "",
title = info.title,
videoUrl = videoOnly,
audioUrl = audioOnly,
combinedUrl = combined,

View file

@ -8,17 +8,10 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History
import com.sulkta.straw.util.bestThumbnail
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 = "",
@ -52,7 +45,23 @@ class SearchViewModel : ViewModel() {
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) { search(q) }
// Phase U-2 / Path C-3: rustypipe via UniFFI. The bindgen-generated
// `search()` is already a suspend fun running on the tokio runtime
// baked into libstrawcore.so — no Dispatchers.IO wrapper needed,
// the JNI call returns to us on the caller dispatcher when the
// future completes.
val rustItems = uniffi.strawcore.search(q)
val items = rustItems.map { r ->
StreamItem(
url = r.url,
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
)
}
_ui.value = _ui.value.copy(loading = false, results = items)
} catch (t: Throwable) {
_ui.value = _ui.value.copy(
@ -62,23 +71,4 @@ class SearchViewModel : ViewModel() {
}
}
}
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 ?: "",
uploaderUrl = it.uploaderUrl,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
}
}
}

View file

@ -13,9 +13,34 @@
package com.sulkta.straw.net
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okio.Buffer
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Path C-6 / Phase U-5: USER_AGENT + shared OkHttpClient that previously
* lived on NewPipeDownloader. After ripping NewPipeExtractor, the RYD +
* SponsorBlock + ExoPlayer HTTP factories still need both. One shared
* client is fine.
*/
const val STRAW_USER_AGENT: String =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
@Volatile
private var sharedClient: OkHttpClient? = null
fun strawHttpClient(): OkHttpClient =
sharedClient ?: synchronized(STRAW_USER_AGENT) {
sharedClient ?: OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
.also { sharedClient = it }
}
fun ResponseBody.cappedString(maxBytes: Long): String {
val cl = contentLength()

View file

@ -8,7 +8,6 @@
package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
@ -34,11 +33,11 @@ object RydClient {
strawLogD(TAG) { "fetch start: $videoId$url" }
val req = Request.Builder()
.url(url)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json")
.build()
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
strawHttpClient().newCall(req).execute().use { r ->
val code = r.code
// AUD-HIGH: bounded body read to defend against OOM.
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""

View file

@ -8,7 +8,6 @@
package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
@ -47,11 +46,11 @@ object SponsorBlockClient {
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
val req = Request.Builder()
.url(urlStr)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json")
.build()
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
strawHttpClient().newCall(req).execute().use { r ->
val code = r.code
// AUD-HIGH: bounded body read.
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""

View file

@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* NewPipeExtractor returns thumbnails as a List<Image> with width/height
* fields. Calling .firstOrNull() picks the smallest (the list is sorted
* ascending) which gave us pixelated thumbnails. This helper picks the
* largest by pixel area instead.
*/
package com.sulkta.straw.util
import org.schabi.newpipe.extractor.Image
fun bestThumbnail(images: List<Image>?): String? {
if (images.isNullOrEmpty()) return null
return images
.maxByOrNull {
val w = it.width.takeIf { v -> v > 0 } ?: 0
val h = it.height.takeIf { v -> v > 0 } ?: 0
w.toLong() * h.toLong()
}
?.url
}