diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 63f2118de..1255794b2 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -9,6 +9,29 @@ const val STRAW_SDK_TARGET = 35 // Sulkta fork — Straw // +// vc=86 / 0.1.0-CT — audit-fix sprint (code-audit HIGH H2 + 5 MED/LOW): +// * Audio-only toggle no longer drops your max-resolution cap. Both the +// fullscreen button and the detail "Audio" pill rebuilt the track- +// selection params from a fresh Builder, wiping the data-saver ceiling; +// they now buildUpon() the existing params so the cap survives. +// * Subscriptions "Refresh" button no longer sticks at "..." forever on a +// warm restart within the cache TTL — refreshIfStale now clears the +// initial loading seed when it decides nothing needs refreshing. +// * Search + Channel result lists carry a stable item key (the video url) +// so appending a page / filtering shorts stops re-binding rows to new +// data (which re-triggered thumbnail loads + shifted scroll). +// * iOS-safe data source: the unknown-length (LENGTH_UNSET) chunk path now +// rolls forward to the next Range chunk at inner-EOF instead of +// re-reading the exhausted source forever (was truncating playback to +// the first chunk on any inner that reports no length). +// * strawcore channel_feed_rss now PROPAGATES the real failure (network / +// HTTP / parse) instead of collapsing every error to an empty list, so a +// broken channel fetch is distinguishable from "no new videos" (the +// bulk subscription_feed keeps its per-channel tolerance). +// * Feed recency: a clock-skewed future upload emits "0 seconds ago" +// (parses to top) instead of "just now" (which the Kotlin recency parser +// couldn't read → sank the item to the bottom). +// // vc=85 / 0.1.0-CS — image caching + SB/RYD → Rust + crash/autoplay fixes: // * Thumbnails + channel icons stay cached. Coil's default disk cache is // only 2% of the phone's FREE space, so on a storage-tight device the @@ -202,6 +225,6 @@ const val STRAW_SDK_TARGET = 35 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 85 -const val STRAW_VERSION_NAME = "0.1.0-CS" +const val STRAW_VERSION_CODE = 86 +const val STRAW_VERSION_NAME = "0.1.0-CT" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index bf4377636..bc5e5c92f 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -105,7 +105,13 @@ pub async fn channel_feed_rss( crate::runtime::ensure_initialized(); log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len()); let client = rss_client()?; - Ok(fetch_channel_rss(client, &channel_url).await.unwrap_or_default()) + // Propagate the real failure (network / HTTP / parse) instead of + // collapsing it to an empty Vec. The single-channel caller (the subs + // feed) needs to tell "this channel posted nothing" apart from "this + // fetch broke" — the prior `.unwrap_or_default()` made the `Result` + // a lie. (subscription_feed keeps its own per-channel unwrap_or_default + // below — fan-out tolerance is correct there, not here.) + fetch_channel_rss(client, &channel_url).await } /// Bulk subscription feed fan-out — for callers that want one round-trip @@ -138,20 +144,34 @@ pub async fn subscription_feed( Ok(results.into_iter().flatten().collect()) } -async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option> { - let channel_id = extract_channel_id(channel_url)?; +async fn fetch_channel_rss( + client: &Client, + channel_url: &str, +) -> Result, StrawcoreError> { + // A URL we can't pull a channel id from isn't a *failure* — it's an + // unsupported shape (e.g. an `@handle`, which RSS can't take). Report + // "no videos", not an error, so it doesn't trip the caller's retry. + let Some(channel_id) = extract_channel_id(channel_url) else { + return Ok(Vec::new()); + }; let url = format!("{RSS_BASE}{channel_id}"); + // .without_url() on the error: the rule is to never let a reqwest + // error's URL reach a log/message (these RSS URLs carry no secret, but + // we keep the discipline uniform). let resp = client .get(&url) .send() .await - .ok()? + .map_err(|e| StrawcoreError::Network { msg: format!("rss fetch: {}", e.without_url()) })? .error_for_status() - .ok()?; + .map_err(|e| StrawcoreError::Network { msg: format!("rss status: {}", e.without_url()) })?; // Streaming body read with a hard byte cap — `.text()` reads // unbounded into a String. Shared with the RYD/SB path (net.rs). - let body = crate::net::read_capped_body(resp, RSS_MAX_BYTES).await?; + let body = crate::net::read_capped_body(resp, RSS_MAX_BYTES) + .await + .ok_or_else(|| StrawcoreError::Network { msg: "rss body exceeded cap".into() })?; parse_rss(&body, channel_id) + .ok_or_else(|| StrawcoreError::Extractor { msg: "rss parse failed".into() }) } /// Extract the `UCxxx` channel ID from a channel URL. Accepts the @@ -403,7 +423,13 @@ fn iso_to_relative(iso: &str) -> String { // a single skewed item doesn't pin itself at the top of the // feed. if secs > now_secs { - return "just now".to_string(); + // "0 seconds ago", NOT "just now": the Kotlin recencyScore parser + // only matches `(\d+)\s+unit(s)?\s+ago`, so "just now" fails to + // parse → Long.MIN_VALUE → the item sinks to the BOTTOM of the feed + // (the opposite of intent — a fresh upload on a forward-skewed clock + // would vanish to the end). "0 seconds ago" parses to score 0 = top, + // and matches what format_relative(0) emits for a now-ish timestamp. + return "0 seconds ago".to_string(); } format_relative(now_secs - secs) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 382a77c48..1935150b3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -184,7 +184,7 @@ fun ChannelScreen( } HorizontalDivider() } - items(filteredVideos) { item -> + items(filteredVideos, key = { it.url }) { item -> ChannelVideoRow( item = item, onClick = { onOpenVideo(item.url, item.title) }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt index 6da3cd03a..a08fda2f5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt @@ -327,7 +327,10 @@ fun VideoDetailBody( uploaderUrl = d.uploaderUrl, ) } - c.trackSelectionParameters = TrackSelectionParameters.Builder(context) + // buildUpon() preserves the existing + // max-resolution cap; a fresh Builder + // would wipe it (data-saver leak). + c.trackSelectionParameters = c.trackSelectionParameters.buildUpon() .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) .build() if (!c.isPlaying) c.play() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index ce36b558b..913fdd9b4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -163,7 +163,16 @@ class SubscriptionFeedViewModel : ViewModel() { val entry = channelCache[ch.url] entry == null || now - entry.fetchedAt >= perChannelTtlMs } - if (anyStale || _ui.value.items.isEmpty()) refreshInternal(force = false) + if (anyStale || _ui.value.items.isEmpty()) { + refreshInternal(force = false) + } else { + // Everything is fresh and already on screen (warm restart within + // the per-channel TTL) — clear the initial loading=true seed so + // the Refresh button doesn't sit at "..." forever. The + // full-screen spinner is gated on empty items so it never showed; + // only StrawHome's button label ("..." vs "Refresh") was stuck. + _ui.update { it.copy(loading = false) } + } } fun refresh() = refreshInternal(force = true) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 2f1e51dc1..122816ecb 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -234,7 +234,13 @@ fun PlayerScreen( desc = if (audioOnly) "Audio-only on" else "Video on", ) { audioOnly = !audioOnly - controller.trackSelectionParameters = TrackSelectionParameters.Builder(context) + // buildUpon(), NOT a fresh Builder(context) — a new + // builder discards every existing constraint, most + // importantly the user's max-resolution cap from + // setPlayingFrom/applyMaxResolutionCap, so toggling + // audio-only off then streamed UNCAPPED resolution + // until the next setPlayingFrom (data-saver leak). + controller.trackSelectionParameters = controller.trackSelectionParameters.buildUpon() .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly) .build() Toast.makeText( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 8dd402d3e..ae61d27a6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -191,7 +191,7 @@ fun SearchScreen( modifier = Modifier.fillMaxSize(), contentPadding = rememberBottomContentPadding(), ) { - items(filteredResults) { item -> + items(filteredResults, key = { it.url }) { item -> ResultRow( item = item, onClick = { onOpenVideo(item.url, item.title) }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt index d0e1108ba..1e18b6e1a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt @@ -129,9 +129,14 @@ class IosSafeHttpDataSource( if (chunkRemaining > 0L) chunkRemaining -= read.toLong() // If chunkRemaining hits 0 here, the next read() call will roll // to the next chunk via the block at the top. - } else if (chunkRemaining > 0L) { - // Inner ran out before its advertised end. Force chunk roll on - // next read() so we re-open at the next position. + } else if (chunkRemaining != 0L) { + // Inner hit EOF. Force a chunk roll on the next read() so we + // re-open at the next position. `!= 0L` (not `> 0L`) so the + // unknown-end case (LENGTH_UNSET inner → chunkRemaining < 0) + // ALSO rolls: with `> 0L` a negative chunkRemaining never reset + // to 0, so the top-of-read() roll block (gated on `== 0L`) never + // fired and we re-read the exhausted inner forever — playback + // truncated to the first chunk. chunkRemaining = 0L } return read