Frame-accurate scrubbing + mobile highlights UX + collapsed action menu

Frame-accurate timestamps (end-to-end)
- Migration 2026_08_08_000002 promotes match_rounds.start_time_seconds,
  match_points.timestamp_seconds, coach_reviews.start_time_seconds and
  coach_reviews.end_time_seconds from INTEGER to decimal(10,3).
- Model $casts updated (float). MatchEventController validators relaxed
  from `integer` to `numeric|min:0` on all four fields.
- Blade SSR data-time-{start,end} attributes cast to (float) so the
  first paint carries frame precision.
- Point + review capture strips: slider step = 1/fps, snapToFrame()
  applied to every seek/nudge/input commit. Clock now shows SMPTE
  MM:SS.FF; parsePcbTimeInput accepts MM:SS.FF, MM:SS, MM.SS or seconds.
- Default fps is 30; override via window.matchFPS.
- confirmPointCapture no longer Math.round()s currentTime — sends the
  exact frame boundary.

Point + review UX
- Video pauses aggressively on scrubber grab (mousedown/pointerdown/
  touchstart) AND on every input, with a 30ms follow-up to beat HLS.js
  race conditions.
- Zoom buttons now scope by data-pcs-zoom / data-rcb-zoom so clicks on
  the review-strip zoom don't clobber the point-strip zoom (root cause
  of the "resets while button still on 20x" bug).
- Point cards now play a preroll/postroll clip at 1× then replay at
  the chosen slow-mo rate, then resume normal playback.
- REPLAY ×N badge shown in the top-left of the video during replays,
  swapping palette when slow-mo kicks in.
- Slow-mo picker (¼× ½× ¾×) in the tab-header — window.slowmoRate is
  the source of truth for both point and review replays.
- Clicking a card hides the player chrome (controls-hidden re-applied
  across 0/10/60/200/500ms to beat pause/seek/play showControls races);
  mousemove over the video brings them back instantly.
- Review-tools slow-mo button removed — the card click IS the trigger.

Coach review specific
- Dual-thumb scrubber on a shared track with a red fill between the
  thumbs (visualises the note's range). Start clamps to ≤ end and
  vice-versa; nudges act on the last-touched thumb.
- Note overlay live-previews as user types and is drag-to-position;
  overlay position persists (position_x/position_y decimal(6,4)).
- Overlay uses container-query units (cqi) so text scales with the
  player width and position stays proportional on any resize.
- Timeline card design (chapter-style time label + subtle card with
  emoji, note, coach name, tools) — nothing else selected.

Sidebar
- "Rounds & points" / "Private notes" section labels removed; only
  the owner-only + Add Round / + Add Note button remains.
- Points that share a timestamp within a round collapse into ONE card
  with a chip per side (equal-width Blue/Red pills), one edit/delete
  pair, and a coloured score line (yellow ROUND N, blue/red score
  badges — background pills with white numerals).
- On mobile the highlights sheet only closes via its own toggle now.
  Backdrop is pointer-events: none so taps pass through to the video.

Mobile capture strip
- Renders below the video on mobile portrait so the paused frame is
  visible while typing. Wider inner padding (10px 20px 14px) so
  content doesn't sit on the edges.
- .pcs-entry uses flex-wrap: nowrap and Action input shrinks to
  fit — the row stays on one line at any width.

Collapsed action menu (video-actions component)
- Every viewport now shows the single  Action dropdown; individual
  desktop-action buttons are hidden by CSS across the board.

SPA navigation fixes
- window.videoId + isOwner now live on window so navigation to a new
  match video via Up Next can update them via reloadMatchVideoState.
- Tabs, event-item clicks, highlights toggle and sidebar-height sync
  re-hydrate on each SPA swap.
- Blade point-time regression: "{{ '@' . $fmtTime(...) }}" instead of
  the misused "@{{ ... }}" escape directive.

Backend
- Point-score recompute (recomputeRoundScores) already sums by
  (timestamp, id) order — works fine with the new decimal columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-08-09 02:26:19 +03:00
parent 55247828e1
commit 1c4e4986b5
7 changed files with 690 additions and 162 deletions

View File

@ -18,7 +18,7 @@ class MatchEventController extends Controller
$request->validate([ $request->validate([
'round_number' => 'required|integer|min:1', 'round_number' => 'required|integer|min:1',
'name' => 'nullable|string|max:50', '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 // Check if user owns the video
@ -45,7 +45,7 @@ class MatchEventController extends Controller
$request->validate([ $request->validate([
'round_number' => 'sometimes|integer|min:1', 'round_number' => 'sometimes|integer|min:1',
'name' => 'required|string|max:50', '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 // Check if user owns the video
@ -87,7 +87,7 @@ class MatchEventController extends Controller
{ {
$request->validate([ $request->validate([
'round_id' => 'required|exists:match_rounds,id', 'round_id' => 'required|exists:match_rounds,id',
'timestamp_seconds' => 'required|integer|min:0', 'timestamp_seconds' => 'required|numeric|min:0',
'action' => 'required|string|max:255', 'action' => 'required|string|max:255',
'points' => 'required|integer|min:1', 'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red', 'competitor' => 'required|in:blue,red',
@ -150,7 +150,7 @@ class MatchEventController extends Controller
public function updatePoint(Request $request, MatchPoint $point) public function updatePoint(Request $request, MatchPoint $point)
{ {
$request->validate([ $request->validate([
'timestamp_seconds' => 'required|integer|min:0', 'timestamp_seconds' => 'required|numeric|min:0',
'action' => 'required|string|max:255', 'action' => 'required|string|max:255',
'points' => 'required|integer|min:1', 'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red', 'competitor' => 'required|in:blue,red',
@ -202,8 +202,8 @@ class MatchEventController extends Controller
public function storeReview(Request $request, Video $video) public function storeReview(Request $request, Video $video)
{ {
$request->validate([ $request->validate([
'start_time_seconds' => 'required|integer|min:0', 'start_time_seconds' => 'required|numeric|min:0',
'end_time_seconds' => 'nullable|integer|min:0', 'end_time_seconds' => 'nullable|numeric|min:0',
'note' => 'required|string|max:1000', 'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100', 'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10', 'emoji' => 'nullable|string|max:10',
@ -238,8 +238,8 @@ class MatchEventController extends Controller
public function updateReview(Request $request, CoachReview $review) public function updateReview(Request $request, CoachReview $review)
{ {
$request->validate([ $request->validate([
'start_time_seconds' => 'required|integer|min:0', 'start_time_seconds' => 'required|numeric|min:0',
'end_time_seconds' => 'nullable|integer|min:0', 'end_time_seconds' => 'nullable|numeric|min:0',
'note' => 'required|string|max:1000', 'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100', 'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10', 'emoji' => 'nullable|string|max:10',

View File

@ -23,8 +23,10 @@ class CoachReview extends Model
]; ];
protected $casts = [ protected $casts = [
'position_x' => 'float', 'position_x' => 'float',
'position_y' => 'float', 'position_y' => 'float',
'start_time_seconds' => 'float',
'end_time_seconds' => 'float',
]; ];
public function video(): BelongsTo public function video(): BelongsTo

View File

@ -22,6 +22,10 @@ class MatchPoint extends Model
'score_red', 'score_red',
]; ];
protected $casts = [
'timestamp_seconds' => 'float',
];
public function video(): BelongsTo public function video(): BelongsTo
{ {
return $this->belongsTo(Video::class); return $this->belongsTo(Video::class);

View File

@ -19,7 +19,7 @@ class MatchRound extends Model
]; ];
protected $casts = [ protected $casts = [
'start_time_seconds' => 'integer', 'start_time_seconds' => 'float',
]; ];
public function video(): BelongsTo public function video(): BelongsTo

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Frame-accurate timestamps: the match highlights scrubber lands on
* frame boundaries (1/fps seconds). Storing these as INTEGER seconds
* throws away sub-second precision, so we promote the columns to
* decimal(10,3) 3 decimals cover up to 1000 fps and centuries of
* runtime, and existing integer values migrate losslessly.
*/
public function up(): void
{
// MatchRound.start_time_seconds is used to jump to a round's opening
// frame too, so bring it along.
Schema::table('match_rounds', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -71,10 +71,14 @@
color: var(--brand-red) !important; 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 { .mobile-action-dropdown {
display: none; display: inline-block;
position: relative; position: relative;
margin-left: auto;
} }
.video-actions > .desktop-action { display: none !important; }
.mobile-action-dropdown .dropdown-menu { .mobile-action-dropdown .dropdown-menu {
right: 0; right: 0;

File diff suppressed because it is too large Load Diff