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 --}}
+
+ REPLAY
+ ×1
+
@@ -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 --}}
+