takeone-youtube-clone/app/Http/Controllers/MatchEventController.php
ghassan 716693a74f Coach reviews: in-player capture strip, draggable overlay, timeline UI
Capture strip
- Twin of the point-capture strip, embedded inside #ytpWrap. Two time slots
  (Start + optional End) with the scrubber driving whichever is "active".
  Editable mm:ss inputs, zoom (1x/5x/20x), inline note + coach name + emoji
  chip picker. Cancel / Save on the right; Delete in edit mode.
- ▶ preview button plays Start → End and auto-pauses at End; click again to
  stop early. Playback uses the live overlay so previewing shows what the
  viewer will see.
- Removed the #reviewModal popup; openAddReviewModal(), setReviewEmoji(),
  saveReview() and the reviewForm submit binding are gone.

Live-preview overlay + drag-to-position
- As the user types, the coach-note overlay updates on the video in real
  time. In capture mode the overlay is grab-able and draggable to any spot
  over the video (mouse + touch), with a red dashed outline + "↕ drag me"
  hint.
- Overlay position is stored as normalized 0..1 CENTER coords of the video
  wrap and applied via `left: X%; top: Y%; transform: translate(-50%,-50%)`
  so the overlay stays at the same relative spot on any resize / fullscreen.
- Font-size, padding and border-radius scale with the player width via CSS
  container queries (`container-type: inline-size` on #ytpWrap + `clamp(...cqi)`),
  so the note grows/shrinks proportionally.
- Draggable overlay lives above the strip (z-index 70 vs 60) so it can be
  dropped anywhere including over the strip.

Persistence (schema change)
- Migration 2026_08_08_000001_add_position_to_coach_reviews: adds nullable
  decimal(6,4) position_x / position_y to coach_reviews.
- CoachReview fillable + casts updated. store/updateReview validate
  position_x / position_y as numeric between 0 and 1 and persist them.
- showCoachNoteOverlay(text, pos) applies the saved position on playback.

Cards: timeline design (Option B from the mockup)
- Vertical spine with red dots on the left; each review is a chapter-style
  time label above a subtle card. Card body: emoji as the leading indicator,
  note text, then coach name + tools (▶ / ✏️ / 🗑).
- ▶ = playReviewSlowmo(id): plays Start → End once at 1×, then loops back
  and plays again at 0.5×, then pauses and hides the overlay. Only rendered
  when the review has an end_time > start_time.
- Card body still clickable for the classic "jump-and-play-once" behaviour.
- Fixes a latent bug where the primary Save button stayed disabled after a
  successful save, silently swallowing the next click. Both the review and
  point capture strips now re-enable the button in their cancel handlers.

Also
- Design mockup file at public/coach-review-mockups.html shows the four
  candidate layouts side by side (kept for future design iterations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-08 07:07:03 +03:00

312 lines
10 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\CoachReview;
use App\Models\MatchPoint;
use App\Models\MatchRound;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MatchEventController extends Controller
{
// ==================== ROUNDS ====================
public function storeRound(Request $request, Video $video)
{
$request->validate([
'round_number' => 'required|integer|min:1',
'name' => 'nullable|string|max:50',
'start_time_seconds' => 'nullable|integer|min:0',
]);
// Check if user owns the video
if (Auth::id() !== $video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$round = MatchRound::create([
'video_id' => $video->id,
'round_number' => $request->round_number,
'name' => $request->name ?? 'ROUND '.$request->round_number,
'start_time_seconds' => $request->start_time_seconds,
]);
return response()->json([
'success' => true,
'round' => $round,
'message' => 'Round added successfully!',
]);
}
public function updateRound(Request $request, MatchRound $round)
{
$request->validate([
'round_number' => 'sometimes|integer|min:1',
'name' => 'required|string|max:50',
'start_time_seconds' => 'nullable|integer|min:0',
]);
// Check if user owns the video
if (Auth::id() !== $round->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$round->update([
'round_number' => $request->round_number ?? $round->round_number,
'name' => $request->name,
'start_time_seconds' => $request->start_time_seconds,
]);
return response()->json([
'success' => true,
'round' => $round,
'message' => 'Round updated successfully!',
]);
}
public function destroyRound(MatchRound $round)
{
// Check if user owns the video
if (Auth::id() !== $round->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$round->delete();
return response()->json([
'success' => true,
'message' => 'Round deleted successfully!',
]);
}
// ==================== POINTS ====================
public function storePoint(Request $request, Video $video)
{
$request->validate([
'round_id' => 'required|exists:match_rounds,id',
'timestamp_seconds' => 'required|integer|min:0',
'action' => 'required|string|max:255',
'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red',
'notes' => 'nullable|string|max:500',
]);
// Check if user owns the video
if (Auth::id() !== $video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
// Create the row first with placeholder scores, then recompute the whole
// round's running scores. This is the only correct approach when two
// points can share a timestamp (Blue + Red at the same moment) or when
// a point is inserted between existing ones.
$point = MatchPoint::create([
'video_id' => $video->id,
'match_round_id' => $request->round_id,
'timestamp_seconds' => $request->timestamp_seconds,
'action' => $request->action,
'points' => $request->points,
'competitor' => $request->competitor,
'notes' => $request->notes,
'score_blue' => 0,
'score_red' => 0,
]);
$this->recomputeRoundScores($request->round_id);
return response()->json([
'success' => true,
'point' => $point->fresh(),
'message' => 'Point added successfully!',
]);
}
/**
* Recompute cumulative score_blue/score_red for every point in a round,
* ordered by (timestamp_seconds, id). Handles same-timestamp ties by
* insertion order, so Blue and Red saved at the same moment both end up
* reflecting the running score after that moment.
*/
private function recomputeRoundScores(int $roundId): void
{
$blue = 0;
$red = 0;
MatchPoint::where('match_round_id', $roundId)
->orderBy('timestamp_seconds', 'asc')
->orderBy('id', 'asc')
->get()
->each(function (MatchPoint $p) use (&$blue, &$red) {
if ($p->competitor === 'blue') $blue += (int) $p->points;
else $red += (int) $p->points;
if ((int) $p->score_blue !== $blue || (int) $p->score_red !== $red) {
$p->update(['score_blue' => $blue, 'score_red' => $red]);
}
});
}
public function updatePoint(Request $request, MatchPoint $point)
{
$request->validate([
'timestamp_seconds' => 'required|integer|min:0',
'action' => 'required|string|max:255',
'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red',
'notes' => 'nullable|string|max:500',
]);
// Check if user owns the video
if (Auth::id() !== $point->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$roundId = $point->match_round_id;
$point->update([
'timestamp_seconds' => $request->timestamp_seconds,
'action' => $request->action,
'points' => $request->points,
'competitor' => $request->competitor,
'notes' => $request->notes,
]);
$this->recomputeRoundScores($roundId);
return response()->json([
'success' => true,
'point' => $point->fresh(),
'message' => 'Point updated successfully!',
]);
}
public function destroyPoint(MatchPoint $point)
{
// Check if user owns the video
if (Auth::id() !== $point->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$roundId = $point->match_round_id;
$point->delete();
$this->recomputeRoundScores($roundId);
return response()->json([
'success' => true,
'message' => 'Point deleted successfully!',
]);
}
// ==================== COACH REVIEWS ====================
public function storeReview(Request $request, Video $video)
{
$request->validate([
'start_time_seconds' => 'required|integer|min:0',
'end_time_seconds' => 'nullable|integer|min:0',
'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10',
'position_x' => 'nullable|numeric|between:0,1',
'position_y' => 'nullable|numeric|between:0,1',
]);
// Check if user owns the video
if (Auth::id() !== $video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$review = CoachReview::create([
'video_id' => $video->id,
'user_id' => Auth::id(),
'start_time_seconds' => $request->start_time_seconds,
'end_time_seconds' => $request->end_time_seconds,
'note' => $request->note,
'coach_name' => $request->coach_name,
'emoji' => $request->emoji ?? '🔥',
'position_x' => $request->position_x,
'position_y' => $request->position_y,
]);
return response()->json([
'success' => true,
'review' => $review,
'message' => 'Coach note added successfully!',
]);
}
public function updateReview(Request $request, CoachReview $review)
{
$request->validate([
'start_time_seconds' => 'required|integer|min:0',
'end_time_seconds' => 'nullable|integer|min:0',
'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10',
'position_x' => 'nullable|numeric|between:0,1',
'position_y' => 'nullable|numeric|between:0,1',
]);
// Check if user owns the video
if (Auth::id() !== $review->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$review->update([
'start_time_seconds' => $request->start_time_seconds,
'end_time_seconds' => $request->end_time_seconds,
'note' => $request->note,
'coach_name' => $request->coach_name,
'emoji' => $request->emoji,
'position_x' => $request->position_x,
'position_y' => $request->position_y,
]);
return response()->json([
'success' => true,
'review' => $review,
'message' => 'Coach note updated successfully!',
]);
}
public function destroyReview(CoachReview $review)
{
// Check if user owns the video
if (Auth::id() !== $review->video->user_id) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$review->delete();
return response()->json([
'success' => true,
'message' => 'Coach note deleted successfully!',
]);
}
// ==================== GET DATA ====================
public function getMatchData(Video $video)
{
// Check if user can view this video
if (! $video->canView(Auth::user())) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
$rounds = MatchRound::where('video_id', $video->id)
->with('points')
->orderBy('round_number')
->get();
$reviews = CoachReview::where('video_id', $video->id)
->orderBy('start_time_seconds')
->get();
return response()->json([
'success' => true,
'rounds' => $rounds,
'reviews' => $reviews,
]);
}
}