diff --git a/mobile/lib/constants/app_constants.dart b/mobile/lib/constants/app_constants.dart index 3c4f9c219..819245a5a 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 7fe53de06..c997bbcad 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 f5f9b753e..3c089f6c2 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 d7823269b..fbc7c30a9 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,11 @@ 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, + /// 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 000000000..f5ba1ad96 --- /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 `