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:
parent
55247828e1
commit
1c4e4986b5
@ -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',
|
||||
|
||||
@ -25,6 +25,8 @@ class CoachReview extends Model
|
||||
protected $casts = [
|
||||
'position_x' => 'float',
|
||||
'position_y' => 'float',
|
||||
'start_time_seconds' => 'float',
|
||||
'end_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
@ -22,6 +22,10 @@ class MatchPoint extends Model
|
||||
'score_red',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'timestamp_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
|
||||
@ -19,7 +19,7 @@ class MatchRound extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_time_seconds' => 'integer',
|
||||
'start_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user