Skip to content

fix(feed): cap playback length with native clipping - #6429

Merged
rabble merged 3 commits into
mainfrom
feat/6421-feed-playback-duration-cap
Jul 28, 2026
Merged

fix(feed): cap playback length with native clipping#6429
rabble merged 3 commits into
mainfrom
feat/6421-feed-playback-duration-cap

Conversation

@hm21

@hm21 hm21 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Description

Restores the feed-side guarantee that nothing plays longer than a Vine, implemented as native clipping rather than the Dart-side seekTo(Duration.zero) that #5551 removed.

Why the rule went missing. The old maxLoopDuration: VideoEditorConstants.maxDuration did two jobs at once: it enforced the product rule (no 30/60s videos in the feed, including kind-34236 events from foreign clients pointing at arbitrarily long files) and it implemented that rule as a Dart seek. The seek was the direct cause of the audible loop seam in #5544, so #5551 removed the whole setting — and the product rule left with it. There is no duration guard anywhere in the app today; a 60s file referenced by another Nostr client plays in full.

Why clipping works where the seek didn't. The loop point now lives in the native player rather than in a Dart roundtrip, so there is no seam to produce. Android sets MediaItem.ClippingConfiguration.endPositionMs and REPEAT_MODE_ALL repeats the clipped item; iOS trims the AVMutableComposition via insertTimeRange and AVPlayerLooper loops the trimmed composition. Both were already plumbed through the method channel as startMs/endMs — this PR only starts using them from the feed.

Why 7s and not 6.3s. VideoEditorConstants.maxDuration is the recording limit and stays 6.3s. Classic Vine assets are not 6.3s — measured with ffprobe on media.divine.video they run 6.500–6.533s. Re-clipping at the recording limit would cut ~200ms off every classic Vine and destroy the musical loop point on exactly the beatbox content #5544 and #6386 complain about. The new AppConstants.maxFeedPlaybackDuration is a separate, named playback constant set to 7s.

Why the cap applies to every source, not just non-Divine ones. The issue floated capping only foreign sources. Divine's own export is already bounded at 6.3s (renderVideo(maxOutputDuration:)) and classic Vines are ≤6.533s, so a 7s cap is a no-op for both. A source-conditional branch would buy nothing and leave a hole for anything that slips past the source check.

Why all four open paths. First load, cache hit, source failover and the HTTP-202 processing retry each re-open the player independently, so a missed site would let a long video escape the cap on that path alone. The contract test counts VideoClip constructions against cap applications, so a future path cannot skip it silently.

Why the Swift change. ExoPlayer clamps endPositionMs to the real duration itself (ClippingTimeline: if (endUs > window.durationUs) endUs = window.durationUs). AVFoundation clamps what insertTimeRange actually inserts, but the Swift side computed clipDuration from the requested end regardless — so every 6.533s Vine would have reported a 7.0s duration while looping at 6.533s, desyncing every duration readout. endTime is now clamped to the asset duration, matching ExoPlayer.

maxLoopDuration and its seek path are untouched and still unused by the feed; the existing contract assertion that feed_videos.dart contains no maxLoopDuration: stays green.

Related Issue: Closes #6421

Out of Scope

  • Removing the now-unused maxLoopDuration parameter and subscribeToLoopEnforcement from infinite_video_feed. Pre-existing dead-in-production code, unrelated to this fix, and deleting it would touch 16 test references in the package.
  • The Linux (media_kit) backend does not clamp a clip end past the source duration, so it reports the requested length. Linux is a dev-only desktop target that CI does not build, and clamping there would cost a duration probe per clip on the editor path. Documented on VideoClip.end as an Android/Apple guarantee instead.

Verification

  • flutter analyze lib test integration_test — clean (app), plus both touched packages.
  • dart format — clean.
  • infinite_video_feed: full suite, 231 tests, 100.00% line coverage (the package's CI gate).
  • divine_video_player: full suite, 257 tests. Coverage 99.49%, byte-identical to the same run on a clean tree — the four uncovered lines are pre-existing const-constructor declarations.
  • App: test/widgets/video_feed_item/ + test/screens/feed/, 460 tests.
  • New tests: cap threading through the fallback chain and the HTTP-202 retry (source_loader_test.dart); cap wired in feed_videos.dart, applied at every clip site, and clearing classic Vine length (feed_looping_contract_test.dart).
  • Negative check: removed one end: application on purpose and confirmed the new contract test goes red.
  • Confirmed ExoPlayer's clamp by disassembling ClippingMediaSource$ClippingTimeline from the media3 1.10.0 AAR rather than trusting the docs.

Manual test plan (pending — draft until these pass)

  • Real Samsung Galaxy device: a >7s video from an external Nostr client stops at 7s and loops from there, with no rebuffer at the loop point.
  • iOS: same video caps and loops, AVPlayerLooper still gapless over the trimmed composition.
  • Both platforms: a classic beatbox Vine plays to its natural end, loop point unchanged versus the current build, no truncation.

Type of Change

  • 🛠️ Bug fix (non-breaking change which fixes an issue)

#5551 removed maxLoopDuration to kill the audible loop seam its
Dart-side seekTo(zero) produced, and the product rule that setting also
enforced went with it: nothing in the feed plays longer than a Vine. A
60s file referenced by a foreign client's kind-34236 event has played in
full ever since — there is no duration guard anywhere in the app today.

Restore the cap as a native clip end so the loop point lives in the
platform player instead of a Dart roundtrip: Android clips the MediaItem
and repeats it, iOS trims the composition AVPlayerLooper loops over. The
cap is 7s rather than VideoEditorConstants.maxDuration, which is the
6.3s recording limit — classic Vine assets measure 6.500-6.533s, so
reusing it would cut the musical loop point off every one of them.

Every path that re-opens a player applies the cap (first load, cache
hit, source failover, HTTP-202 processing retry). The contract test
counts clip constructions against cap applications so a future path
cannot skip it silently.

AVFoundation clamps what insertTimeRange actually inserts, but the Swift
side computed clipDuration from the requested end regardless, so a
capped clip would have reported 7.0s for a 6.533s asset and desynced
every duration readout. Clamp endTime to the asset duration; ExoPlayer
already clamps its ClippingConfiguration the same way.
@hm21 hm21 self-assigned this Jul 27, 2026
@github-actions

This comment has been minimized.

hm21 added 2 commits July 27, 2026 10:42
The feed now caps every clip at 7s without knowing the source length, so
a backend that reports the requested end as the duration inflates it for
every shorter video. Android does this in ClippingTimeline and Apple got
the clamp in the previous commit; the web backend returned
`clipEnd - clipStart` unconditionally.

On app.divine.video that made every feed video report 7.0s. Auto-advance
arms at `duration - 1s`, so a 3s video never reached 6s, never armed, and
the feed stopped advancing — plus every progress readout was wrong.

The clamp lives in a plain Dart file rather than inline in the `_web.dart`
backend: that file cannot be imported from the VM, and `element.duration`
is read-only in a browser test, so an inline clamp would have stayed
untestable.
The cap guards matched raw file text, so commenting out the cap left them
green: `// end: widget.maxPlaybackDuration` keeps the substring, both
sides of the parity count stay equal, and the feed plays uncapped. Verified
by mutation before and after.

They now read through `scripts/lib/dart_code_only.awk`, the same filter the
design-system ratchets use, instead of a second hand-rolled stripper. The
clip regex widens to `VideoClip[.(]` so the unnamed constructor and the
asset/memory helpers cannot drop out of both counts together.

The app-side wiring moves from a grep to an assertion on the pumped
InfiniteVideoFeed, which no textual edit can defeat. The Apple clamp gets a
source contract next to the existing composition-guard one — it has no Dart
runtime surface and the package runs no Swift job, so nothing else would
notice a refactor dropping it.
@github-actions

This comment has been minimized.

@hm21
hm21 marked this pull request as ready for review July 27, 2026 08:55
@github-actions

Copy link
Copy Markdown

Mobile PR Preview

Preview refreshed for 565a5cd

Last refresh: 565a5cd at 2026-07-27 08:59:48 UTC (preview run)

Property Value
Preview URL https://6f144f75.openvine-app.pages.dev
Pages project openvine-app
Preview branch pr-6429
PR branch feat/6421-feed-playback-duration-cap
Commit 565a5cd

@rabble rabble left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. 7s native clip-end cap threaded through all four feed open paths; Swift clamps end to asset duration; web duration resolver clamps; parity contract test pins every clip site. Cross-PR note: textually conflicts with #6430 in source_loader.dart, infinite_video_feed.dart, and DivineVideoPlayerInstance.swift — semantically compatible (both only shorten the end), but whichever merges second will need a rebase at those three sites. Nit (non-blocking): feed_looping_contract_test.dart shells out to awk with CWD-relative paths — fine in CI (runs from mobile/) but non-hermetic; consider resolving paths from the script location.

@rabble
rabble merged commit 5d5d05e into main Jul 28, 2026
19 checks passed
@rabble
rabble deleted the feat/6421-feed-playback-duration-cap branch July 28, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

task(feed): reinstate playback duration cap via native clipping, above classic Vine length

2 participants