Compare commits
9 commits
main
...
kayos/path
| Author | SHA1 | Date | |
|---|---|---|---|
| e410d0e92d | |||
| 979b4021b0 | |||
| b95565bec7 | |||
| 90930ade11 | |||
| 198d2a9066 | |||
| 47e037ee62 | |||
| 7968bbb8e6 | |||
| 93297ad0a0 | |||
| 54458f3d40 |
19 changed files with 315 additions and 376 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -38,3 +38,9 @@ rust/target/
|
||||||
strawApp/src/main/jniLibs/
|
strawApp/src/main/jniLibs/
|
||||||
# UniFFI-generated Kotlin bindings (regen'd from .so on every build)
|
# UniFFI-generated Kotlin bindings (regen'd from .so on every build)
|
||||||
strawApp/src/main/java/uniffi/
|
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/
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
|
||||||
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// Sulkta fork — Straw
|
||||||
const val STRAW_VERSION_CODE = 15
|
const val STRAW_VERSION_CODE = 16
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-AA"
|
const val STRAW_VERSION_NAME = "0.1.0-AB"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,27 @@ crate-type = ["cdylib", "staticlib"]
|
||||||
uniffi = { version = "0.28", features = ["cli", "tokio"] }
|
uniffi = { version = "0.28", features = ["cli", "tokio"] }
|
||||||
# Tokio multi-thread runtime — rustypipe is async-first.
|
# Tokio multi-thread runtime — rustypipe is async-first.
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
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
|
# 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).
|
# 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)
|
# rquickjs-sys (transitive dep of rustypipe for YT signature decryption JS)
|
||||||
# doesn't ship prebuilt Android bindings. Direct-depend with `bindgen` feature
|
# doesn't ship prebuilt Android bindings. Direct-depend with `bindgen` feature
|
||||||
# so it generates them at build time. Crafting-table has libclang preinstalled.
|
# so it generates them at build time. Crafting-table has libclang preinstalled.
|
||||||
|
|
|
||||||
|
|
@ -129,19 +129,15 @@ pub async fn stream_info(url: String) -> Result<StreamInfo, StrawcoreError> {
|
||||||
log::info!("strawcore::stream_info id={}", id);
|
log::info!("strawcore::stream_info id={}", id);
|
||||||
let rp = RustyPipe::new();
|
let rp = RustyPipe::new();
|
||||||
|
|
||||||
// rustypipe's default `player()` uses the Web client first, which
|
// Use the fork's audit-fixed default client order. As of
|
||||||
// returns signed URLs that need JS deobfuscation. Even the TV (TVHTML5)
|
// v0.11.5-sulkta.2 that's [Ios, Tv] without botguard — iOS first
|
||||||
// client signs URLs nowadays, so deobfuscation runs and currently
|
// because it skips player.js deobfuscation AND doesn't require
|
||||||
// fails ("could not extract sig fn name") because YT changed the
|
// device attestation. Android is intentionally NOT in the default
|
||||||
// obfuscation pattern after rustypipe 0.11.4's last cut.
|
// order: needs_po_token doesn't flag Android, so unsigned requests
|
||||||
//
|
// get YT's "Precondition check failed" / "Sign in to confirm
|
||||||
// Android and iOS YT-app clients serve URLs UNSIGNED — no sig
|
// you're not a bot" rejection, which is environmental-non-switchable.
|
||||||
// decryption needed, ExoPlayer plays them directly. This is the same
|
// Re-add Android when a real po_token strategy lands.
|
||||||
// path NewPipe uses for its mobile + iOS-embed strategies.
|
let player = rp.query().player(&id).await?;
|
||||||
let player = rp
|
|
||||||
.query()
|
|
||||||
.player_from_clients(&id, &[ClientType::Android, ClientType::Ios])
|
|
||||||
.await?;
|
|
||||||
let details = &player.details;
|
let details = &player.details;
|
||||||
|
|
||||||
// Progressive (combined audio+video) goes through video_streams; the
|
// Progressive (combined audio+video) goes through video_streams; the
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,9 @@ dependencies {
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|
||||||
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep
|
// 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)
|
implementation(libs.squareup.okhttp)
|
||||||
|
|
||||||
// JSON for SponsorBlock + Return YouTube Dislike clients
|
// JSON for SponsorBlock + Return YouTube Dislike clients
|
||||||
|
|
@ -110,4 +112,95 @@ dependencies {
|
||||||
implementation("androidx.media3:media3-session:1.4.1")
|
implementation("androidx.media3:media3-session:1.4.1")
|
||||||
// Guava ListenableFuture support for awaiting MediaController connect.
|
// Guava ListenableFuture support for awaiting MediaController connect.
|
||||||
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
|
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) }
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,13 @@ import android.app.Application
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.data.Subscriptions
|
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() {
|
class StrawApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
NewPipe.init(
|
// Path C-6 / Phase U-5: NewPipeExtractor is out. strawcore (Rust)
|
||||||
NewPipeDownloader.init(),
|
// loads its own libstrawcore.so via JNA when first called — no
|
||||||
Localization("en", "US"),
|
// explicit init needed here. Just bootstrap the local stores.
|
||||||
ContentCountry("US"),
|
|
||||||
)
|
|
||||||
History.init(this)
|
History.init(this)
|
||||||
Settings.init(this)
|
Settings.init(this)
|
||||||
Subscriptions.init(this)
|
Subscriptions.init(this)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
||||||
|
*
|
||||||
|
* 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
|
package com.sulkta.straw.feature.channel
|
||||||
|
|
@ -8,19 +12,10 @@ package com.sulkta.straw.feature.channel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
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(
|
data class ChannelUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
|
|
@ -40,43 +35,24 @@ class ChannelViewModel : ViewModel() {
|
||||||
_ui.value = ChannelUiState(loading = true)
|
_ui.value = ChannelUiState(loading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
val ch = uniffi.strawcore.channelInfo(channelUrl)
|
||||||
val info = withContext(Dispatchers.IO) {
|
val videos = ch.videos.map { v ->
|
||||||
ChannelInfo.getInfo(service, channelUrl)
|
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(
|
_ui.value = ChannelUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
name = info.name ?: "",
|
name = ch.name,
|
||||||
subscriberCount = info.subscriberCount,
|
subscriberCount = ch.subscriberCount,
|
||||||
banner = bestThumbnail(info.banners),
|
banner = ch.banner,
|
||||||
avatar = bestThumbnail(info.avatars),
|
avatar = ch.avatar,
|
||||||
videos = videos,
|
videos = videos,
|
||||||
)
|
)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ import androidx.media3.exoplayer.source.MergingMediaSource
|
||||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import coil3.compose.AsyncImage
|
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.formatCount
|
||||||
import com.sulkta.straw.util.formatViews
|
import com.sulkta.straw.util.formatViews
|
||||||
import com.sulkta.straw.util.stripHtml
|
import com.sulkta.straw.util.stripHtml
|
||||||
|
|
@ -284,10 +284,8 @@ fun VideoDetailScreen(
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
val audio = info?.audioStreams
|
// info is now uniffi.strawcore.StreamInfo (Path C-4).
|
||||||
?.filter { it.content?.isNotBlank() == true }
|
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
|
||||||
?.content
|
|
||||||
if (audio != null) {
|
if (audio != null) {
|
||||||
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
|
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
|
||||||
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
|
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
|
||||||
|
|
@ -298,14 +296,8 @@ fun VideoDetailScreen(
|
||||||
showDownloadDialog = false
|
showDownloadDialog = false
|
||||||
}) { Text("Audio") }
|
}) { Text("Audio") }
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
val video = info?.videoStreams
|
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
|
||||||
?.filter { it.content?.isNotBlank() == true }
|
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
|
||||||
?.content
|
|
||||||
?: info?.videoOnlyStreams
|
|
||||||
?.filter { it.content?.isNotBlank() == true }
|
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
|
||||||
?.content
|
|
||||||
if (video != null) {
|
if (video != null) {
|
||||||
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
|
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
|
||||||
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
|
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
|
||||||
|
|
@ -417,7 +409,7 @@ private fun InlinePlayer(
|
||||||
LaunchedEffect(resolved) {
|
LaunchedEffect(resolved) {
|
||||||
val r = resolved ?: return@LaunchedEffect
|
val r = resolved ?: return@LaunchedEffect
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
.setUserAgent(STRAW_USER_AGENT)
|
||||||
.setAllowCrossProtocolRedirects(true)
|
.setAllowCrossProtocolRedirects(true)
|
||||||
val source = when {
|
val source = when {
|
||||||
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
|
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
||||||
|
*
|
||||||
|
* 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
|
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.RydClient
|
||||||
import com.sulkta.straw.net.RydVotes
|
import com.sulkta.straw.net.RydVotes
|
||||||
import com.sulkta.straw.net.SponsorBlockClient
|
import com.sulkta.straw.net.SponsorBlockClient
|
||||||
import com.sulkta.straw.util.bestThumbnail
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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(
|
data class VideoDetail(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|
@ -48,8 +44,8 @@ data class VideoDetailUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val detail: VideoDetail? = null,
|
val detail: VideoDetail? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
// Stored on success for handoff to player. Not in UI.
|
// Stored on success for handoff to the player + Download dialog. Not in UI.
|
||||||
val streamInfo: StreamInfo? = null,
|
val streamInfo: uniffi.strawcore.StreamInfo? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class VideoDetailViewModel : ViewModel() {
|
class VideoDetailViewModel : ViewModel() {
|
||||||
|
|
@ -66,11 +62,12 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
_ui.value = VideoDetailUiState(loading = true)
|
_ui.value = VideoDetailUiState(loading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
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 videoId = info.id
|
||||||
val thumb = bestThumbnail(info.thumbnails)
|
val thumb = info.thumbnail
|
||||||
val title = info.name ?: "(no title)"
|
val title = info.title.ifBlank { "(no title)" }
|
||||||
val uploader = info.uploaderName ?: ""
|
val uploader = info.uploader
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
History.get().recordWatch(
|
History.get().recordWatch(
|
||||||
|
|
@ -92,51 +89,43 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
|
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
|
||||||
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0)
|
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
|
// strawcore returns `related` as List<SearchItem>. Map to the
|
||||||
// already chose. Best-effort: empty if the fetch fails so the
|
// Kotlin StreamItem shape used elsewhere.
|
||||||
// detail screen still renders. Filters out the current video.
|
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> =
|
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
|
||||||
if (info.uploaderUrl.isNullOrBlank()) emptyList()
|
if (uploaderUrl.isNullOrBlank()) emptyList()
|
||||||
else withContext(Dispatchers.IO) {
|
else runCatching {
|
||||||
runCatching {
|
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
|
||||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
ch.videos
|
||||||
val ch = ChannelInfo.getInfo(service, info.uploaderUrl)
|
.filter { it.url != streamUrl }
|
||||||
val videosTab = ch.tabs.firstOrNull {
|
.take(20)
|
||||||
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
.map { v ->
|
||||||
} ?: ch.tabs.firstOrNull()
|
com.sulkta.straw.feature.search.StreamItem(
|
||||||
if (videosTab == null) emptyList()
|
url = v.url,
|
||||||
else ChannelTabInfo.getInfo(service, videosTab)
|
title = v.title.ifBlank { "(no title)" },
|
||||||
.relatedItems
|
uploader = v.uploader.ifBlank { uploader },
|
||||||
.filterIsInstance<StreamInfoItem>()
|
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
|
||||||
.filter { it.url != streamUrl }
|
thumbnail = v.thumbnail,
|
||||||
.take(20)
|
durationSeconds = v.durationSeconds,
|
||||||
.map { si ->
|
viewCount = v.viewCount,
|
||||||
com.sulkta.straw.feature.search.StreamItem(
|
)
|
||||||
url = si.url,
|
}
|
||||||
title = si.name ?: "(no title)",
|
}.getOrDefault(emptyList())
|
||||||
uploader = si.uploaderName ?: uploader,
|
|
||||||
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
|
|
||||||
thumbnail = bestThumbnail(si.thumbnails),
|
|
||||||
durationSeconds = si.duration,
|
|
||||||
viewCount = si.viewCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
_ui.value = VideoDetailUiState(
|
_ui.value = VideoDetailUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
|
|
@ -146,7 +135,7 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
uploader = uploader,
|
uploader = uploader,
|
||||||
uploaderUrl = info.uploaderUrl,
|
uploaderUrl = info.uploaderUrl,
|
||||||
viewCount = info.viewCount,
|
viewCount = info.viewCount,
|
||||||
description = info.description?.content ?: "",
|
description = info.description,
|
||||||
thumbnail = thumb,
|
thumbnail = thumb,
|
||||||
ryd = ryd,
|
ryd = ryd,
|
||||||
sbSegmentCount = sbCount,
|
sbSegmentCount = sbCount,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* Phase Q: aggregate latest videos across all subscribed channels into a
|
* Phase Q: aggregate latest videos across all subscribed channels into a
|
||||||
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
|
* single feed. Fans out per-channel channelInfo() fetches in parallel,
|
||||||
* fetches in parallel, merges by view count desc, caps at 200 items.
|
* 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):
|
* Audit fixes (2026-05-24 pass #2):
|
||||||
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap
|
* 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 androidx.lifecycle.viewModelScope
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
import com.sulkta.straw.util.bestThumbnail
|
|
||||||
import com.sulkta.straw.util.strawLogW
|
import com.sulkta.straw.util.strawLogW
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -34,14 +36,7 @@ import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
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(
|
data class SubscriptionFeedUiState(
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
|
|
@ -82,55 +77,46 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
_ui.update { it.copy(loading = true, error = null) }
|
_ui.update { it.copy(loading = true, error = null) }
|
||||||
inFlight = viewModelScope.launch {
|
inFlight = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val items = withContext(Dispatchers.IO) {
|
val perChannelMax = 5
|
||||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
val gate = Semaphore(parallelism)
|
||||||
val perChannelMax = 5
|
val items = coroutineScope {
|
||||||
val gate = Semaphore(parallelism)
|
val deferreds = channels.map { ch ->
|
||||||
coroutineScope {
|
async {
|
||||||
val deferreds = channels.map { ch ->
|
gate.withPermit {
|
||||||
async {
|
withTimeoutOrNull(perChannelTimeoutMs) {
|
||||||
gate.withPermit {
|
runCatching {
|
||||||
withTimeoutOrNull(perChannelTimeoutMs) {
|
val info = uniffi.strawcore.channelInfo(ch.url)
|
||||||
runCatching {
|
info.videos
|
||||||
val info = ChannelInfo.getInfo(service, ch.url)
|
.take(perChannelMax)
|
||||||
val tab = info.tabs.firstOrNull {
|
.map { v ->
|
||||||
it.contentFilters.contains(ChannelTabs.VIDEOS)
|
StreamItem(
|
||||||
} ?: info.tabs.firstOrNull()
|
url = v.url,
|
||||||
?: return@runCatching emptyList<StreamItem>()
|
title = v.title.ifBlank { "(no title)" },
|
||||||
ChannelTabInfo.getInfo(service, tab)
|
uploader = v.uploader.ifBlank { ch.name },
|
||||||
.relatedItems
|
uploaderUrl = v.uploaderUrl ?: ch.url,
|
||||||
.filterIsInstance<StreamInfoItem>()
|
thumbnail = v.thumbnail,
|
||||||
.take(perChannelMax)
|
durationSeconds = v.durationSeconds,
|
||||||
.map { si ->
|
viewCount = v.viewCount,
|
||||||
StreamItem(
|
)
|
||||||
url = si.url,
|
}
|
||||||
title = si.name ?: "(no title)",
|
}.onFailure {
|
||||||
uploader = si.uploaderName ?: ch.name,
|
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
|
||||||
uploaderUrl = si.uploaderUrl ?: ch.url,
|
}.getOrDefault(emptyList())
|
||||||
thumbnail = bestThumbnail(si.thumbnails),
|
} ?: run {
|
||||||
durationSeconds = si.duration,
|
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
|
||||||
viewCount = si.viewCount,
|
emptyList()
|
||||||
)
|
|
||||||
}
|
|
||||||
}.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()
|
deferreds.awaitAll()
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
_ui.update {
|
||||||
SubscriptionFeedUiState(
|
SubscriptionFeedUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
import com.sulkta.straw.StrawActivity
|
import com.sulkta.straw.StrawActivity
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
import com.sulkta.straw.net.STRAW_USER_AGENT
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
|
|
@ -63,7 +63,7 @@ class PlaybackService : MediaSessionService() {
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
|
|
||||||
val httpFactory = DefaultHttpDataSource.Factory()
|
val httpFactory = DefaultHttpDataSource.Factory()
|
||||||
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
.setUserAgent(STRAW_USER_AGENT)
|
||||||
.setAllowCrossProtocolRedirects(true)
|
.setAllowCrossProtocolRedirects(true)
|
||||||
val mediaSourceFactory = DefaultMediaSourceFactory(this)
|
val mediaSourceFactory = DefaultMediaSourceFactory(this)
|
||||||
.setDataSourceFactory(httpFactory)
|
.setDataSourceFactory(httpFactory)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
import androidx.media3.ui.PlayerView
|
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.net.SbSegment
|
||||||
import com.sulkta.straw.util.strawLogI
|
import com.sulkta.straw.util.strawLogI
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -169,7 +169,7 @@ fun PlayerScreen(
|
||||||
LaunchedEffect(resolved) {
|
LaunchedEffect(resolved) {
|
||||||
val r = resolved ?: return@LaunchedEffect
|
val r = resolved ?: return@LaunchedEffect
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
.setUserAgent(NewPipeDownloader.USER_AGENT)
|
.setUserAgent(STRAW_USER_AGENT)
|
||||||
.setAllowCrossProtocolRedirects(true)
|
.setAllowCrossProtocolRedirects(true)
|
||||||
|
|
||||||
val source = when {
|
val source = when {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
||||||
|
*
|
||||||
|
* 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
|
package com.sulkta.straw.feature.player
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.sulkta.straw.data.MaxResolution
|
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.net.SbSegment
|
import com.sulkta.straw.net.SbSegment
|
||||||
import com.sulkta.straw.net.SponsorBlockClient
|
import com.sulkta.straw.net.SponsorBlockClient
|
||||||
|
|
@ -17,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
|
||||||
|
|
||||||
data class ResolvedPlayback(
|
data class ResolvedPlayback(
|
||||||
val title: String,
|
val title: String,
|
||||||
|
|
@ -48,8 +50,11 @@ class PlayerViewModel : ViewModel() {
|
||||||
_ui.value = PlayerUiState(loading = true)
|
_ui.value = PlayerUiState(loading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
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 videoId = info.id
|
||||||
|
|
||||||
val sbCategories = Settings.get().sbCategories.value.map { it.key }
|
val sbCategories = Settings.get().sbCategories.value.map { it.key }
|
||||||
val segments = if (sbCategories.isEmpty()) {
|
val segments = if (sbCategories.isEmpty()) {
|
||||||
emptyList()
|
emptyList()
|
||||||
|
|
@ -61,32 +66,24 @@ class PlayerViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val maxRes = Settings.get().maxResolution.value.ceiling
|
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
|
// Audit HIGH-8 carry-over: filter by max resolution but fall
|
||||||
// (e.g. user picked 144p but the video only has 360p+), fall
|
// back to lowest available if the ceiling excludes everything.
|
||||||
// back to the lowest-resolution available instead of returning
|
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
|
||||||
// null and showing a black-screen player.
|
if (streams.isEmpty()) return null
|
||||||
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
|
val filtered = streams.filter { it.height <= maxRes }
|
||||||
if (streams.isNullOrEmpty()) return null
|
val pool = filtered.ifEmpty { streams }
|
||||||
val withContent = streams.filter { it.content?.isNotBlank() == true }
|
return pool.maxByOrNull { it.bitrate }?.url
|
||||||
val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes }
|
|
||||||
val pool = filtered.ifEmpty { withContent }
|
|
||||||
return pool.maxByOrNull { it.bitrate ?: 0 }?.content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val combined = pickVideo(info.videoStreams)
|
val combined = pickVideo(info.combined)
|
||||||
val videoOnly = pickVideo(info.videoOnlyStreams)
|
val videoOnly = pickVideo(info.videoOnly)
|
||||||
val audioOnly = info.audioStreams
|
val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url
|
||||||
?.filter { it.content?.isNotBlank() == true }
|
|
||||||
?.maxByOrNull { it.bitrate ?: 0 }
|
|
||||||
?.content
|
|
||||||
|
|
||||||
_ui.value = PlayerUiState(
|
_ui.value = PlayerUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
resolved = ResolvedPlayback(
|
resolved = ResolvedPlayback(
|
||||||
title = info.name ?: "",
|
title = info.title,
|
||||||
videoUrl = videoOnly,
|
videoUrl = videoOnly,
|
||||||
audioUrl = audioOnly,
|
audioUrl = audioOnly,
|
||||||
combinedUrl = combined,
|
combinedUrl = combined,
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,10 @@ package com.sulkta.straw.feature.search
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.sulkta.straw.data.History
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
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(
|
data class SearchUiState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
|
|
@ -52,7 +45,23 @@ class SearchViewModel : ViewModel() {
|
||||||
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
|
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
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)
|
_ui.value = _ui.value.copy(loading = false, results = items)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_ui.value = _ui.value.copy(
|
_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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,34 @@
|
||||||
|
|
||||||
package com.sulkta.straw.net
|
package com.sulkta.straw.net
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import java.io.IOException
|
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 {
|
fun ResponseBody.cappedString(maxBytes: Long): String {
|
||||||
val cl = contentLength()
|
val cl = contentLength()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
package com.sulkta.straw.net
|
package com.sulkta.straw.net
|
||||||
|
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
|
||||||
import com.sulkta.straw.util.strawLogD
|
import com.sulkta.straw.util.strawLogD
|
||||||
import com.sulkta.straw.util.strawLogW
|
import com.sulkta.straw.util.strawLogW
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -34,11 +33,11 @@ object RydClient {
|
||||||
strawLogD(TAG) { "fetch start: $videoId → $url" }
|
strawLogD(TAG) { "fetch start: $videoId → $url" }
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
.header("User-Agent", STRAW_USER_AGENT)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build()
|
.build()
|
||||||
return runCatching {
|
return runCatching {
|
||||||
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
strawHttpClient().newCall(req).execute().use { r ->
|
||||||
val code = r.code
|
val code = r.code
|
||||||
// AUD-HIGH: bounded body read to defend against OOM.
|
// AUD-HIGH: bounded body read to defend against OOM.
|
||||||
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""
|
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
package com.sulkta.straw.net
|
package com.sulkta.straw.net
|
||||||
|
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
|
||||||
import com.sulkta.straw.util.strawLogD
|
import com.sulkta.straw.util.strawLogD
|
||||||
import com.sulkta.straw.util.strawLogW
|
import com.sulkta.straw.util.strawLogW
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -47,11 +46,11 @@ object SponsorBlockClient {
|
||||||
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
|
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url(urlStr)
|
.url(urlStr)
|
||||||
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
.header("User-Agent", STRAW_USER_AGENT)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build()
|
.build()
|
||||||
return runCatching {
|
return runCatching {
|
||||||
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
strawHttpClient().newCall(req).execute().use { r ->
|
||||||
val code = r.code
|
val code = r.code
|
||||||
// AUD-HIGH: bounded body read.
|
// AUD-HIGH: bounded body read.
|
||||||
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""
|
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue