diff --git a/app/Http/Controllers/MatchEventController.php b/app/Http/Controllers/MatchEventController.php index 75acd16..95c7a75 100644 --- a/app/Http/Controllers/MatchEventController.php +++ b/app/Http/Controllers/MatchEventController.php @@ -18,7 +18,7 @@ class MatchEventController extends Controller $request->validate([ 'round_number' => 'required|integer|min:1', 'name' => 'nullable|string|max:50', - 'start_time_seconds' => 'nullable|integer|min:0', + 'start_time_seconds' => 'nullable|numeric|min:0', ]); // Check if user owns the video @@ -45,7 +45,7 @@ class MatchEventController extends Controller $request->validate([ 'round_number' => 'sometimes|integer|min:1', 'name' => 'required|string|max:50', - 'start_time_seconds' => 'nullable|integer|min:0', + 'start_time_seconds' => 'nullable|numeric|min:0', ]); // Check if user owns the video @@ -87,7 +87,7 @@ class MatchEventController extends Controller { $request->validate([ 'round_id' => 'required|exists:match_rounds,id', - 'timestamp_seconds' => 'required|integer|min:0', + 'timestamp_seconds' => 'required|numeric|min:0', 'action' => 'required|string|max:255', 'points' => 'required|integer|min:1', 'competitor' => 'required|in:blue,red', @@ -150,7 +150,7 @@ class MatchEventController extends Controller public function updatePoint(Request $request, MatchPoint $point) { $request->validate([ - 'timestamp_seconds' => 'required|integer|min:0', + 'timestamp_seconds' => 'required|numeric|min:0', 'action' => 'required|string|max:255', 'points' => 'required|integer|min:1', 'competitor' => 'required|in:blue,red', @@ -202,8 +202,8 @@ class MatchEventController extends Controller public function storeReview(Request $request, Video $video) { $request->validate([ - 'start_time_seconds' => 'required|integer|min:0', - 'end_time_seconds' => 'nullable|integer|min:0', + 'start_time_seconds' => 'required|numeric|min:0', + 'end_time_seconds' => 'nullable|numeric|min:0', 'note' => 'required|string|max:1000', 'coach_name' => 'required|string|max:100', 'emoji' => 'nullable|string|max:10', @@ -238,8 +238,8 @@ class MatchEventController extends Controller public function updateReview(Request $request, CoachReview $review) { $request->validate([ - 'start_time_seconds' => 'required|integer|min:0', - 'end_time_seconds' => 'nullable|integer|min:0', + 'start_time_seconds' => 'required|numeric|min:0', + 'end_time_seconds' => 'nullable|numeric|min:0', 'note' => 'required|string|max:1000', 'coach_name' => 'required|string|max:100', 'emoji' => 'nullable|string|max:10', diff --git a/app/Models/CoachReview.php b/app/Models/CoachReview.php index 0e5d954..115dc6b 100644 --- a/app/Models/CoachReview.php +++ b/app/Models/CoachReview.php @@ -23,8 +23,10 @@ class CoachReview extends Model ]; protected $casts = [ - 'position_x' => 'float', - 'position_y' => 'float', + 'position_x' => 'float', + 'position_y' => 'float', + 'start_time_seconds' => 'float', + 'end_time_seconds' => 'float', ]; public function video(): BelongsTo diff --git a/app/Models/MatchPoint.php b/app/Models/MatchPoint.php index 620ae7c..e943d4b 100644 --- a/app/Models/MatchPoint.php +++ b/app/Models/MatchPoint.php @@ -22,6 +22,10 @@ class MatchPoint extends Model 'score_red', ]; + protected $casts = [ + 'timestamp_seconds' => 'float', + ]; + public function video(): BelongsTo { return $this->belongsTo(Video::class); diff --git a/app/Models/MatchRound.php b/app/Models/MatchRound.php index 9140267..f1d4f3d 100644 --- a/app/Models/MatchRound.php +++ b/app/Models/MatchRound.php @@ -19,7 +19,7 @@ class MatchRound extends Model ]; protected $casts = [ - 'start_time_seconds' => 'integer', + 'start_time_seconds' => 'float', ]; public function video(): BelongsTo diff --git a/database/migrations/2026_08_08_000002_frame_accurate_timestamps.php b/database/migrations/2026_08_08_000002_frame_accurate_timestamps.php new file mode 100644 index 0000000..74eda7a --- /dev/null +++ b/database/migrations/2026_08_08_000002_frame_accurate_timestamps.php @@ -0,0 +1,49 @@ +decimal('start_time_seconds', 10, 3)->nullable()->change(); + }); + + Schema::table('match_points', function (Blueprint $table) { + $table->decimal('timestamp_seconds', 10, 3)->change(); + }); + + Schema::table('coach_reviews', function (Blueprint $table) { + $table->decimal('start_time_seconds', 10, 3)->change(); + $table->decimal('end_time_seconds', 10, 3)->nullable()->change(); + }); + } + + public function down(): void + { + // Round back to integer on rollback — sub-second data would be + // truncated but nothing catastrophic happens. + Schema::table('match_rounds', function (Blueprint $table) { + $table->integer('start_time_seconds')->nullable()->change(); + }); + Schema::table('match_points', function (Blueprint $table) { + $table->integer('timestamp_seconds')->change(); + }); + Schema::table('coach_reviews', function (Blueprint $table) { + $table->integer('start_time_seconds')->change(); + $table->integer('end_time_seconds')->nullable()->change(); + }); + } +}; diff --git a/resources/views/components/video-actions.blade.php b/resources/views/components/video-actions.blade.php index 13af1f9..fde7e0c 100644 --- a/resources/views/components/video-actions.blade.php +++ b/resources/views/components/video-actions.blade.php @@ -71,10 +71,14 @@ color: var(--brand-red) !important; } + /* Single Action dropdown for every viewport — the individual + .desktop-action buttons are collapsed into this one menu. */ .mobile-action-dropdown { - display: none; + display: inline-block; position: relative; + margin-left: auto; } + .video-actions > .desktop-action { display: none !important; } .mobile-action-dropdown .dropdown-menu { right: 0; diff --git a/resources/views/videos/types/match.blade.php b/resources/views/videos/types/match.blade.php index f79803f..f9bdee2 100644 --- a/resources/views/videos/types/match.blade.php +++ b/resources/views/videos/types/match.blade.php @@ -143,6 +143,48 @@ .coach-note-overlay.show { display: block; } + /* ═══════════════════════════════════════════════════════════════ + REPLAY badge — sports-broadcast style. Diagonal red-to-orange + bar with "REPLAY" over a big ×N speed indicator. Shows during + point replays and slow-mo phases; scales with the player width. + ═══════════════════════════════════════════════════════════════ */ + .replay-badge { + position: absolute; + top: 2.5cqi; left: 2.5cqi; + z-index: 15; + display: flex; flex-direction: column; align-items: center; + padding: clamp(4px, 0.7cqi, 8px) clamp(7px, 1.1cqi, 12px); + background: linear-gradient(135deg, #b91c1c 0%, #e61e1e 45%, #f97316 100%); + color: #fff; + border-radius: clamp(3px, 0.4cqi, 7px); + box-shadow: 0 4px 14px rgba(0,0,0,.5), 0 0 0 1.5px rgba(255,255,255,.14) inset; + transform: skewX(-8deg); + pointer-events: none; + font-family: 'Bebas Neue', 'Arial Narrow', Impact, sans-serif; + letter-spacing: .1em; + } + .replay-badge[hidden] { display: none; } + .replay-badge > * { transform: skewX(8deg); } + .replay-badge-word { + font-size: clamp(9px, 1.2cqi, 15px); + font-weight: 900; text-transform: uppercase; + text-shadow: 0 1px 3px rgba(0,0,0,.5); + line-height: 1; + } + .replay-badge-speed { + font-size: clamp(13px, 2cqi, 22px); + font-weight: 900; + line-height: 1; + margin-top: 1px; + text-shadow: 0 1px 4px rgba(0,0,0,.55), 0 0 8px rgba(255,255,255,.22); + font-variant-numeric: tabular-nums; + } + /* Slow-mo variant: swap to a cool blue/purple palette so the phase + change is instantly readable — no extra copy, just the palette + speed. */ + .replay-badge.is-slowmo { + background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 45%, #a855f7 100%); + } + /* Drag mode (while composing / editing a coach note) — reveal grab UX. Bumped above the capture strip (z-index 60) so it can be dragged into the area the strip currently covers. */ @@ -1038,9 +1080,31 @@ .tab-header { display: flex; + align-items: center; border-bottom: 1px solid var(--border-color); } + /* Slow-mo speed picker — sits at the right end of the tab header */ + .slowmo-picker { + margin-left: auto; + display: inline-flex; align-items: center; gap: 2px; + padding: 3px 6px 3px 8px; + font-size: 11px; color: var(--text-secondary); + } + .slowmo-picker-lbl { color: var(--brand-red, #e61e1e); font-size: 12px; margin-right: 2px; } + .slowmo-opt { + border: none; background: transparent; + color: var(--text-secondary); + padding: 3px 6px; border-radius: 4px; + font-family: inherit; font-size: 11px; font-weight: 700; + cursor: pointer; line-height: 1; + transition: background .1s, color .1s; + } + .slowmo-opt:hover { background: rgba(255,255,255,.06); color: var(--text-primary); } + .slowmo-opt.is-active { + background: var(--brand-red, #e61e1e); color: #fff; + } + .tab-button { flex: 1; padding: 12px 16px; @@ -1251,8 +1315,16 @@ } .event-meta .meta-round { color: #eab308; font-weight: 700; letter-spacing: .02em; } .event-meta .meta-sep { margin: 0 4px; opacity: .5; } - .event-meta .meta-score-blue { color: #3b82f6; font-weight: 700; font-variant-numeric: tabular-nums; } - .event-meta .meta-score-red { color: #ef4444; font-weight: 700; font-variant-numeric: tabular-nums; } + .event-meta .meta-score-blue, + .event-meta .meta-score-red { + display: inline-block; min-width: 20px; text-align: center; + padding: 1px 6px; border-radius: 4px; + color: #fff; font-weight: 700; + font-variant-numeric: tabular-nums; font-size: 11px; + line-height: 1.3; + } + .event-meta .meta-score-blue { background: #3b82f6; } + .event-meta .meta-score-red { background: #ef4444; } .event-meta .meta-score-sep { margin: 0 4px; color: var(--text-secondary); opacity: .7; } .pill { @@ -1288,7 +1360,6 @@ padding: 3px 4px 3px 3px; border-radius: 6px; transition: background .12s; } - .event-chip:hover { background: rgba(255,255,255,.04); } .chip-action { font-size: 13px; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -2045,6 +2116,10 @@ background: transparent; visibility: hidden; z-index: 1399; + /* The sheet only closes via its own toggle now — the backdrop + is purely decorative and MUST let taps pass through to the + video / other controls underneath. */ + pointer-events: none; } .hl-sheet-backdrop.show { visibility: visible; } @@ -2149,6 +2224,11 @@ {{-- Coach note overlay (subtitle-style) --}}
+ {{-- Sports-broadcast "REPLAY ×N" badge, shown during point replays --}} + @@ -2443,16 +2523,25 @@ const player = document.getElementById('ytpWrap'); const isMobile = () => window.matchMedia('(max-width: 991px)').matches; - // Pin the sheet's top edge to the player's exact bottom so the - // gap between them is zero on any device/orientation. + // Pin the sheet's top edge to the bottom of the player, + // or — if a capture strip is open BELOW the player — to + // the bottom of that strip, so nothing overlaps the tools. const positionSheet = () => { if (!isMobile()) { sidebar.style.removeProperty('--hl-sheet-top'); return; } const wrap = document.getElementById('ytpWrap'); - if (wrap) { - const h = Math.round(wrap.getBoundingClientRect().height); - if (h > 0) sidebar.style.setProperty('--hl-sheet-top', h + 'px'); + if (!wrap) return; + let bottom = wrap.offsetTop + wrap.offsetHeight; + // A capture strip in "below" mode is a sibling of the + // player wrap; when visible, push the sheet under it. + const strip = document.querySelector('.point-capture-strip.pcs-below:not([hidden])'); + if (strip) { + bottom = Math.max(bottom, strip.offsetTop + strip.offsetHeight); } + if (bottom > 0) sidebar.style.setProperty('--hl-sheet-top', bottom + 'px'); }; + // Expose so the point / review capture flows can re-position + // the sheet the moment their strip opens or closes. + window.positionHighlightsSheet = positionSheet; const setOpen = (open) => { sidebar.classList.toggle('show', open); @@ -2483,8 +2572,9 @@ toggleBtn._hlHandler = () => setOpen(!sidebar.classList.contains('show')); toggleBtn.addEventListener('click', toggleBtn._hlHandler); - if (backdrop) backdrop.addEventListener('click', () => setOpen(false)); - if (grab) grab.addEventListener('click', () => setOpen(false)); + // The highlights sheet closes ONLY via its own toggle button. + // Removing the backdrop / grab close handlers so tapping the + // video (or anywhere else) never dismisses it. // Keep the scrim + header state honest if the viewport crosses // the mobile boundary while the pane is open. @@ -2543,23 +2633,26 @@
+ {{-- Slow-mo speed selector — affects both point replays and coach note replays --}} +
+ + + + +
-
-
- -
- @auth - @if (isset($video) && Auth::id() === $video->user_id) + @auth + @if (isset($video) && Auth::id() === $video->user_id) +
- @endif - @endauth -
+
+ @endif + @endauth
@php $isOwner = auth()->check() && isset($video) && auth()->id() === $video->user_id; @@ -2573,7 +2666,7 @@
- @@ -2588,7 +2681,7 @@ $label = $point->action ?: 'Point'; if ($point->points) $label .= ' ('.$point->points.' pt'.($point->points == 1 ? '' : 's').')'; @endphp -
{{ '@' . $fmtTime($point->timestamp_seconds) }}
@@ -2624,28 +2717,25 @@
-
- - @auth - @if (isset($video) && Auth::id() === $video->user_id) + @auth + @if (isset($video) && Auth::id() === $video->user_id) +
- @endif - @endauth -
+
+ @endif + @endauth
@forelse ($video->coachReviews as $review) @php $start = $fmtTime($review->start_time_seconds); $end = $review->end_time_seconds ? $fmtTime($review->end_time_seconds) : null; $coach = $review->coach_name ?: 'Coach'; - $canSlowmo = $end && $review->end_time_seconds > $review->start_time_seconds; @endphp
end_time_seconds) data-time-end="{{ (int) $review->end_time_seconds }}" @endif + data-time-start="{{ (float) $review->start_time_seconds }}" + @if ($review->end_time_seconds) data-time-end="{{ (float) $review->end_time_seconds }}" @endif data-id="{{ $review->id }}">
@@ -2663,11 +2753,6 @@ {{ $coach }}
- @if ($canSlowmo) - - @endif @if ($isOwner) @@ -3316,16 +3401,21 @@
-
- - - +
+ +
+
+ + +
+
+ {{-- legacy id kept as a null sink for any stray reference --}}
@@ -3364,6 +3454,28 @@ /* Prevent the click-to-toggle overlay from stealing pointer events */ #ytpWrap[data-pcb-active] { cursor: default; } + /* MOBILE PORTRAIT: strip lives BELOW the video (in-flow), not overlaid. + Renders as a solid card so it's readable against page bg. Class is + applied by beginPointCapture / beginReviewCapture at runtime. */ + .point-capture-strip.pcs-below { + position: static; + background: #101010; + border-top: 1px solid #262626; + border-bottom: 1px solid #262626; + /* Wider horizontal padding so the inner rows sit off the strip's edges + (and therefore off the window's edges). Vertical padding untouched + so nothing shifts up or down. */ + padding: 10px 20px 14px; + margin: 0 -12px 12px; /* strip still bleeds edge-to-edge on mobile */ + box-shadow: 0 6px 20px rgba(0,0,0,.35); + animation: pcsSlideDown .18s ease-out; + z-index: auto; + } + @keyframes pcsSlideDown { + from { transform: translateY(-6px); opacity: 0; } + to { transform: none; opacity: 1; } + } + .pcs-row-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .pcs-copy { display: flex; align-items: center; gap: 8px; min-width: 0; color: #fff; font-size: 13px; font-weight: 600; text-shadow: 0 1px 2px rgba(0,0,0,.6); } @@ -3376,6 +3488,62 @@ #pcbDuration { color: #bbb; } .pcs-scrubber { display: flex; align-items: center; gap: 10px; } + /* ── Dual-thumb scrubber (coach review Start / End) ───────────── + Two stacked on the same track with pointer + events only on the thumbs; a red fill sits between the thumbs to + visualise the note's time range. */ + .rcb-dual { + position: relative; flex: 1; + height: 22px; display: flex; align-items: center; + } + .rcb-dual::before { + content: ''; position: absolute; left: 0; right: 0; + height: 6px; border-radius: 3px; + background: rgba(255,255,255,.25); + } + .rcb-range-fill { + position: absolute; + left: 0; width: 0; + height: 6px; border-radius: 3px; + background: var(--brand-red, #e61e1e); + pointer-events: none; + transition: left .04s linear, width .04s linear; + } + .rcb-range-dual { + position: absolute; left: 0; right: 0; top: 0; bottom: 0; + width: 100%; margin: 0; + -webkit-appearance: none; appearance: none; + background: transparent; cursor: pointer; + pointer-events: none; /* only thumbs are interactive */ + } + .rcb-range-dual::-webkit-slider-runnable-track { + height: 6px; background: transparent; + } + .rcb-range-dual::-moz-range-track { + height: 6px; background: transparent; + } + .rcb-range-dual::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 18px; height: 18px; border-radius: 50%; + background: var(--brand-red, #e61e1e); border: 3px solid #fff; + box-shadow: 0 2px 6px rgba(0,0,0,.6); + margin-top: -6px; + cursor: grab; pointer-events: auto; /* re-enable on thumb */ + } + .rcb-range-dual:active::-webkit-slider-thumb { cursor: grabbing; } + .rcb-range-dual::-moz-range-thumb { + width: 18px; height: 18px; border-radius: 50%; + background: var(--brand-red, #e61e1e); border: 3px solid #fff; + box-shadow: 0 2px 6px rgba(0,0,0,.6); + cursor: grab; pointer-events: auto; + } + /* Give Start thumb a slight left offset visual accent (chevron-like) */ + .rcb-range-start::-webkit-slider-thumb { + background: linear-gradient(135deg, #e61e1e 50%, #b91c1c 50%); + } + .rcb-range-end::-webkit-slider-thumb { + background: linear-gradient(135deg, #b91c1c 50%, #e61e1e 50%); + } /* Zoom controls */ .pcs-zoom { display: inline-flex; border-radius: 6px; overflow: hidden; flex-shrink: 0; @@ -3572,16 +3740,17 @@ } /* Entry rows (single or split-both) */ - .pcs-entry { display: flex; align-items: center; gap: 8px; } + .pcs-entry { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; min-width: 0; } .pcs-entry[hidden] { display: none; } - .pcs-entry-single .pcs-action { flex: 1 1 auto; min-width: 160px; } + /* Action is the flex-shrink target so the row always fits on one line */ + .pcs-entry-single .pcs-action { flex: 1 1 0; min-width: 0; } .pcs-entry-both { flex-direction: column; align-items: stretch; gap: 6px; } .pcs-entry-row { display: flex; align-items: center; gap: 8px; background: rgba(0,0,0,.35); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; padding: 4px 8px; } - .pcs-entry-row .pcs-action { flex: 1 1 auto; min-width: 140px; background: transparent; border-color: rgba(255,255,255,.14); } + .pcs-entry-row .pcs-action { flex: 1 1 0; min-width: 0; background: transparent; border-color: rgba(255,255,255,.14); } .pcs-side-blue { border-left: 3px solid #2563eb; } .pcs-side-red { border-left: 3px solid #e61e1e; } .pcs-side-dot { @@ -3844,7 +4013,6 @@ const rangeHtml = timeEnd ? `${timeStart} ${timeEnd}` : `${timeStart}`; - const canSlowmo = review.end_time_seconds && review.end_time_seconds > review.start_time_seconds; const emoji = review.emoji || '📝'; reviewsHtml += `
@@ -3859,10 +4027,7 @@ ${review.coach_name || 'Coach'}
- ${canSlowmo ? `` : ''} - ${isOwner ? ` + ${isOwner ? ` ` : ''} @@ -3879,10 +4044,13 @@ attachEventListeners(); } + // Compact MM:SS display for the sidebar (frames omitted — the strip + // uses the SMPTE MM:SS.FF format for editing). function formatTime(seconds) { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + const t = Math.max(0, Math.floor(Number(seconds) || 0)); + const mins = Math.floor(t / 60); + const secs = t % 60; + return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } function parseMinuteSecondInput(value) { @@ -4046,42 +4214,45 @@ // clear any previous stop watcher clearReviewPlaybackHandler(videoPlayer); hideCoachNoteOverlay(); + // Cancel any in-flight slow-mo replay (review or point) and restore normal speed + if (typeof _stopReviewSlowmo === 'function') _stopReviewSlowmo(); + if (typeof _stopPointReplay === 'function') _stopPointReplay(); + videoPlayer.playbackRate = 1; if (isReviewItem) { - const noteText = this.querySelector('.review-note-title')?.textContent - ?.trim() || - this.querySelector('.event-label')?.textContent?.trim() || ''; - // Look up the saved overlay position from the in-memory cache const revId = Number(this.dataset.id); const rev = (window.matchReviews || []).find(r => Number(r.id) === revId); + const hasRange = rev && rev.end_time_seconds != null && + Number(rev.end_time_seconds) > Number(rev.start_time_seconds); + + // When the note has a range, replay start→end at 1× then again at 0.5×. + // For a single-moment note (no end), fall back to a normal one-shot play. + if (hasRange && typeof playReviewSlowmo === 'function') { + playReviewSlowmo(revId); + return; + } + + const noteText = + this.querySelector('.review-note')?.textContent?.trim() || + this.querySelector('.review-note-title')?.textContent?.trim() || + this.querySelector('.event-label')?.textContent?.trim() || ''; const pos = (rev && rev.position_x != null && rev.position_y != null) ? { x: Number(rev.position_x), y: Number(rev.position_y) } : null; - if (noteText) { - showCoachNoteOverlay(noteText, pos); - } + const emoji = rev && rev.emoji ? rev.emoji + ' ' : ''; + if (noteText) showCoachNoteOverlay(emoji + noteText, pos); - // start exactly from review start time videoPlayer.currentTime = Math.max(0, timeStart); - - // if review has end time, stop playback at end and hide overlay - if (timeEnd !== null && !Number.isNaN(timeEnd) && timeEnd > timeStart) { - reviewPlaybackEndTime = timeEnd; - reviewPlaybackStopHandler = function() { - if (videoPlayer.currentTime >= reviewPlaybackEndTime) { - videoPlayer.pause(); - videoPlayer.currentTime = reviewPlaybackEndTime; - hideCoachNoteOverlay(); - clearReviewPlaybackHandler(videoPlayer); - } - }; - videoPlayer.addEventListener('timeupdate', reviewPlaybackStopHandler); - } } else { - // non-review items keep previous behavior - videoPlayer.currentTime = Math.max(0, timeStart - 1.0); + // Point items: play a short clip around the point at 1×, + // replay it at 0.5×, then keep going at 1× — matches the + // coach-review flow so both interactions feel the same. + playPointReplay(timeStart); + _hidePlayerChrome(); + return; } videoPlayer.play(); + _hidePlayerChrome(); } } }); @@ -4322,6 +4493,37 @@ // capture strip embedded inside #ytpWrap. The big red marker on the // strip's slider is the moment being captured; dragging it seeks the // video live. Confirm/Cancel restore the normal controls. + // Detect mobile portrait — capture strips render BELOW the video + // instead of inside it so the paused frame stays visible while typing. + function _mobilePortraitStrip() { + return window.matchMedia('(max-width: 768px) and (orientation: portrait)').matches; + } + // On mobile the .yt-main container is the scroll parent (per CLAUDE.md + // — the window itself is locked). Scroll the strip into view so the + // user can see the fields they're about to type into. + function _scrollStripIntoView(bar) { + if (!bar || !_mobilePortraitStrip()) return; + // Highlights sheet must slide UNDER the strip so it doesn't cover it + if (typeof window.positionHighlightsSheet === 'function') { + window.positionHighlightsSheet(); + } + const main = document.getElementById('main'); + const wrap = document.getElementById('ytpWrap'); + if (main && wrap) { + const wrapBottom = wrap.offsetTop + wrap.offsetHeight; + main.scrollTo({ top: Math.max(0, wrapBottom - 8), behavior: 'smooth' }); + } else if (bar.scrollIntoView) { + bar.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + // Called on cancel / save so the sheet slides back up to the player edge + function _stripClosed() { + if (typeof window.positionHighlightsSheet === 'function') { + // Wait a tick so the strip is truly hidden (offsetHeight = 0) + setTimeout(() => window.positionHighlightsSheet(), 20); + } + } + let _pcbCtx = null; function beginPointCapture(roundNumber, roundId, existing) { if (isDemoMode || !csrfToken) { showToast('Demo Mode', 'info'); return; } @@ -4352,12 +4554,23 @@ try { video.currentTime = seedTs; } catch (_) {} } - // Move the strip inside the player wrap so it visually replaces the - // control bar (and inherits fullscreen when the wrap goes fullscreen). - if (bar.parentElement !== wrap) { + // Placement: on desktop / mobile landscape the strip sits INSIDE the + // player wrap (replaces the control bar, inherits fullscreen). On + // mobile portrait it goes BELOW the video so the user can still see + // the paused frame while entering the point. + const useBelow = _mobilePortraitStrip(); + if (bar.parentElement !== (useBelow ? wrap.parentNode : wrap)) { bar._pcbOrigParent = bar.parentElement; bar._pcbOrigNext = bar.nextSibling; - wrap.appendChild(bar); + if (useBelow) { + wrap.parentNode.insertBefore(bar, wrap.nextSibling); + bar.classList.add('pcs-below'); + } else { + wrap.appendChild(bar); + bar.classList.remove('pcs-below'); + } + } else if (!useBelow) { + bar.classList.remove('pcs-below'); } wrap.setAttribute('data-pcb-active', '1'); @@ -4429,8 +4642,17 @@ } // Slider → video seek (live scrub) → time input mirror + // Aggressive pause — unconditional and covers HLS.js's async play state. + const forcePause = () => { + try { video.pause(); } catch (_) {} + // HLS layer may re-issue play() during a pending buffer/seek; + // a second pause a tick later beats the race in most builds. + setTimeout(() => { try { video.pause(); } catch (_) {} }, 30); + }; const onSlide = () => { - const t = Number(slider.value) || 0; + forcePause(); + const t = snapToFrame(Number(slider.value) || 0); + slider.value = t; try { video.currentTime = t; } catch (_) {} const clock = toMinuteSecondClock(t); clockEl.textContent = clock; @@ -4490,6 +4712,13 @@ }; slider.addEventListener('input', onSlide); + // Pause the second the user touches the slider — not only on input. + // Some browsers / HLS builds keep playing until the first input fires; + // this guarantees the frame is frozen before the drag even moves. + const onSliderGrab = () => forcePause(); + slider.addEventListener('mousedown', onSliderGrab); + slider.addEventListener('pointerdown', onSliderGrab); + slider.addEventListener('touchstart', onSliderGrab, { passive: true }); slider.addEventListener('change', onSlideCommit); video.addEventListener('timeupdate', onVideoTime); video.addEventListener('seeked', onVideoTime); @@ -4504,10 +4733,13 @@ setTimeout(() => { const focusTarget = document.querySelector('.pcs-entry:not([hidden]) .pcs-action'); if (focusTarget) focusTarget.focus(); + // On mobile portrait the strip lives below the video — scroll + // the main container so the user can actually see it. + _scrollStripIntoView(bar); }, 30); _pcbCtx = { roundNumber, roundId, video, wrap, bar, slider, - onSlide, onSlideCommit, onVideoTime, onKey, + onSlide, onSlideCommit, onVideoTime, onKey, onSliderGrab, timeInp, commitTimeInput, onTimeInputKey, existingId: existingSingle ? existingSingle.id : null, existingBlueId: existingBlue ? existingBlue.id : null, @@ -4532,7 +4764,8 @@ slider.min = start; slider.max = end; // Finer step at higher zoom for smoother frame-level positioning - slider.step = _pcbZoom >= 20 ? 0.02 : _pcbZoom >= 5 ? 0.05 : 0.1; + // Always step by 1 frame — the scrubber is frame-accurate. + slider.step = FRAME_STEP(); slider.value = cur; // Info line under the scrubber @@ -4549,29 +4782,45 @@ } function _pcbSetZoom(level) { _pcbZoom = Math.max(1, Number(level) || 1); - document.querySelectorAll('.pcs-zoom-btn').forEach(el => { + // Scope strictly to the POINT strip's own zoom buttons — the review + // strip uses the same class but has data-rcb-zoom, and clicks on it + // must not clobber this state. + document.querySelectorAll('.pcs-zoom-btn[data-pcs-zoom]').forEach(el => { el.classList.toggle('is-active', Number(el.dataset.pcsZoom) === _pcbZoom); }); _pcbApplyZoomWindow(); } - // Click delegation for the zoom buttons + // Click delegation only for point-strip zoom buttons. document.addEventListener('click', function (e) { - const btn = e.target.closest('.pcs-zoom-btn'); + const btn = e.target.closest('.pcs-zoom-btn[data-pcs-zoom]'); if (!btn) return; _pcbSetZoom(Number(btn.dataset.pcsZoom) || 1); }); // Parse "mm:ss" (or "mm.ss" or plain seconds) into total seconds. + // Accepts: MM:SS.FF (frames) | MM:SS | MM.SS | plain seconds function parsePcbTimeInput(raw) { const s = String(raw || '').trim(); if (!s) return null; - const m = s.match(/^(\d+)[:.](\d{1,2})$/); + const fps = window.matchFPS || 30; + // MM:SS.FF or MM:SS,FF + let m = s.match(/^(\d+)[:.](\d{1,2})[.,](\d{1,2})$/); + if (m) { + const mins = parseInt(m[1], 10); + const secs = parseInt(m[2], 10); + const frames = parseInt(m[3], 10); + if ([mins, secs, frames].some(Number.isNaN) || secs > 59 || frames >= fps) return null; + return snapToFrame(mins * 60 + secs + frames / fps); + } + // MM:SS or MM.SS + m = s.match(/^(\d+)[:.](\d{1,2})$/); if (m) { const mins = parseInt(m[1], 10); const secs = parseInt(m[2].padStart(2, '0'), 10); if (Number.isNaN(mins) || Number.isNaN(secs) || secs > 59) return null; - return mins * 60 + secs; + return snapToFrame(mins * 60 + secs); } - if (/^\d+$/.test(s)) return parseInt(s, 10); + // Plain integer seconds + if (/^\d+$/.test(s)) return snapToFrame(parseInt(s, 10)); return null; } function _pcsSetCompetitor(which) { @@ -4640,9 +4889,14 @@ return; } const { video, wrap, bar, slider, onSlide, onSlideCommit, onVideoTime, onKey, - timeInp, commitTimeInput, onTimeInputKey } = _pcbCtx; + onSliderGrab, timeInp, commitTimeInput, onTimeInputKey } = _pcbCtx; slider.removeEventListener('input', onSlide); slider.removeEventListener('change', onSlideCommit); + if (onSliderGrab) { + slider.removeEventListener('mousedown', onSliderGrab); + slider.removeEventListener('pointerdown', onSliderGrab); + slider.removeEventListener('touchstart', onSliderGrab); + } video.removeEventListener('timeupdate', onVideoTime); video.removeEventListener('seeked', onVideoTime); document.removeEventListener('keydown', onKey); @@ -4666,14 +4920,16 @@ delete bar._pcbOrigNext; } _pcbCtx = null; + _stripClosed(); } function pcbNudge(deltaSeconds) { if (!_pcbCtx) return; const { video, slider } = _pcbCtx; - // Scale nudge by zoom so 20× yields 0.05s steps instead of a full second - const scaled = deltaSeconds / (_pcbZoom || 1); + // Direction only — nudge is always ±1 frame, at every zoom level + const dir = deltaSeconds >= 0 ? 1 : -1; + const step = FRAME_STEP() * dir; const dur = Number(video.duration) || 0; - const next = Math.max(0, Math.min(dur, (Number(video.currentTime) || 0) + scaled)); + const next = snapToFrame(Math.max(0, Math.min(dur, (Number(video.currentTime) || 0) + step))); try { video.currentTime = next; } catch (_) {} // Recentre window if next fell out of the current zoom window if (_pcbZoom > 1 && (next < Number(slider.min) || next > Number(slider.max))) { @@ -4691,7 +4947,8 @@ const { roundId, video, bar, timeInp, existingId, existingBlueId, existingRedId } = _pcbCtx; // Prefer whatever's typed in the time input (auto-committed on blur too) - let seconds = Math.max(0, Math.round(Number(video.currentTime) || 0)); + // Frame-accurate: snap to the nearest frame instead of rounding to a whole second + let seconds = Math.max(0, snapToFrame(Number(video.currentTime) || 0)); if (timeInp) { const parsed = parsePcbTimeInput(timeInp.value); if (parsed !== null) seconds = parsed; @@ -4882,10 +5139,52 @@ if (saveBtn) saveBtn.disabled = false; } } + // Frame rate assumed for match footage (SMPTE 30fps by default). + // Set window.matchFPS from outside to override per video. + window.matchFPS = window.matchFPS || 30; + const FRAME_STEP = () => 1 / (window.matchFPS || 30); + + // Slow-mo replay speed — user-selectable via the picker in the sidebar + // tab header. Read by playPointReplay and playReviewSlowmo. + window.slowmoRate = window.slowmoRate || 0.5; + // Hide the player's chrome when a sidebar card triggers playback. The + // player's `pause` handler synchronously fires showControls(), and the + // `play` handler resets a 3s hide timer that keeps the chrome up. To + // beat that whole race we apply the hide over several ticks — mousemove + // over the actual video still brings the controls back instantly. + function _hidePlayerChrome() { + const container = document.getElementById('videoContainer'); + if (!container) return; + const hide = () => container.classList.add('controls-hidden'); + hide(); + [10, 60, 200, 500].forEach(ms => setTimeout(hide, ms)); + } + document.addEventListener('click', function (e) { + const btn = e.target.closest('.slowmo-opt'); + if (!btn) return; + const rate = Number(btn.dataset.slowmo); + if (!rate) return; + window.slowmoRate = rate; + document.querySelectorAll('.slowmo-opt').forEach(el => { + el.classList.toggle('is-active', Number(el.dataset.slowmo) === rate); + }); + }); + // Snap an arbitrary seconds value onto the nearest frame boundary + function snapToFrame(totalSeconds) { + const fps = window.matchFPS || 30; + const frame = Math.round((Number(totalSeconds) || 0) * fps); + return frame / fps; + } + // Broadcast MM:SS.FF (0-indexed frames within the second) function toMinuteSecondClock(totalSeconds) { - const s = Math.max(0, Math.floor(Number(totalSeconds) || 0)); - const m = Math.floor(s / 60); - return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; + const t = Math.max(0, Number(totalSeconds) || 0); + const fps = window.matchFPS || 30; + const totalFrames = Math.round(t * fps); + const totalSecs = Math.floor(totalFrames / fps); + const frames = totalFrames % fps; + const m = Math.floor(totalSecs / 60); + const s = totalSecs % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(frames).padStart(2, '0')}`; } // Edit a point inline in the capture strip — no popup. @@ -5019,11 +5318,21 @@ try { video.pause(); } catch (_) {} - // Move strip into the player, hide chrome controls - if (bar.parentElement !== wrap) { + // Placement: inside the player wrap on desktop / landscape, + // BELOW the player on mobile portrait so the video stays visible. + const useBelowR = _mobilePortraitStrip(); + if (bar.parentElement !== (useBelowR ? wrap.parentNode : wrap)) { bar._rcbOrigParent = bar.parentElement; bar._rcbOrigNext = bar.nextSibling; - wrap.appendChild(bar); + if (useBelowR) { + wrap.parentNode.insertBefore(bar, wrap.nextSibling); + bar.classList.add('pcs-below'); + } else { + wrap.appendChild(bar); + bar.classList.remove('pcs-below'); + } + } else if (!useBelowR) { + bar.classList.remove('pcs-below'); } wrap.setAttribute('data-pcb-active', '1'); @@ -5037,18 +5346,21 @@ const endInp = document.getElementById('rcbEnd'); const noteInp = document.getElementById('rcbNote'); const coachInp = document.getElementById('rcbCoach'); - const slider = document.getElementById('rcbSlider'); + const sliderS = document.getElementById('rcbSliderStart'); + const sliderE = document.getElementById('rcbSliderEnd'); + const fillEl = document.getElementById('rcbRangeFill'); const durEl = document.getElementById('rcbDuration'); noteInp.value = existing ? (existing.note || '') : ''; coachInp.value = existing ? (existing.coach_name || '') : ''; _rcbSetEmoji(existing && existing.emoji ? existing.emoji : '🔥'); - const startSec = existing ? Number(existing.start_time_seconds || 0) : Math.max(0, Math.round(video.currentTime || 0)); - const endSec = existing && existing.end_time_seconds !== null && existing.end_time_seconds !== undefined - ? Number(existing.end_time_seconds) : null; - startInp.value = toMinuteSecondClock(startSec); - endInp.value = endSec !== null ? toMinuteSecondClock(endSec) : ''; + const seedStart = existing ? Number(existing.start_time_seconds || 0) : Math.max(0, Math.round(video.currentTime || 0)); + const seedEnd = existing && existing.end_time_seconds !== null && existing.end_time_seconds !== undefined + ? Number(existing.end_time_seconds) + : seedStart + 3; // default to a 3s window when there's no end yet + startInp.value = toMinuteSecondClock(seedStart); + endInp.value = toMinuteSecondClock(seedEnd); _rcbSetActiveSlot('start'); @@ -5060,6 +5372,7 @@ if (isFinite(d) && d > 0) { durEl.textContent = toMinuteSecondClock(d); _rcbApplyZoomWindow(); + _rcbRefreshFill(); } }; wireDuration(); @@ -5067,32 +5380,53 @@ video.addEventListener('loadedmetadata', wireDuration, { once: true }); } _rcbApplyZoomWindow(); - slider.value = Math.max(0, Math.min(video.duration || 0, video.currentTime || 0)); + sliderS.value = Math.max(Number(sliderS.min) || 0, Math.min(Number(sliderS.max) || 0, seedStart)); + sliderE.value = Math.max(Number(sliderE.min) || 0, Math.min(Number(sliderE.max) || 0, seedEnd)); + _rcbRefreshFill(); - // Slider → active slot's time + video seek - const onSlide = () => { - const t = Number(slider.value) || 0; + // Which marker was touched last — nudge buttons act on it. + let _rcbLastTouched = 'start'; + + // Aggressive pause — beats HLS.js's async play state re-issuing on seek. + const forcePauseR = () => { + try { video.pause(); } catch (_) {} + setTimeout(() => { try { video.pause(); } catch (_) {} }, 30); + }; + const onSlideStart = () => { + forcePauseR(); + let t = snapToFrame(Number(sliderS.value) || 0); + const eNow = Number(sliderE.value) || 0; + if (t > eNow) t = eNow; + sliderS.value = t; + _rcbLastTouched = 'start'; + _rcbSetActiveSlot('start'); try { video.currentTime = t; } catch (_) {} - const clock = toMinuteSecondClock(t); - _rcbSetActiveTime(clock); + if (document.activeElement !== startInp) startInp.value = toMinuteSecondClock(t); + _rcbRefreshFill(); }; - const onSlideCommit = () => { - if (_rcbZoom <= 1) return; - const v = Number(slider.value) || 0; - const mn = Number(slider.min) || 0, mx = Number(slider.max) || 0; - const edge = (mx - mn) * 0.02; - if (v <= mn + edge || v >= mx - edge) _rcbApplyZoomWindow(); + const onSlideEnd = () => { + forcePauseR(); + let t = snapToFrame(Number(sliderE.value) || 0); + const sNow = Number(sliderS.value) || 0; + if (t < sNow) t = sNow; + sliderE.value = t; + _rcbLastTouched = 'end'; + _rcbSetActiveSlot('end'); + try { video.currentTime = t; } catch (_) {} + if (document.activeElement !== endInp) endInp.value = toMinuteSecondClock(t); + _rcbRefreshFill(); }; + const onSlideCommit = () => { /* auto-recenter disabled — user zooms explicitly */ }; const onVideoTime = () => { - if (document.activeElement === slider) return; - const t = video.currentTime || 0; - if (_rcbZoom > 1 && (t < Number(slider.min) || t > Number(slider.max))) _rcbApplyZoomWindow(); - slider.value = t; + // When the video moves on its own (preview, external seek), + // update the fill so the range visualization stays in sync. + _rcbRefreshFill(); }; // Time input commit (parse and clamp both slots) - const commitStart = () => _rcbCommitTimeInput('start'); - const commitEnd = () => _rcbCommitTimeInput('end'); + const commitStart = () => { _rcbCommitTimeInput('start'); _rcbRefreshFill(); }; + const commitEnd = () => { _rcbCommitTimeInput('end'); _rcbRefreshFill(); }; + _rcbCtxLastTouched = () => _rcbLastTouched; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); cancelReviewCapture(); return; } @@ -5111,8 +5445,17 @@ } }; - slider.addEventListener('input', onSlide); - slider.addEventListener('change', onSlideCommit); + sliderS.addEventListener('input', onSlideStart); + sliderE.addEventListener('input', onSlideEnd); + sliderS.addEventListener('change', onSlideCommit); + sliderE.addEventListener('change', onSlideCommit); + // Freeze the frame the moment either thumb is grabbed + const onSliderGrabR = () => forcePauseR(); + [sliderS, sliderE].forEach(sl => { + sl.addEventListener('mousedown', onSliderGrabR); + sl.addEventListener('pointerdown', onSliderGrabR); + sl.addEventListener('touchstart', onSliderGrabR, { passive: true }); + }); video.addEventListener('timeupdate', onVideoTime); video.addEventListener('seeked', onVideoTime); document.addEventListener('keydown', onKey); @@ -5150,11 +5493,16 @@ } bar.hidden = false; - setTimeout(() => noteInp.focus(), 30); + setTimeout(() => { + noteInp.focus(); + _scrollStripIntoView(bar); + }, 30); _rcbCtx = { existingId: existing ? existing.id : null, - video, wrap, bar, slider, - onSlide, onSlideCommit, onVideoTime, onKey, + video, wrap, bar, + sliderS, sliderE, fillEl, + onSlideStart, onSlideEnd, onSlideCommit, onVideoTime, onKey, + onSliderGrabR, startInp, endInp, commitStart, commitEnd, noteInp, previewFn, overlay, // Seed from existing position when editing, else default @@ -5272,7 +5620,10 @@ const secs = parsePcbTimeInput(val); if (secs !== null && _rcbCtx && _rcbCtx.video) { try { _rcbCtx.video.currentTime = secs; } catch (_) {} - if (_rcbCtx.slider) _rcbCtx.slider.value = secs; + // Keep dual sliders in sync with the input value + if (_rcbCtx.sliderS && which === 'start') _rcbCtx.sliderS.value = secs; + if (_rcbCtx.sliderE && which === 'end') _rcbCtx.sliderE.value = secs; + _rcbRefreshFill(); } } function _rcbSetActiveTime(clock) { @@ -5298,8 +5649,11 @@ inp.value = toMinuteSecondClock(clamped); if ((document.getElementById('rcbActiveSlot').value || 'start') === which) { try { _rcbCtx.video.currentTime = clamped; } catch (_) {} - _rcbCtx.slider.value = clamped; } + // Mirror the committed value into the corresponding scrubber thumb + if (which === 'start' && _rcbCtx.sliderS) _rcbCtx.sliderS.value = clamped; + if (which === 'end' && _rcbCtx.sliderE) _rcbCtx.sliderE.value = clamped; + _rcbRefreshFill(); } function rcbClearEnd(e) { if (e) e.stopPropagation(); @@ -5363,32 +5717,63 @@ btn.title = 'Preview from Start → End'; } } + // Nudge the LAST-TOUCHED scrubber (start or end) by ±1 frame. function rcbNudge(deltaSeconds) { if (!_rcbCtx) return; - const { video, slider } = _rcbCtx; - const scaled = deltaSeconds / (_rcbZoom || 1); + const { video, sliderS, sliderE, startInp, endInp } = _rcbCtx; + const which = (typeof _rcbCtxLastTouched === 'function' ? _rcbCtxLastTouched() : 'start'); + const target = which === 'end' ? sliderE : sliderS; + const otherVal = Number(which === 'end' ? sliderS.value : sliderE.value) || 0; + const dir = deltaSeconds >= 0 ? 1 : -1; + const step = FRAME_STEP() * dir; const dur = Number(video.duration) || 0; - const next = Math.max(0, Math.min(dur, (Number(video.currentTime) || 0) + scaled)); + let next = snapToFrame(Math.max(0, Math.min(dur, (Number(target.value) || 0) + step))); + // Enforce start <= end + if (which === 'end' && next < otherVal) next = otherVal; + if (which === 'start' && next > otherVal) next = otherVal; try { video.currentTime = next; } catch (_) {} - if (_rcbZoom > 1 && (next < Number(slider.min) || next > Number(slider.max))) _rcbApplyZoomWindow(); - slider.value = next; - _rcbSetActiveTime(toMinuteSecondClock(next)); + target.value = next; + const inp = which === 'end' ? endInp : startInp; + if (inp && document.activeElement !== inp) inp.value = toMinuteSecondClock(next); + _rcbRefreshFill(); } + // Apply the current zoom window to BOTH scrubbers so they share a track function _rcbApplyZoomWindow() { - const video = document.getElementById('videoPlayer'); - const slider = document.getElementById('rcbSlider'); - if (!video || !slider) return; + const video = document.getElementById('videoPlayer'); + const sS = document.getElementById('rcbSliderStart'); + const sE = document.getElementById('rcbSliderEnd'); + if (!video || !sS || !sE) return; const dur = Number(video.duration) || 0; - if (dur <= 0) { slider.min = 0; slider.max = 0; slider.step = 0.1; return; } + const apply = (mn, mx) => { + sS.min = mn; sS.max = mx; sE.min = mn; sE.max = mx; + // Always step by 1 frame — the scrubber is frame-accurate + sS.step = FRAME_STEP(); sE.step = FRAME_STEP(); + }; + if (dur <= 0) { apply(0, 0); return; } const cur = Math.max(0, Math.min(dur, Number(video.currentTime) || 0)); const winSize = Math.max(0.5, dur / (_rcbZoom || 1)); let s = cur - winSize / 2, e = cur + winSize / 2; if (s < 0) { e -= s; s = 0; } if (e > dur) { s -= (e - dur); e = dur; if (s < 0) s = 0; } - slider.min = s; slider.max = e; - slider.step = _rcbZoom >= 20 ? 0.02 : _rcbZoom >= 5 ? 0.05 : 0.1; - slider.value = cur; + apply(s, e); + _rcbRefreshFill(); } + // Update the red bar between the two thumbs to match current values + function _rcbRefreshFill() { + const sS = document.getElementById('rcbSliderStart'); + const sE = document.getElementById('rcbSliderEnd'); + const fill = document.getElementById('rcbRangeFill'); + if (!sS || !sE || !fill) return; + const mn = Number(sS.min) || 0, mx = Number(sS.max) || 0; + const span = mx - mn || 1; + const a = (Number(sS.value) - mn) / span; + const b = (Number(sE.value) - mn) / span; + const left = Math.max(0, Math.min(1, Math.min(a, b))) * 100; + const right = Math.max(0, Math.min(1, Math.max(a, b))) * 100; + fill.style.left = left + '%'; + fill.style.width = (right - left) + '%'; + } + let _rcbCtxLastTouched = null; function _rcbSetZoomButtons(level) { document.querySelectorAll('.pcs-zoom-btn[data-rcb-zoom]').forEach(el => { el.classList.toggle('is-active', Number(el.dataset.rcbZoom) === Number(level)); @@ -5412,8 +5797,9 @@ if (sb) sb.disabled = false; } if (!_rcbCtx) return; - const { video, wrap, slider, startInp, endInp, - onSlide, onSlideCommit, onVideoTime, onKey, + const { video, wrap, sliderS, sliderE, startInp, endInp, + onSlideStart, onSlideEnd, onSlideCommit, onVideoTime, onKey, + onSliderGrabR, commitStart, commitEnd, noteInp, previewFn, overlay } = _rcbCtx; // Stop any in-flight preview and reset the button @@ -5424,8 +5810,24 @@ document.querySelectorAll('.rcb-emoji-btn').forEach(el => previewFn && el.removeEventListener('click', previewFn)); hideCoachNoteOverlay(); rcbDisableOverlayDrag(); - slider.removeEventListener('input', onSlide); - slider.removeEventListener('change', onSlideCommit); + if (sliderS) { + sliderS.removeEventListener('input', onSlideStart); + sliderS.removeEventListener('change', onSlideCommit); + if (onSliderGrabR) { + sliderS.removeEventListener('mousedown', onSliderGrabR); + sliderS.removeEventListener('pointerdown', onSliderGrabR); + sliderS.removeEventListener('touchstart', onSliderGrabR); + } + } + if (sliderE) { + sliderE.removeEventListener('input', onSlideEnd); + sliderE.removeEventListener('change', onSlideCommit); + if (onSliderGrabR) { + sliderE.removeEventListener('mousedown', onSliderGrabR); + sliderE.removeEventListener('pointerdown', onSliderGrabR); + sliderE.removeEventListener('touchstart', onSliderGrabR); + } + } video.removeEventListener('timeupdate', onVideoTime); video.removeEventListener('seeked', onVideoTime); document.removeEventListener('keydown', onKey); @@ -5433,6 +5835,7 @@ startInp.removeEventListener('blur', commitStart); endInp.removeEventListener('change', commitEnd); endInp.removeEventListener('blur', commitEnd); + _rcbCtxLastTouched = null; bar.querySelectorAll('.rcb-time-edit').forEach(el => { if (el._rcbClick) { el.removeEventListener('click', el._rcbClick); delete el._rcbClick; } }); @@ -5448,6 +5851,7 @@ delete bar._rcbOrigParent; delete bar._rcbOrigNext; } _rcbCtx = null; + _stripClosed(); } async function confirmReviewCapture() { @@ -5548,13 +5952,16 @@ let phase = 'normal'; // 'normal' → 'slowmo' → 'done' video.playbackRate = 1; try { video.currentTime = Math.max(0, start); } catch (_) {} + _setReplayBadge('1', 'normal'); const watcher = function () { if (video.currentTime < end) return; if (phase === 'normal') { phase = 'slowmo'; - video.playbackRate = 0.5; + const rate = Number(window.slowmoRate) || 0.5; + video.playbackRate = rate; try { video.currentTime = Math.max(0, start); } catch (_) {} + _setReplayBadge(String(rate), 'slowmo'); } else { _stopReviewSlowmo(btn); } @@ -5563,6 +5970,7 @@ video.addEventListener('timeupdate', watcher); const p = video.play(); if (p && typeof p.catch === 'function') p.catch(() => {}); + _hidePlayerChrome(); } function _stopReviewSlowmo(btn) { const video = document.getElementById('videoPlayer'); @@ -5576,8 +5984,69 @@ try { video.pause(); } catch (_) {} video.playbackRate = 1; } + _setReplayBadge(null); hideCoachNoteOverlay(); } + + // Point replay: play a short clip around the point at 1×, replay at + // 0.5×, then RESUME normal playback. A sports-broadcast REPLAY badge + // sits on the video, flipping from red (×1) to blue (SLOW-MO ×0.5). + let _pointReplayWatcher = null; + function _setReplayBadge(speed, mode) { + const badge = document.getElementById('replayBadge'); + const speedEl = document.getElementById('replayBadgeSpeed'); + if (!badge || !speedEl) return; + if (!speed) { badge.hidden = true; badge.classList.remove('is-slowmo'); return; } + badge.hidden = false; + badge.classList.toggle('is-slowmo', mode === 'slowmo'); + speedEl.textContent = '×' + speed; + } + function playPointReplay(pointTimeSec) { + const video = document.getElementById('videoPlayer'); + if (!video || !Number.isFinite(pointTimeSec)) return; + + const preroll = 1.5; + const postroll = 2.5; + const start = Math.max(0, pointTimeSec - preroll); + const end = pointTimeSec + postroll; + + _stopPointReplay(); // cancel any prior replay + video.playbackRate = 1; + try { video.currentTime = start; } catch (_) {} + _setReplayBadge('1', 'normal'); + + let phase = 'normal'; // normal → slowmo → done (resume normal) + const watcher = function () { + if (video.currentTime < end) return; + if (phase === 'normal') { + phase = 'slowmo'; + const rate = Number(window.slowmoRate) || 0.5; + video.playbackRate = rate; + try { video.currentTime = start; } catch (_) {} + _setReplayBadge(String(rate), 'slowmo'); + } else if (phase === 'slowmo') { + phase = 'done'; + video.playbackRate = 1; + video.removeEventListener('timeupdate', watcher); + _pointReplayWatcher = null; + _setReplayBadge(null); // hide when done — resume normal + } + }; + _pointReplayWatcher = watcher; + video.addEventListener('timeupdate', watcher); + const p = video.play(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + _hidePlayerChrome(); + } + function _stopPointReplay() { + const video = document.getElementById('videoPlayer'); + if (_pointReplayWatcher && video) { + video.removeEventListener('timeupdate', _pointReplayWatcher); + } + _pointReplayWatcher = null; + if (video) video.playbackRate = 1; + _setReplayBadge(null); + } async function deleteReview(reviewId) { requestDelete('review', reviewId); }