From f297987930f6d411f7e9b1ac69e3539c25eaeec7 Mon Sep 17 00:00:00 2001 From: hm21 Date: Mon, 27 Jul 2026 09:49:13 +0200 Subject: [PATCH 1/3] fix(feed): cap playback length with native clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- mobile/lib/constants/app_constants.dart | 17 +++++ .../widgets/video_feed_item/feed_videos.dart | 10 ++- .../DivineVideoPlayerInstance.swift | 13 +++- .../lib/src/video_clip.dart | 5 +- .../lib/src/utils/source_loader.dart | 7 ++ .../lib/src/widgets/infinite_video_feed.dart | 26 +++++++- .../test/src/utils/source_loader_test.dart | 42 ++++++++++++ .../feed_looping_contract_test.dart | 65 +++++++++++++++++++ 8 files changed, 179 insertions(+), 6 deletions(-) diff --git a/mobile/lib/constants/app_constants.dart b/mobile/lib/constants/app_constants.dart index 3c4f9c219a..819245a5aa 100644 --- a/mobile/lib/constants/app_constants.dart +++ b/mobile/lib/constants/app_constants.dart @@ -54,6 +54,23 @@ class AppConstants { /// Minimum following videos needed before loading discovery feed static const int followingVideoThreshold = 5; + // ============================================================================ + // FEED PLAYBACK + // ============================================================================ + + /// Hard cap on how long any feed video plays before it loops. + /// + /// Nothing in the feed plays longer than a Vine, including kind-34236 events + /// published by other Nostr clients that point at arbitrarily long files. + /// The cap is applied as a native clip end (`VideoClip.end`), so the loop + /// point stays inside the platform player — a Dart-side seek-to-zero is what + /// produced the audible loop seam in #5544. + /// + /// Deliberately above `VideoEditorConstants.maxDuration` (6.3s, the + /// *recording* limit): classic Vine assets measure 6.500–6.533s, and capping + /// at the recording limit would clip their musical loop point (#6421). + static const Duration maxFeedPlaybackDuration = Duration(seconds: 7); + // ============================================================================ // VIDEO PROCESSING // ============================================================================ diff --git a/mobile/lib/widgets/video_feed_item/feed_videos.dart b/mobile/lib/widgets/video_feed_item/feed_videos.dart index 7fe53de06a..c997bbcad7 100644 --- a/mobile/lib/widgets/video_feed_item/feed_videos.dart +++ b/mobile/lib/widgets/video_feed_item/feed_videos.dart @@ -15,6 +15,7 @@ import 'package:openvine/blocs/video_interactions/video_interactions_bloc.dart'; import 'package:openvine/blocs/video_playback_status/video_playback_status_cubit.dart'; import 'package:openvine/blocs/video_playback_status/video_playback_status_state.dart'; import 'package:openvine/blocs/video_volume/video_volume_cubit.dart'; +import 'package:openvine/constants/app_constants.dart'; import 'package:openvine/extensions/video_event_extensions.dart'; import 'package:openvine/features/feature_flags/models/feature_flag.dart'; import 'package:openvine/features/feature_flags/providers/feature_flag_providers.dart'; @@ -364,9 +365,12 @@ class FeedVideosState extends ConsumerState with RouteAware { _resumeAutoAdvanceAfterSwipe(); widget.onActiveVideoChanged?.call(video, index); }, - // Do not pass maxLoopDuration here. Feed playback should loop at the - // asset boundary; restarting with a Dart seek at the 6.3s recording - // limit creates an audible seam. + // Nothing in the feed plays longer than a Vine, not even a 60s file a + // foreign Nostr client points at. The cap is a native clip end, so the + // loop point stays in the platform player — deliberately NOT + // maxLoopDuration, whose Dart seek at the 6.3s recording limit both + // truncated classic Vines and created an audible seam (#5544, #6421). + maxPlaybackDuration: AppConstants.maxFeedPlaybackDuration, onVideoLoopCompleted: _handleAutoAdvanceCompleted, shouldPortraitExpand: widget.shouldPortraitExpand, canAutoPlay: _canAutoPlayVideo, diff --git a/mobile/packages/divine_video_player/darwin/divine_video_player/Sources/divine_video_player/DivineVideoPlayerInstance.swift b/mobile/packages/divine_video_player/darwin/divine_video_player/Sources/divine_video_player/DivineVideoPlayerInstance.swift index f5f9b753ed..3c089f6c2a 100644 --- a/mobile/packages/divine_video_player/darwin/divine_video_player/Sources/divine_video_player/DivineVideoPlayerInstance.swift +++ b/mobile/packages/divine_video_player/darwin/divine_video_player/Sources/divine_video_player/DivineVideoPlayerInstance.swift @@ -391,7 +391,18 @@ final class DivineVideoPlayerInstance: NSObject, FlutterStreamHandler { let startTime = CMTime(value: startMs, timescale: 1000) let endTime: CMTime if let endMs { - endTime = CMTime(value: endMs.int64Value, timescale: 1000) + // Clamp to the asset: insertTimeRange silently inserts only the + // media that exists, so an endMs past the source would leave + // clipDuration (and therefore the reported totalDuration) + // longer than what actually plays. The feed relies on this — + // it caps every clip at maxFeedPlaybackDuration without knowing + // the source length up front. ExoPlayer clamps the equivalent + // ClippingConfiguration itself. + let requestedEnd = CMTime(value: endMs.int64Value, timescale: 1000) + endTime = + (assetDuration.isNumeric && CMTimeCompare(requestedEnd, assetDuration) > 0) + ? assetDuration + : requestedEnd } else { endTime = assetDuration } diff --git a/mobile/packages/divine_video_player/lib/src/video_clip.dart b/mobile/packages/divine_video_player/lib/src/video_clip.dart index d7823269b0..5f7c3cdad3 100644 --- a/mobile/packages/divine_video_player/lib/src/video_clip.dart +++ b/mobile/packages/divine_video_player/lib/src/video_clip.dart @@ -106,7 +106,10 @@ class VideoClip { /// End position within the source video. /// - /// When `null`, the clip plays to the end of the source. + /// When `null`, the clip plays to the end of the source. On the Android and + /// Apple backends an [end] past the source duration is clamped to it, so a + /// caller capping playback without knowing the source length still gets the + /// natural end for shorter sources. final Duration? end; /// Audio volume for this clip (0.0 = muted, 1.0 = full volume). diff --git a/mobile/packages/infinite_video_feed/lib/src/utils/source_loader.dart b/mobile/packages/infinite_video_feed/lib/src/utils/source_loader.dart index 46cdf3954c..156e75f7a1 100644 --- a/mobile/packages/infinite_video_feed/lib/src/utils/source_loader.dart +++ b/mobile/packages/infinite_video_feed/lib/src/utils/source_loader.dart @@ -34,6 +34,10 @@ class SourceLoadAborted implements Exception { /// Returns a record of `(source, attemptIndex)` for the URL that opened. /// Logs each failure via [log] and re-throws the last error when every /// source fails. +/// +/// [maxPlaybackDuration] becomes the clip's end position, so the native +/// player stops (and loops) there. Sources shorter than the cap are +/// unaffected — both backends clamp the clip end to the real duration. Future<(String, int)> setSourceWithFallbacks({ required int index, required DivineVideoPlayerController controller, @@ -41,6 +45,7 @@ Future<(String, int)> setSourceWithFallbacks({ required void Function(String) log, Map? Function(String source)? httpHeadersForSource, bool Function()? isLoadCurrent, + Duration? maxPlaybackDuration, SourceLoadDelay delay = Future.delayed, }) async { Object? lastError; @@ -59,6 +64,7 @@ Future<(String, int)> setSourceWithFallbacks({ await controller.setSource( VideoClip.network( source, + end: maxPlaybackDuration, httpHeaders: httpHeadersForSource?.call(source) ?? const {}, ), ); @@ -101,6 +107,7 @@ Future<(String, int)> setSourceWithFallbacks({ await controller.setSource( VideoClip.network( source, + end: maxPlaybackDuration, httpHeaders: httpHeadersForSource?.call(source) ?? const {}, ), ); diff --git a/mobile/packages/infinite_video_feed/lib/src/widgets/infinite_video_feed.dart b/mobile/packages/infinite_video_feed/lib/src/widgets/infinite_video_feed.dart index 0dd7a75db2..dd9afbbe02 100644 --- a/mobile/packages/infinite_video_feed/lib/src/widgets/infinite_video_feed.dart +++ b/mobile/packages/infinite_video_feed/lib/src/widgets/infinite_video_feed.dart @@ -53,6 +53,7 @@ class InfiniteVideoFeed extends StatefulWidget { this.releaseCurrentWhenInactive = false, this.prefetchCount = 8, this.shouldPortraitExpand = true, + this.maxPlaybackDuration, this.maxLoopDuration, this.onActiveVideoChanged, this.onNearEnd, @@ -231,10 +232,24 @@ class InfiniteVideoFeed extends StatefulWidget { /// Defaults to `true`. final bool shouldPortraitExpand; + /// Hard cap on how long each video plays before the native player loops. + /// + /// Applied as the clip end position ([VideoClip.end]) on every source the + /// feed opens, so the loop point lives in the platform player: Android + /// clips the `MediaItem` and repeats it, iOS trims the composition the + /// `AVPlayerLooper` loops over. A source shorter than the cap plays to its + /// natural end — both backends clamp the clip end to the real duration. + /// + /// When `null`, every source plays to its full length. + final Duration? maxPlaybackDuration; + /// Seeks active playback back to zero once this position is reached. /// /// When `null`, timeline-length loop enforcement is disabled and native /// looping behavior applies. + /// + /// Prefer [maxPlaybackDuration] for length enforcement: a Dart-side seek + /// produces an audible seam at the loop point (#5544). final Duration? maxLoopDuration; /// Called when the active video changes. @@ -1146,7 +1161,12 @@ class InfiniteVideoFeedState extends State { if (fromCache) { try { - await controller.setSource(VideoClip.file(cachedFile.path)); + await controller.setSource( + VideoClip.file( + cachedFile.path, + end: widget.maxPlaybackDuration, + ), + ); if (!guardInitOwnership('setSource(cache)')) return; _loadedFromCache.add(index); // Register network sources with prestart so a runtime parseError @@ -1184,6 +1204,7 @@ class InfiniteVideoFeedState extends State { log: _log, httpHeadersForSource: httpHeadersForSource, isLoadCurrent: ownsInit, + maxPlaybackDuration: widget.maxPlaybackDuration, ); if (!guardInitOwnership('setSourceWithFallbacks(cache)')) return; _sources.register(index, playbackSources, openedSourceIdx); @@ -1204,6 +1225,7 @@ class InfiniteVideoFeedState extends State { log: _log, httpHeadersForSource: httpHeadersForSource, isLoadCurrent: ownsInit, + maxPlaybackDuration: widget.maxPlaybackDuration, ); if (!guardInitOwnership('setSourceWithFallbacks(network)')) return; _sources.register(index, playbackSources, openedSourceIdx); @@ -1363,6 +1385,7 @@ class InfiniteVideoFeedState extends State { await controller.setSource( VideoClip.network( nextSource, + end: widget.maxPlaybackDuration, httpHeaders: _httpHeadersByIndex[index] ?? const {}, ), ); @@ -1458,6 +1481,7 @@ class InfiniteVideoFeedState extends State { await controller.setSource( VideoClip.network( source, + end: widget.maxPlaybackDuration, httpHeaders: _httpHeadersByIndex[index] ?? const {}, ), ); diff --git a/mobile/packages/infinite_video_feed/test/src/utils/source_loader_test.dart b/mobile/packages/infinite_video_feed/test/src/utils/source_loader_test.dart index 4342eb3124..7b61d489a5 100644 --- a/mobile/packages/infinite_video_feed/test/src/utils/source_loader_test.dart +++ b/mobile/packages/infinite_video_feed/test/src/utils/source_loader_test.dart @@ -26,9 +26,51 @@ void main() { expect(result, equals(('urlA', 0))); expect(controller.lastSource?.httpHeaders, isEmpty); + // No cap requested — the package must not invent one of its own. + expect(controller.lastSource?.end, isNull); expect(logs, isEmpty); }); + test('caps every source in the chain at maxPlaybackDuration', () async { + final clips = []; + final controller = _RecordingControllerWithOneFailure(clips.add); + addTearDown(controller.dispose); + + const cap = Duration(seconds: 7); + final result = await setSourceWithFallbacks( + index: 0, + controller: controller, + sources: ['optimizedUrl', 'rawUrl'], + log: logs.add, + maxPlaybackDuration: cap, + ); + + expect(result, equals(('rawUrl', 1))); + expect(clips.map((clip) => clip.end), equals([cap, cap])); + }); + + test('keeps the cap on a source retried after HTTP 202', () async { + final clips = []; + final controller = _RecordingControllerWithFailures( + clips.add, + failures: [Exception('CoreMediaErrorDomain error -12667 - HTTP 202')], + ); + addTearDown(controller.dispose); + + const cap = Duration(seconds: 7); + final result = await setSourceWithFallbacks( + index: 0, + controller: controller, + sources: ['processingUrl'], + log: logs.add, + maxPlaybackDuration: cap, + delay: (_) async {}, + ); + + expect(result, equals(('processingUrl', 0))); + expect(clips.map((clip) => clip.end), equals([cap, cap])); + }); + test('passes headers for the selected source', () async { final controller = FakeController(); addTearDown(controller.dispose); diff --git a/mobile/test/widgets/video_feed_item/feed_looping_contract_test.dart b/mobile/test/widgets/video_feed_item/feed_looping_contract_test.dart index d2a59d4cb6..0c21b53acf 100644 --- a/mobile/test/widgets/video_feed_item/feed_looping_contract_test.dart +++ b/mobile/test/widgets/video_feed_item/feed_looping_contract_test.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:openvine/constants/app_constants.dart'; +import 'package:openvine/constants/video_editor_constants.dart'; void main() { test('feed playback uses native looping instead of 6.3s seek enforcement', () { @@ -21,4 +23,67 @@ void main() { '6.3s recording limit; seek-based restarts create audible seams.', ); }); + + test('feed playback caps video length via a native clip end', () { + final feedVideosSource = File( + 'lib/widgets/video_feed_item/feed_videos.dart', + ).readAsStringSync(); + + expect( + feedVideosSource, + contains('maxPlaybackDuration: AppConstants.maxFeedPlaybackDuration'), + reason: + 'Without the cap a 60s file referenced by a foreign client plays in ' + 'full in the feed.', + ); + }); + + test('every feed source is opened with the cap applied', () { + // Cache hit, first load, source failover and processing retry each re-open + // the player, so a missed site lets a long video escape the cap on that + // path alone. + const capArgumentByFile = { + 'packages/infinite_video_feed/lib/src/widgets/infinite_video_feed.dart': + 'end: widget.maxPlaybackDuration', + 'packages/infinite_video_feed/lib/src/utils/source_loader.dart': + 'end: maxPlaybackDuration', + }; + + for (final entry in capArgumentByFile.entries) { + final source = File(entry.key).readAsStringSync(); + final clipCount = RegExp( + r'VideoClip\.(network|file)\(', + ).allMatches(source).length; + + expect( + clipCount, + greaterThan(0), + reason: 'No clips found in ${entry.key}', + ); + expect( + entry.value.allMatches(source), + hasLength(clipCount), + reason: '${entry.key} opens a clip without applying the cap.', + ); + } + }); + + test('the cap clears classic Vine assets and is not the recording limit', () { + // Longest classic Vine measured with ffprobe on media.divine.video (#6421). + // Capping below this would cut the musical loop point off every one of + // them. + const longestClassicVine = Duration(milliseconds: 6533); + + expect( + AppConstants.maxFeedPlaybackDuration, + greaterThan(longestClassicVine), + ); + expect( + AppConstants.maxFeedPlaybackDuration, + greaterThan(VideoEditorConstants.maxDuration), + reason: + 'maxDuration is the 6.3s recording limit, not a playback cap; ' + 'reusing it truncates classic Vines.', + ); + }); } From e6ed9f654259559c3090717d678209b97ccac2a3 Mon Sep 17 00:00:00 2001 From: hm21 Date: Mon, 27 Jul 2026 10:42:01 +0200 Subject: [PATCH 2/3] fix(video): clamp web clip end to the source duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../lib/src/video_clip.dart | 9 +- .../lib/src/web/web_clip_duration.dart | 34 ++++++ .../src/web/web_video_player_backend_web.dart | 16 ++- .../test/src/web_clip_duration_test.dart | 109 ++++++++++++++++++ 4 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 mobile/packages/divine_video_player/lib/src/web/web_clip_duration.dart create mode 100644 mobile/packages/divine_video_player/test/src/web_clip_duration_test.dart diff --git a/mobile/packages/divine_video_player/lib/src/video_clip.dart b/mobile/packages/divine_video_player/lib/src/video_clip.dart index 5f7c3cdad3..fbc7c30a95 100644 --- a/mobile/packages/divine_video_player/lib/src/video_clip.dart +++ b/mobile/packages/divine_video_player/lib/src/video_clip.dart @@ -106,10 +106,11 @@ class VideoClip { /// End position within the source video. /// - /// When `null`, the clip plays to the end of the source. On the Android and - /// Apple backends an [end] past the source duration is clamped to it, so a - /// caller capping playback without knowing the source length still gets the - /// natural end for shorter sources. + /// When `null`, the clip plays to the end of the source. On the Android, + /// Apple and web backends an [end] past the source duration is clamped to + /// it, so a caller capping playback without knowing the source length still + /// gets the natural end for shorter sources. The Linux backend does not + /// clamp and reports the requested length. final Duration? end; /// Audio volume for this clip (0.0 = muted, 1.0 = full volume). diff --git a/mobile/packages/divine_video_player/lib/src/web/web_clip_duration.dart b/mobile/packages/divine_video_player/lib/src/web/web_clip_duration.dart new file mode 100644 index 0000000000..f5ba1ad967 --- /dev/null +++ b/mobile/packages/divine_video_player/lib/src/web/web_clip_duration.dart @@ -0,0 +1,34 @@ +import 'dart:math' as math; + +/// Resolves the playback duration the web backend reports for a clip, in +/// seconds. +/// +/// [sourceDurationSeconds] is the `