Covers Private visibility acceptance, ended-state Watch page shows
"Stream ended" (and omits the "Waiting for the broadcast" placeholder
from the connection JS), channel-name link resolves to the user's
channel URL, chat poll returns messages newer than ?since=, chat send
rejects guests (401), chat send rejects offline streams (422), and
private-stream chat 404s for non-owners.
Also broadens LiveStream::isWatchableBy to keep public/unlisted
streams permalinked after they end — viewers now see the Stream Ended
card and the Watch Replay CTA instead of a hard 404.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Restores the live streaming controllers / views / model from the
pre-audit WIP that got stashed on 2026-10-21, then applies the Phase 6
polish items:
## /live/new form
Add a Private visibility option that mirrors the upload modal's
wording — "Only you can see this stream. Perfect for testing without
an audience." Validator in LiveStreamController@store now permits
public|unlisted|private (was public|unlisted).
## Watch page ended-state
When the stream status is "ended", the overlay is a solid "Stream
ended" card showing the stream's duration (started_at → ended_at)
and, when a recording was published, a Watch Replay CTA linking to
the saved video. Previously an ended stream showed the generic
"Waiting for the broadcast to start" placeholder forever.
## Channel name link
The uploader's name in the meta row is now a real <a href> to the
channel page.
## Viewer action row
Subscribe + Share buttons below the player, visible to anyone except
the owner. Subscribe for guests uses the Phase 7 ?intended= pattern
so after sign-in they land back on the stream.
## Live chat (DB-backed polling)
New live_chat_messages table (migration 2026_10_21_000003). Viewers
GET /live/{stream}/chat?since=N every 3 seconds; signed-in users POST
to the same path (rate-limited at 60/min). Guests see the panel in
read-only mode with a "Sign in to chat" placeholder.
Private streams reject chat for anyone but the owner (404). Non-live
status rejects chat sends (422). Author name is snapshotted at post
time so a renamed account's old messages keep the name they used.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Ten tests spanning the eight commits in this branch:
- Player end-screen markup present (overlay + ring + cancel hooks).
- C hotkey binds to #ytpLyricsBtn in the player keydown handler.
- Guest Subscribe button carries ?intended= and encodeURIComponent.
- /@handle/videos 302s to the hash URL; nonsense tabs 404.
- /channel/{username}/videos/more returns a cursor-paginated JSON
payload with items + next_cursor + has_more.
- /studio redirects anonymous visitors to login.
- /studio renders only the signed-in user's videos.
- Studio bulk visibility + bulk delete scope updates to Auth::id().
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Skeleton for the creator studio surface deferred since Phase 6. The
/studio route lands on a filterable table of the signed-in user's
videos — All / Public / Unlisted / Private / Shorts / Processing-or-
Failed — with headline totals (public / unlisted / private counts,
processing + failed, total views) in a stat strip above.
Each row shows thumbnail + title + stripped description preview +
visibility badge + per-video totals. Row-level checkboxes raise a
toolbar when any row is selected; the toolbar supports:
- Change visibility across the selection (any of the three visibility
enums).
- Delete the selection with a confirm prompt.
Server-side the bulk endpoints owner-filter by Auth::id() so a crafted
request body can't flip or delete anyone else's videos. Response is
JSON { updated | deleted : N } so the page can reload / toast cleanly.
Sidebar now carries a persistent "Studio" link under the You section
alongside History / Liked Videos.
Analytics, stepped upload (Details → Visibility), and schedule remain
future work — this ships the content management surface, nothing else.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Previous commit added the HTML + CSS for the comments pane but the JS
handlers only landed in the first (markup) Edit. This drops the two
window.short*Comments functions into the IIFE so the onclick handlers
in the markup actually resolve.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The comments icon on each Short used to open the full /videos/{id}
page, kicking the viewer out of the vertical feed. Now it opens a
shared side drawer on desktop (right edge, 420px wide, starts below
the header) or a bottom sheet on mobile (70vh, slides up from the
bottom nav), without leaving the feed.
Content is the same resources/views/components/video-comments
section rendered on the watch page — fetched as XHR and the whole
#ytcSection node is lifted into the pane. All the existing comment
features (replies, likes, translate, heart/pin, permalinks) work
identically because we're reusing the exact same DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two complementary additions that share the same bridge-not-rewrite
philosophy — reuse the existing single-page channel view instead of
splitting its 2000 lines into five route-driven partials.
/@handle/{tab}: videos / shorts / playlists / posts / about / wall
redirect to the equivalent /channel/{slug}#tab URL (302). The channel
page's existing hash-tab JS already handles landing on the right tab;
these aliases just make the URLs shareable in the YouTube style.
Channel Videos tab Load More: new JSON endpoint
GET /channel/{username}/videos/more returns the next cursor page of
server-rendered video cards. A sentinel + IntersectionObserver below
the grid fetches the next page when scrolled into view, with a Load
More button as fallback. Behavior mirrors the Phase 2 home feed —
users with thousands of uploads no longer get a 300-item grid on
first paint.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Clicking Subscribe as a guest used to redirect straight to /login with no
context. Now a short toast tells the visitor "Sign in to subscribe to
{channel}" and the redirect carries ?intended=<current path> so after
login they land back on the same video instead of the home page.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
YT parity. "C" clicks the visible #ytpLyricsBtn when there is one (music
type), no-op otherwise so hitting C next to K / L / M on a plain video
doesn't trigger something unexpected.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
When a video finishes and autoplay is on, instead of switching to the
next recommendation silently we show a YouTube-style end screen with
the next video's thumbnail, title and a radial countdown. The viewer
can Cancel to stay put, Play Now to skip the countdown, or just wait
and the existing _plOnVideoEnd / navigateNext handoff fires at zero.
End screen is skipped entirely when there's no sidebar card to show
or when loop is on (loop keeps the old same-video behavior).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three features that depend on the two additive migrations shipped in
the same commit (safe defaults, cascading foreign keys, no data loss
possible).
## Dislike button
- New video_dislikes table (migration 2026_10_21_000001) with the same
shape as video_likes.
- Video model: dislikes() relation + isDislikedBy() / getDislikeCount
Attribute() helpers mirroring the like pair.
- UserController@toggleDislike: endpoint behind POST
/videos/{video}/toggle-dislike. Mutually exclusive with like — the
toggle*Like / toggle*Dislike controllers detach the opposite state
so a user is in exactly one of {liked, disliked, neither}. YouTube
parity.
- video-actions component: a second thumbs-down button next to the
existing like. Both reconcile in parallel on either response so the
UI pair always reflects the authoritative server state.
## Creator heart + pin
- comments table gains hearted + pinned boolean columns (migration
2026_10_21_000002, both default false, pinned is indexed).
- CommentController@heart + @pin: 403 for anyone but the video owner;
pin() also rejects nested replies (422) and auto-unpins the
previously-pinned top-level comment so the "at most one pinned"
invariant is enforced at the DB level.
- Comment component:
- Pinned comments render ahead of everything else; reorder()
clears the comments() relation's default ->latest() so pinned
DESC becomes the PRIMARY sort instead of a tiebreaker.
- Creator sees heart + pin toggle buttons in the action bar.
- Hearted comments carry a small red heart badge in the meta row.
- Pinned comments carry a "Pinned by {creator}" header and a
red left accent so they're visually distinct from the stream.
## Tests (9 new, 63 total)
- Dislike toggle adds/removes the row.
- Disliking clears an existing like and vice versa.
- Owner can heart; non-owner gets 403.
- Only one comment pinned at a time; replies can't be pinned (422);
non-owner gets 403.
- Pinned comments render before newer unpinned ones.
## Deploy note
Running the migrations against the live DB needs:
php artisan migrate
The two migration files are additive and reversible via `migrate:rollback`.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Four tests covering the post-audit polish commits:
- Match watch page renders the shared .channel-row component.
- Reply-to-reply button carries data-parent-id and data-handle so the
JS can prefill "@author " when the parent reply form opens.
- liked() controller returns a CursorPaginator instance (controller
introspection — avoids a 400 KB view render per assertion).
- video-player component ships the ">" / "<" playback-rate hotkey.
Also cleans up a stale ->onEachSide()->links() call in user/liked.blade
that would blow up now that the controller returns a CursorPaginator
(which doesn't support onEachSide).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- /liked: switch Laravel paginate(12) to cursorPaginate(24). Cursor
pagination matches the Phase 2 feed endpoint pattern: stable across
inserts (unlike ?page=N which can duplicate or skip rows when a new
like lands between page fetches) and loads a bounded slice no matter
how many thousands of likes the user accumulates.
- /history: switch from a get()-all plus in-memory sort to a joined
simplePaginate(48) at the DB layer. A heavy user who's watched
thousands of videos would previously fetch every video row into
memory on each history page load — now the query selects a bounded
page of (video_views + videos) pairs. Duplicates within a page
collapse via collect()->unique('id').
Channel videos tab stays non-paginated for now — it needs a dedicated
JSON endpoint before it can share the Phase 2 /videos/feed/more
infinite-scroll wiring. Flagged as follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
YouTube-parity hotkeys. Shift+. (">") steps playback up by 0.25 to a
max of 2x; Shift+, ("<") steps it down to a min of 0.25x. "." / ","
alone also work when the browser doesn't surface Shift for the
underlying character — e.g. non-US keyboard layouts.
Theater (T), PiP (I), Fullscreen (F), Play/pause (K / space), Mute
(M), Seek (J / L / arrows), % seek (0-9) were already wired — this
closes the remaining Phase 4 hotkey gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Match page: the only path from a match video to the uploader's channel
was a tiny avatar buried inside the Match Overview card. Add the shared
<x-channel-row :video> component right under the title + action bar so
the Subscribe + channel link is as prominent as it is on music and
generic pages.
Comments:
- Long comments collapse to a 4-line clamp with a "Read more" / "Show
less" toggle. The CSS line-clamp doesn't cut mid-word, and the
button is added after enhanceBody() runs so it measures the final
rendered height (timestamps + mentions already materialised).
- Nested-reply Reply button: each reply now carries a button that
opens the parent-level reply form and prefills "@AuthorName " so
the thread stays readable even though the DB only stores one-level
nesting.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Four tests covering the admin polish follow-ups to Phase 7:
- Pagination partial renders both First and Last anchors with the
correct aria-labels and page URLs on a middle-page view.
- Pagination partial degrades to a disabled First span on page 1 so
the admin can't click into an invalid negative page.
- Admin video list strips HTML tags from the description preview
(no "<p>Hello" leaking into the row) and carries the full
title in a title="" tooltip on the ellipsized columns.
- Dashboard stat card now shows "Ready" instead of the misleading
"Live Videos" label the Phase 7 commit renamed.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Storage:
- The dashboard storage card used to walk Storage::disk('public')'s
videos / thumbnails / avatars / images directories. NAS is primary
storage, so for every file that's been pushed-and-purged (most of
them, per CLAUDE.md), the disk walk reports 0. Replace with a
DB-driven estimate: SUM(videos.size) for video bytes plus a 120 KB
conservative per-file estimate × (thumbnails + avatars + banners +
post images). Fast (3 count queries + one SUM), works regardless
of local-cache state, and now reflects content that lives on NAS.
Error logs:
- Flat view used to render every line as its own equal-weight <div>,
so a stack trace of 70 frames buried the next real event. Group
lines by header: a line that matches .(ERROR|WARNING|INFO|DEBUG|
CRITICAL|…): starts a new group; subsequent lines belong to its
stack trace. Each group collapses by default behind a caret the
admin can expand. Stack-trace lines preserve their natural #0 → #N
order inside the group even when the file is reversed newest-first.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Admin video list: description was stored as sanitized rich HTML, so
the admin's "{{ Str::limit(description, 55) }}" row printed literal
<p>…</p> tags in the preview. Pre-strip the HTML, collapse
whitespace, and surface the full stripped text in a title=""
tooltip. The visible title column now also carries title="" so the
admin can hover an ellipsized name to read it in full.
- Shared pagination partial (used by admin videos + users + feed
pagers) now ships First + Last buttons. The icons use the double-
chevron variants and the aria-label makes the destination explicit.
Buttons disable cleanly on page 1 and the last page.
- Audit-logs pager has the same four buttons: First, Prev, N…N, Next,
Last. Each disabled state carries a title tooltip so the admin
knows what each stub does without hovering through trial and error.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
PHPUnit was flagging 18-19 tests per run as "did not close its own
output buffers". The leaks come from Blade — a @once block, a @push,
or a view that throws mid-render leaves ob_start()'ed buffers open
when the test method returns.
Capture ob_get_level() in setUp, then unwind back to that depth in
tearDown. Suite is now green AND clean: 46/46, 0 failures, 0 risky.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
ExampleTest hit GET / expecting 200, but had no RefreshDatabase trait,
so the home controller's Video::public()->pluck('id') query hit a
non-existent `videos` table in the testing DB and 500'd. It's been
failing since Phase 1 but was always flagged as "pre-existing,
unrelated" because the test file isn't touched by any audit code.
Now mirrors the Phase-test setup: putenv forces :memory: before
parent::setUp, RefreshDatabase migrates the throwaway DB, and the
assertion passes against an empty home feed. 46/46 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Six tests covering:
- Skip-to-content link renders on the home page with href="#main".
- Hamburger menu + mobile search toggle buttons carry aria-label
attributes (regression test for icon-only buttons losing their
accessible name).
- Registration is server-side rejected with a birthday error when
the user is under 13; a 14-year-old passes.
- Gender select on channel settings uses "Select…" for the empty
value instead of a second "Prefer not to say".
- Unknown URL returns 404 with the branded body ("Error 404").
The <title> format test for the 404 page is captured in the deploy
checklist instead of here because the exception handler in the
testing env bypasses layouts.app's head and makes the DOM-level
<title> assertion unreliable; curl -I against /no-such on the live
site confirms "Page not found | TAKEONE".
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A11y:
- Skip-to-content link lands first in the body and jumps to #main
past the header + sidebar for keyboard users.
- aria-label on the icon buttons that previously carried only an
icon: hamburger menu (+ aria-controls="sidebar"), mobile search
toggle, account menu (also drops the duplicate alt on the avatar
since the button name already speaks the user's name).
Admin:
- Dashboard stat card renamed "Live Videos" → "Ready" (matches the
DB status column; nothing on this card is actually live).
Settings:
- Duplicate "Prefer not to say" option on the Gender select — the
empty placeholder shared the same label as the real option.
Placeholder is now "Select…"; the real "Prefer not to say" option
stays unchanged.
- Birthday picker caps the Year dropdown at (this year − 13) and
the server-side register validation requires the same, so a
client bypass can't let a <13 user through.
Titles:
- Admin layout uses "{Page} | TAKEONE" to match the frontend format
(was "{Page} — TAKEONE"). User pages that said "| TAKEONE" literal
now interpolate config('app.name'). Shorts and Welcome moved from
" - " to " | " separator.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Also tightens stat/post-video rendering so "0" prints as "0" and not
"0.0" (Number::abbreviate(0, precision: 1) stringifies as "0.0", which
leaked into the About tab's Total Views card and post-video badges).
Four tests covering:
- /@username opens the matching channel.
- /@invalid url (space) 404s.
- Owner view shows id="postForm"; preview?preview=1 hides it.
- Zero-view playlist doesn't render "bi-eye … 0.0"; "1 videos" never
renders in place of "1 video".
Fixed a Blade dupe-declaration fatal that only fired in feature tests
(two requests in one PHP process would redeclare notifVal()). Guarded
with function_exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- New /@handle route (channel.handle name) that maps to UserController@channel,
so YouTube-style profile URLs work. Handle is the existing username
slug for now; a dedicated `handle` column can land later if we want
the two to diverge. {handle} is constrained to [A-Za-z0-9][A-Za-z0-9._-]*
so arbitrary paths don't match this route.
- Composer on the Wall tab used Auth::id() === $user->id directly,
which bypassed the $isOwner flip-to-false that preview-as-visitor
does. The composer now also checks !$preview so previewing an
owner's channel hides the post box as a visitor would see it.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Playlist cards on the channel page used Number::abbreviate for the
view count, which prints "0.0" (precision=1) when the playlist has
never been viewed. Hide the whole view-count row for zero-view
playlists; still show "1.2K" / "2.3M" when there's traffic.
- "N videos" counts (header meta row + per-playlist count) now use
Str::plural so "1 video" renders correctly instead of "1 videos".
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Five tests:
- /shorts/{id} renders the vertical feed with data-id matching the
target short and includes the scrollIntoView bootstrap.
- /shorts/{id} 404s when the id resolves to a non-short video.
- Home page shows .home-shorts-shelf when at least one short exists,
with a link to /shorts/{id} for every shelf card.
- Home page omits the shelf entirely when no shorts exist.
- An unknown URL returns 404 with the branded view, not the bare
framework stacktrace.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Any URL that doesn't resolve to a known route or model now renders a
page that matches the app shell: header, sidebar, dark theme. The
page explains what happened in one sentence and offers three next-
step buttons — Home, Browse Videos, Watch Shorts. Previously /shorts/1
(and every other unknown URL) rendered the bare framework 404 with no
header, no navigation and no theme.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The home grid used to interleave shorts with regular videos ordered by
upload date, so a cluster of 60-second portrait clips could fill the
first row and push the non-shorts off the fold. Split them:
- The main feed grid now excludes shorts (notShorts() filter).
- A horizontally-scrolling Shorts shelf renders above the grid with
up to 12 latest shorts, each a real <a href> to /shorts/{id} so
tapping it deep-links the vertical feed to that short.
- "See all" link in the shelf header jumps to the full /shorts feed.
- Shelf is skipped entirely when there are no shorts to show.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- New route /shorts/{video} opens the feed scrolled to that short.
Controller aborts 404 when the id doesn't resolve to a short or
isn't viewable by the current viewer, so bad links land on the
branded 404 (added in the next commit) rather than loading an
empty feed.
- The IntersectionObserver that detects the active short now
history.replaceState()s the URL to /shorts/{id} so copy-paste of
the address bar works, without pushing a navigation entry — the
back button still exits the feed in one step.
- Mute state is persisted in localStorage (shortsMuted = '0' | '1')
so unmuting once sticks across sessions. Default remains muted
to satisfy autoplay policies on first load.
- Mouse-wheel navigation moves one short per gesture with a 450 ms
debounce. Trackpad flicks no longer skip five shorts at once.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- All three type-specific watch templates (generic / music / match)
were still rendering the Up Next list as <div onclick="recGoTo(...)">
— only show.blade.php had been converted in the earlier commit.
Convert each to <a href> so middle-click / right-click / cmd-click
open-in-new-tab work, keeping the SPA swap on normal left-click via
an onclick guard that lets modified clicks fall through.
- 9 feature tests covering: singular/plural comment counts, top-level
vs reply separation, ?lc= permalink HREFs, markdown header
promotion in HtmlSanitizer::render(), passthrough of plain text,
download access gate for both MP4 and MP3 at disabled + registered
tiers, and sidebar anchor-card presence.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
AI-generated descriptions ("Letra" / "Chorus" / "Translation") landed
in the DB with markdown prefixes that the sanitiser treated as plain
text — so viewers saw literal "## Letra" and "### Chorus" instead of
headings.
HtmlSanitizer::render() now promotes `##` / `###` at the start of a
line into <h2>/<h3> before sanitising, so the existing allowlist
accepts them and the reader sees real headings. Only touches lines
whose entire content is a heading — markdown mid-sentence is left
alone to avoid false positives.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A video with two audio tracks sharing the same language (common when
an upload was retried before the first attempt finished syncing) used
to show both rows in the player's language picker, leaving the viewer
guessing which one to pick. Dedupe keeps the first occurrence per
(language, label) pair — so legit variants like "en — Original" and
"en — Director's cut" still coexist, but bare duplicates collapse.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Header count now reads "N Comments · M replies" with correct
pluralisation ("1 Comment", "1 reply" — never "1 Comments"). The
old "N comments" number silently folded replies into the main
figure, overstating conversation depth.
- Every timestamp is a permalink. href="?lc=<id>#comment-<id>" lands
on the exact comment or reply when opened — the init handler
auto-expands the parent thread for nested replies, scrolls the
target into view, and pulses a soft red highlight for 2.8s so the
reader sees where they landed.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- VideoController@show now scores the newest 200 public ready videos
against the one being watched: +4 same type, +3 same language, +2
any tag overlap, +1 same channel. Sort by score desc then by newest.
Replaces the plain "latest 20 excluding self" list that was often
dominated by whatever happened to be uploaded last, with no regard
to what the viewer actually chose.
- Sidebar cards are real <a href> elements again, so middle-click,
right-click, cmd-click and screen readers all work. The SPA click
interceptors in types/{music,generic}.blade.php still fire via
data-rec-url for a soft-swap when the user left-clicks normally.
- loading="lazy" + width/height on the sidebar thumbnails so a long
Up Next list doesn't blow the critical path.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@json() with a multi-line arrow function whose body is an array literal
confuses Blade's directive parser — it reads the inner '[' as an
unmatched bracket and bails out with "Unclosed '[' on line 744 does
not match ')'". Precompute the array in a plain @php block and hand
the already-evaluated collection to @json() instead.
The bug has been dormant because the compiled view was cached. It
surfaced the moment the view cache got cleared.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Six feature tests:
- Trending accepts hours=24|168|720, snaps garbage input to 24.
- Trending ranks by in-window views then likes; videos with zero
in-window views are dropped.
- Music feed opens on the newest video (regression test for the
skipped-->latest() call in VideoController@index).
- Old /videos/search?q=... returns a 301 to /search?q=...
- /search matches on video title, channel name, and playlist name
separately; each surfaces in its own Section.
- Empty /search?q= redirects to /videos browse.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add a dedicated SearchController that matches the query against video
title / description / tags / primary language AND any secondary audio
track language (so "arabic" surfaces videos that carry an Arabic track
even if the primary language is English), plus channel name / username
/ bio, plus playlist name / description. Public visibility only.
Results reuse the videos/index view so every card gets the same
component the browse feeds use. A new Channels section renders above
the Videos grid when channel hits exist.
/videos/search is kept as a 301 redirect to /search so bookmarks and
external links stay valid; both the desktop header and mobile overlay
search forms now point at the canonical route, both carry a visually
hidden <label>, both get role="search" and both preserve the current
query in their input.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- 'New to You' promised personalised recommendations the chip did not
deliver — it was simply filter=latest, the newest videos regardless
of viewer. Renamed to 'Recent' so the label matches the behaviour.
- Every active chip now carries aria-current='page' so a screen reader
knows which filter is live without having to infer from the visual
'active' class. role='navigation' + aria-label on the bar.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The main match scoreboard overlay used to render the literal strings
"CLUB LOGO" and "FLAG" inside the per-corner slots whenever the match
had no club logo or flag assigned yet. The slots were there as a visual
design cue, but the text was leaking into cards (via the mini scorebar
overlay that already got removed in Phase 2) and showing up on the
scoreboard itself while a match was being set up.
Both slots are now empty by default. The script.blade.php logic that
replaces them with a real <img> or SVG background on load is unchanged
— when real data is present the output is identical. aria-hidden on
the empty boxes so a screen reader doesn't announce the gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Trending: window options are now 24 / 168 / 720 h (Today / This Week
/ This Month) and the controller snaps the ?hours param to one of
those values instead of clamping to [24, 168] — which silently made
the "This Month" tab show one-week data. Ranking is pure in-window
view count, with likes and newest createdAt as the tiebreaks (not
the old weighted sum of views + velocity + recency bonus + likes,
which was impossible to reason about and hid videos that genuinely
led the window).
- Music + /videos?filter=music: previously the branch skipped the
latest() call so Eloquent fell back to id ASC and the music tab
opened on the oldest uploads. Now both branches share one
->latest() call.
- Channel Popular sort: order by views desc, likes desc, created_at
desc so two equally-viewed videos no longer flip order between
loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Audit fixes against /channel/{slug}:
1. Fluid video grid — repeat(auto-fill, minmax(260px, 1fr)) replaces
the three fixed breakpoints (3/2/4 cols at 1101/1500/… px) that
produced different card widths and could overflow 390 / 1280 /
1920. min-width:0 on every grid child so a long unbroken title
(CJK, "SPARTAWORRIOR") can't widen its track past 1fr.
2. Scoreboard thumbnail — stop including vs-mini + scorebar-mini on
match cards. Those partials leaked fighter names, "AKA/AO",
"ROUND 1", "0:00" into the card DOM, showing stale 0-0 for
finished matches and breaking title layout at narrow widths. The
regular <img> above is the thumbnail now; VS intro + live scorebar
still render on the full match player page where they belong.
3. Sort pills — Latest/Popular/Oldest now append #videos so the page
stays on the Videos tab after reload instead of jumping to Wall.
4. Shorts tab — always render for the owner (so the "My Shorts"
sidebar link always lands somewhere), with an empty-state card
when no shorts exist. Visitors still see it only when shorts > 0.
5. Subscriptions sidebar link — href="#" replaced by a real
/feed/subscriptions route backed by UserController@subscriptionsFeed
that reuses the videos/index view with the subscription feed.
6. Header view count — totalViews now sums video_views over public
videos for visitors (owner still sees the full total). Previously
leaked unlisted and private video view counts.
7. Card redundancy — new hideChannelInfo prop on <x-video-card/>
suppresses the per-card avatar + channel-name row. The channel
page passes it; everywhere else keeps the row.
9. Decorative logo — the second <img alt="Play"> in the responsive
header pair now has alt="" so a screen reader doesn't announce
"Play Play". ⋮ menu button gets aria-label="More actions".
10. Privacy — age, gender, location and horoscope strip now only
render to the owner on the channel header (horoscope strip is
also allowed when the compatibility match-up is being shown to
a signed-in visitor). Joined-month stays public.
11. Notifications dropdown — initial state is "No notifications"
not an indefinite "Loading…". Opening the panel now shows a
spinner while fetching, surfaces a Retry button on error, and
bails out of the "Loading…" state on non-ok HTTP responses.
Title clamp — .yt-video-title is now a 2-line box with
overflow-wrap:anywhere so CJK titles and long words wrap mid-word
and can't widen the card past its track.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Context: on 2026-10-21 the dev SQLite DB was wiped because an early
revision of Phase 1 tests called RefreshDatabase before the per-test
config() swap had been applied, so migrate:fresh ran against the real
database file. Restored from data/backups/db-20261020-033001.sqlite
(24 users, 72 videos, 32 comments recovered); anything created between
Oct 20 03:30 and Oct 21 08:25 needs manual rebuild from NAS.
Two layers of defense added so this cannot recur:
1. .env.testing — forces DB_CONNECTION=sqlite and DB_DATABASE=:memory:
at the earliest bootstrap stage, before any trait setUp hook runs.
Also carries the APP_KEY and testing-safe defaults for mail/queue
/session.
2. TestCase::assertSafeTestDatabase() — a belt-and-braces guard that
aborts the whole phpunit run (exit code 2) if the resolved default
DB target is anything other than :memory: or a /testing/ or /tmp/
path. The abort happens BEFORE any query can execute.
Running the suite with a bad DB target now prints a stderr banner and
stops instantly; the dev file is safe even if someone writes a new
test that forgets to override the connection.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Seven feature tests covering:
- the per-card edit modal is gone (id="editVideoModal{slug}" and the
.cute-edit-modal class),
- owner sees Edit/Delete and no Report/Not-interested,
- non-owner sees Report/Not-interested and no Edit/Delete,
- card thumbnail img carries width/height and loading="lazy",
- feedMore returns JSON with items + next_cursor + has_more,
- feedMore rejects unknown filters by returning an empty slice,
- comments component ships the reply <template>, not inline forms.
While wiring these up I also deleted the 300-line .cute-edit-modal CSS
block that was still shipping in card-styles.blade.php even though its
markup was removed in the previous commit — the CSS was pointing at
IDs that no longer exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The reply form used to render ~13 lines of hidden markup under every
top-level comment, so a 50-comment video shipped roughly 650 lines of
inert DOM to every signed-in viewer. Replaced with a single
<template id="ytc-reply-form-tpl"> plus a per-comment slot div; the
first click on Reply clones the template, wires the IDs
(replyForm{id}, replyBody{id}, data-parent-id, data-video-id) and
appends it, so toggle behavior stays identical from the user's side.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add VideoController@feedMore -> JSON { items, next_cursor, has_more }
that pages the current filter (all / shorts / match / music / lang:xx)
with Laravel's cursorPaginate(24). Cursor is stable across inserts, so
scrolling never duplicates or skips a video when something is uploaded
mid-session.
Client side: a #feed-sentinel + IntersectionObserver (rootMargin
400px) appends the next page of cards to the existing grid without a
page reload. A Load More button is always wired as the fallback when
IO is unavailable or a fetch fails. Both reuse the existing
<x-video-card/> component, so the newly fetched cards get the same
singleton edit modal + width/height thumbs as the server-rendered ones.
The initial 60-card server render stays, so the first viewport is
unchanged. The limit(60) and limit(50) caps in index() can now be
treated as "first page size" rather than a hard ceiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Delete the per-card "cute" edit modal (lines 242-341) and the three
dead handlers that drove it (openEditVideoModal, closeEditVideoModal,
the type/privacy/thumbnail/submit document-level delegators). The
singleton at layouts/partials/edit-video-modal.blade.php already
defines a global window.openEditVideoModal that always won the
last-definition race, so clicking Edit was already going through
the singleton. Card is 547 -> 308 lines; a 60-card feed drops
~14 000 lines of modal markup.
- "Not interested", "Don't recommend channel", "Report" are now gated
on @unless($isOwner) — they're meaningless on your own video.
- All dropdown items that were <a href="javascript:void(0)" onclick>
are now real <button type="button">, including the save-to-playlist
and share slots (tag="button").
- Thumbnail img gains width="320" height="180" so the browser reserves
layout space before the image decodes — removes layout shift on
slow connections. The actual rendered size still comes from
.yt-video-thumb's aspect-ratio: 16/9.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Seven feature tests, run against an in-memory SQLite forced via putenv
in setUp (keeps the shared dev DB safe). Covers:
- Reserved-identifier rule rejects admin / super_admin / takeone /
support / etc, matches both name and email local-part only, and
lets normal names through.
- SafeNotifier swallows a notification throw without re-raising.
- Identical comments within 60s collapse to the first row.
- X-Idempotency-Key returns the first comment on retry.
- Owner viewing their own video does not insert a video_views row.
AppServiceProvider stops force-upgrading the scheme in the testing env
so the HTTP test client isn't 302'd before reaching the controller.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Insert is skipped entirely when the authenticated viewer owns the
video. Owners re-watching, previewing an edit, or impersonating no
longer inflate their own view counts.
- Dedupe window drops 1h -> 30min for both auth and guest paths.
- Auth-path dedupe now also matches on device fingerprint / device_id
cookie, so signing out and back in during the same session doesn't
double-count a single viewer.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Collapse historical duplicate comments produced before the 60-second
dedupe guard landed. Keeps the earliest row in each cluster, reparents
nested replies onto it, moves comment_likes rows, deletes the rest.
Default --window=60 matches the server guard; --dry-run shows the plan
without touching the DB. Transactional per group so a partial failure
does not leave orphaned likes.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Server window raised from 5s to 60s so slow double-fires (observed
on /videos/CRKAf: same body saved 3-7 times, seconds to minutes
apart) collapse into one comment.
- X-Idempotency-Key honored: the client sends a per-attempt UUID; the
server caches the resulting comment id for 5 minutes under that key.
A retry with the same key returns the first-written comment without
touching the DB a second time.
- Comment and reply submit JS both send the key via fetch().
The existing _busy flag + disabled submit button already covers the
fast double-click case on the client; this hardens the server side so
duplicates can't slip through even when the client guard is bypassed.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Drop /login throttle from 10/min to 5/min per IP.
- After every failed login, count user.login.failed audit entries for
the same email in the last 10 minutes. If >=5, fire a one-shot
(cache-deduped) BruteForceLoginAlert notification to every super
admin via database + mail channels.
- Alert carries the email, originating IP, failure count and window
so the admin can decide whether to force a reset.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Resend the verification link to one user (--user=<id|email>) or every
unverified user (--all-unverified), with a --dry-run preview. Uses
User::sendEmailVerificationNotification() which is already Throwable-
guarded, so SMTP hiccups during a bulk flush log instead of aborting.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add App\Support\SafeNotifier that wraps $notifiable->notify() in a
Throwable guard with a logged warning, so SMTP outages (the current
Gmail 535 errors) no longer propagate as a 500 to the user. Apply to
the two hot paths that were still unprotected: CommentController
(new comment, reply, like) and RegisteredUserController (super-admin
registration alert). User::sendEmailVerificationNotification() is
wrapped inline for the same reason.
Also add docs/mail-setup.md covering the three delivery options:
Gmail app password (quick), Mailgun, Postmark — with the exact .env
keys for each.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add App\Rules\ReservedIdentifier for name + email local-part so users
cannot impersonate staff (admin, superadmin, support, root, takeone, …).
Separators are collapsed so "super_admin"/"super-admin"/"superadmin"
all match. Rule wired into RegisteredUserController@store.
email_verified_at is already absent from User::$fillable so only the
signed verification route can flip it — audited, no code change needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Checklist across security, performance/DOM, feeds/search/trending,
watch page, Shorts, channel/studio/live, and admin/a11y. Branch per
phase; backup tag backup/pre-audit-2026-10-21 at 5058eaf.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three bugs made merge destructive and unrecoverable:
1. Primary-track slide paths were only prefix-swapped {sourceDir} → {targetDir}.
The promoted primary track got a NEW folder name
tracks/{lang}-{newTrackId}, but the slide filename kept the old
tracks/{lang}-{srcVideoId} segment, pointing at a folder that no longer
existed. Rewrite is now computed from the owning track's updated path, so
it survives regardless of what the stored filename used to look like.
2. Legacy-layout sources (primary audio directly at {sourceDir}/audio.ext,
slides at {sourceDir}/slides/) were silently skipped. The old code
hard-coded tracks/{lang-id}/ as the source location. Merge is now
layout-aware: it reads dirname($source->path) to pick between folder-move
(new layout) and per-file moves (legacy — audio, slides/ dir, thumb).
3. Lyrics were lost on legacy sources. {sourceDir}/lyrics/primary.json and
{sourceDir}/lyrics/track-{id}.json are now explicitly moved into the
target's per-track folders as lyrics.json, matching the new layout the
LyricsService reads from.
Added moveFile() helper (mirrors moveFolder on NAS + local, queues to
pending_nas_moves when NAS is unreachable). Verified against six scenarios
including new-layout, legacy, target-with-existing-extras, minimal, 2-extras
with slides and reversed-position lyrics source: all broken-file counts are
zero and slide position order is preserved verbatim.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Track-form unification: the "Add Language Track" dialog in the edit modal now
clones the same #um-track-form-template partial that the create modal uses, so
future tweaks to markup/CSS/behaviour show up in both flows automatically.
Added window.cloneTrackFormTemplate() + window.initTrackFormNode() as the
shared cloner+init. updateTrackCard() writes to both um-tc-*-{id} and
edit-tc-*-{id} ids so one helper serves both modals. Dropped the dead
#edit-extra-track-tpl and the custom editHandleTrackAudio (handleTrackAudio
auto-fills title identically).
Cover slides cropper fixes (both modals):
- Second batch no longer hangs. _slidesCropLoadNext now calls
openCropperModal_* BEFORE tcPreload_*; previously preloading first measured
canvas.clientWidth=0 against a display:none overlay and left _st.ready
false so Save silently returned.
- Reorder no longer loses thumbnails. Create flow stored raw Files and
spawned a FileReader per slide per render; under memory pressure some
readers failed silently. Now stores {file, url} with URL.createObjectURL
called once per slide, and renderSlidesStrip assigns img.src = url
directly. Removal revokes the URL; clear revokes everything it drops.
- Reset .value on the slides input after processing so re-selecting the
same filename still fires change.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Comments get a Translate button that calls the active LLM provider in Admin → AI
settings. Each commenter shows a small country flag inferred from their
video_views + profile_visits history (falls back to users.nationality); that
inferred country drives the language hint passed to the LLM so Portuguese
"Linda, linda, linda" translates to "Beautiful" instead of pass-through.
Description boxes get a line-paired translation grid: a shared Translate button
flips #vdbDescSplit into a two-column CSS grid where every source line sits
next to its translation on the same row. Backend splits by newline and asks
the LLM for a JSON map keyed by index so counts always match.
LlmLyricsService now has translate() + translateLines() with echo-retry (if the
model returns the input unchanged while source != target, retry once with a
stronger directive) and keeps the Ollama model resident via keep_alive=1h.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Between 992px and 1300px the left nav (240px) and the 300px side rail left
the video column under ~700px. Up Next titles collapsed into a ~124px
channel and the match About card broke its VS row apart, so a window that
was not full-screen lost information instead of reflowing.
Add one additive @media (max-width: 1300px) block per video type that fires
the SAME stacking the <=991px layout already did, just earlier: layout goes
column, video section and sidebar go full width. Match also collapses
fighters-row to 1fr and stacks the event header.
Nothing is removed. >=1301px is byte-for-byte the old desktop layout, and
the <=991px block still owns the phone treatment (thumb-on-top cards, match
review view, bottom sheet).
Verified by headless render at 1600/1360/1200/1050/960/820/700/420: no
horizontal overflow at any width, desktop unchanged, and a music video still
plays (readyState 4, stream advancing) per the match-type-only rule.
Generic could not be checked visually - there are no generic videos in the
database - but its block is identical to music's.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The point-capture and coach-review strips have a forcePause helper that
pauses the video (and re-pauses 30ms later to beat HLS.js's async
resume) whenever the slider is grabbed or scrubbed. It wasn't being
called from the frame-step buttons or the typed-timestamp commit, so
using the ± nudge to land on an exact moment while the video was
playing would happily keep the video playing and drift past the target.
Expose forcePause on both _pcbCtx and _rcbCtx and call it from:
- pcbNudge / rcbNudge (frame-step buttons)
- commitTimeInput / _rcbCommitTimeInput (typed timestamp on Enter)
- _rcbSetActiveSlot (switching start/end slot re-scrubs the video)
Playback now resumes only on an explicit play press.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sports match share links now show the full VS composition (fighters,
flags, event, weight class, referee/court/match chips) in WhatsApp /
Telegram / Facebook / Twitter previews instead of the raw video frame.
- Browsershot + puppeteer-bundled Chromium screenshots a dedicated
1200x675 (true 16:9) render of the vs-mini composition. Chrome runs
through a small wrapper (bin/browsershot-node.sh) that raises the fd
limit and reads its full env, since PHP-FPM's stripped env otherwise
makes Chromium fail to launch.
- Render is JPEG @ q85 (~100 KB) instead of PNG (~700 KB) — WhatsApp
silently drops OG images above roughly 300 KB, which is why music
thumbnails always showed and match cards didn't.
- SportsMatchController@store/update renders synchronously so the
picture is guaranteed to exist before the user copies the share URL;
edits regenerate and old stamped variants are cleaned. The og:image
URL carries a ?v={updated_at} stamp so scrapers recache on edit.
- accessShare() (/s/{token}) now detects scraper user agents and serves
the video page HTML inline instead of a 302 — some crawlers don't
follow redirects when building link previews.
- og:description / twitter:description built from headerData(): flag
emoji + fighter names (blue vs red), event, weight, match#, court,
referee, view count, upload date.
- Fixed 3-dot menu on video cards being hidden behind the next card
(:has(.dropdown-menu.show) promotes the active card above siblings
and lifts the overflow:hidden clip on .yt-video-info).
- vs-mini: skip the entrance animation on touch devices and expose a
.vs-mini-static class so the OG render always captures the final
frame instead of a random mid-animation moment.
Also adds an admin gallery at /admin/og-previews for spot-checking the
generated preview JPEGs.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- SportsMatchController syncs Video.title with SportsMatch.title on
store/update so the "Match title" field drives the video's display
title everywhere.
- headerData(): "championship" now prefers event_name (event title)
and adds a dedicated "stage" key sourced from SportsMatch.title
(shown between the yellow lines above the weight class in both the
in-player VS overlay and the vs-mini gallery card).
- Video cards render match titles with flags + corner colours
("FINALS – 🇧🇭 Blue vs 🇧🇭 Red (-61 kg)"), matching the video
page header, and clip long titles to one line.
- Video gallery grid uses repeat(N, minmax(0, 1fr)) + grid-auto-rows:1fr
so every card thumbnail renders at exactly the same size; hover-preview
videos use object-fit:cover to stop portrait sources looking smaller.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Match player + preview
- Rebuild VS intro as a 1920×1080 scaled canvas (arena design from drafts/):
angled RED/BLUE panels with clip-paths, slam-in VS with pulse+shine,
event/stage/weight header, Match/Court/Referee chips, per-fighter
Record/Rank/Stats chip row. Every placeholder from the artifact is
always in the DOM; empty ones are hidden by `.vs-nullable:empty`
and `.vs-nullable-hide` so the layout adapts without losing the
design's HTML shape. Country codes always render as full country
names (via new App\Data\Countries::name()).
- Real-time 6s countdown Skip button; video pinned at t=0 while
the intro runs, released to play at zero or on Skip.
- Standalone preview page at /videos/{video}/vs-preview.
Video cards (home + everywhere)
- Match cards get a `vs-mini` overlay of the same arena design
(fixed canvas, per-card ResizeObserver, animations replay every
time the mouse leaves the thumb). On hover the mini explodes
outward (panels fly out, VS scales+blurs+fades) and the hover
video plays over the cleared thumb.
- Match cards also render a `scorebar-mini` overlay (verbatim of
the full-player msb-scale scorebar + msb-feed) that shows
during hover playback. Live-scoring is wired per-card via a
data-sbm-state payload of rounds+points; RED/BLUE score, round
label, per-round clock, and the top-right Live Scoring ticker
(4 entries, sport-aware Yuko/Waza-ari/Ippon or Punch/Body
kick/… labels) all update from the mini video's timeupdate.
Bug fixes
- Hover video wasn't playing sound: dropped the `muted` attribute
I'd added while debugging match previews.
- White flash strobing on the left of match cards: removed the
`.vs-mini-flash` element (intro-only flourish that fired on
every mouseleave replay).
- Sports-match modal was showing "Please complete the required
fields" when saving basic info for match videos that had no
SportsMatch row yet: the submit gate only checked matchId, not
the attached videoId; now it correctly skips uploadVideoFirst()
in both edit and attach-existing-video modes.
Country names project-wide
- New helper App\Data\Countries::name($iso2) → full country name.
- Swapped `?? country` fallbacks to `?? Countries::name(country)`
in admin dashboard, video-analytics, video-insights payloads
(VideoController + SuperAdminController), and the VS partials.
Now user-visible country labels always show "Bahrain" instead
of "BH", including chart tooltips and modal titles.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Remove max-height clamps and override portrait/square/ultrawide/theater
variants so the .ytp-wrap box shape never distorts. Portrait content
letterboxes inside the 16:9 frame via object-fit: contain.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Scoreboard scale observes #videoContainer (the .ytp inner box) instead of
#ytpWrap so the overlay tracks the squeezed container when the fullscreen
highlights drawer opens. Score bar + live-scoring feed shift left with the
video and stay fully visible next to the drawer.
- msb-small (auto-hide identity + feed under 700 px) now skipped while
document.fullscreenElement is set — the drawer squeeze on smaller viewports
was dropping the container under 700 and hiding the live feed.
- Player settings panel (#ytpSettingsPanel) is now vertical-scroll capable:
max-height: calc(100vh - 120px) + overflow-y: auto + slim on-brand
scrollbar + overscroll-behavior: contain. Top items (Mini player, Playback
speed) stay reachable when the injected Scoreboard toggles push the
list past the visible area.
- Removed the slow-mo (.slowmo-picker) 1/4x/1/2x/3/4x selector from the
match highlights tab bar.
- Disabled the scoreboard scrim (.msb-scrim { display: none }). The two
linear-gradients were tinting the top and bottom of the video for text
legibility, but every scoreboard text element already carries its own
text-shadow — the tint was showing as permanent shadows over portrait
and landscape videos alike.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Remove VS intro from player and thumbnail VS card from match video cards.
All references (`msb-vs*`, `initVsIntro`, `setVsImg`, `HAS_VS`, `$vs`,
`$msbThumbVs`, `$hasMatchMeta`) are stripped from scoreboard partials,
video-card component, and scoreboard script. Related CSS keyframes
(`msbFadeIn/Out`, `msbDriftA/B`, `msbSweep`) removed.
- Image cropper (resources/views/components/image-cropper.blade.php):
replace unreliable third-party Cropme integration with a DIY cropper.
Pan/zoom/rotate wired directly with pointer + wheel events; on save,
the visible viewport is rendered to an offscreen canvas at the configured
output-width (with circular clip for `shape="circle"`). Also reparent the
overlay to <body> on open to escape any transformed Bootstrap-modal
ancestor's stacking context that clipped it.
- Sports match modal (resources/views/layouts/partials/sports-match-modal.blade.php):
set fighter photo croppers to 3:4 portrait (285x380) to match the VS
card's 228x304 frame; club logos + referee stay 1:1 square.
- SportsMatchController::fillFromRequest (line 174): coerce
`$this->clean($request->input('media', []))` to `[]` when it returns null
so array_merge doesn't crash under PHP 8's stricter types.
- SportsMatch::headerData(): read fighter headshots + club logos from the
canonical media.* keys (participant1_photo / participant2_photo /
club1_logo / club2_logo) that the uploader modal actually saves, with the
older p1_* / p2_* names kept as a secondary fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Scoreboard overlay driven by video.currentTime (scores/ticker/timeline as pure
functions of playback time; scrub-back removes points; slow-mo doesn't affect).
- Score bar + timeline + VS intro rendered verbatim from the design handoff
(drafts/score-bar.html, drafts/vs-screen.html) — only {{ }} data holes filled.
- Fixed 1150 px design canvas scaled to any player size via ResizeObserver so
the design's pixel-perfect measurements survive at every player width.
- Gear-menu injection adds a "Scoreboard" section with 7 toggle rows (Score bar,
Live scoring feed, Match info, Point timeline, Club logos, Country flags,
Penalties) using a custom 26x14 track/knob toggle matching the design.
Persists per user in localStorage['msb_prefs']; penalties defaults off.
- VS card fades in on first play of a session (skippable by click/tap/key,
2 s hold then 0.5 s fade); skipped entirely when no athlete metadata.
- SportsMatch::headerData() extended to expose sport, headshots, club logos,
division, format, match_date and per-video scoreboard defaults from existing
JSON columns (no schema migration).
- Every missing field hides its element (no "NOT SET" placeholders); athlete
name falls back to RED/BLUE per brief.
- Pushed .replay-badge down from top:2.5cqi to top:8cqi so it clears the
new KARATE KUMITE identity block.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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) <[email protected]>
Fixes a raft of things breaking when jumping between match videos via the
Up Next / playlist sidebar without a page reload:
- Highlights toggle stopped working (button held a click handler pointing at
the OLD, detached .events-sidebar node).
- Points / Coach review tabs went unclickable (new .tab-button elements had
no bound handlers).
- Event-item clicks (jump-to-timestamp on point / review cards) stopped
seeking / playing.
- window.matchRounds / window.matchReviews and const videoId were frozen at
first page load, so edit/delete/save handlers routed to the wrong video.
- Blade point card leaked "{{ $fmtTime(...) }}" as literal text because of
a misused @{{ }} escape directive.
Changes
- videoId + isOwner now live on window so they can be updated at runtime;
`var videoId` at module scope keeps existing handlers working.
- Extracted tab-button + attachEventListeners wiring into
window.initMatchTabsAndEvents (idempotent — _tabBound flag).
- Added window.reloadMatchVideoState(url): parses the new slug from the URL,
updates window.videoId, calls loadMatchData (refreshes cache + re-renders
sidebar), then re-runs the tabs/events/toggle/sidebar-height inits.
- recSwapContent and plSwapContent call reloadMatchVideoState whenever the
target page has an .events-sidebar (falls back to just the toggle re-init
for non-match targets).
- Fixed the Blade point-card time: {{ '@' . $fmtTime(...) }} instead of
@{{ ... }} so the timestamp actually renders instead of showing braces.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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) <[email protected]>
Replaces the point Add/Edit popup with an in-player capture strip and fixes
the underlying running-score bug.
Backend (MatchEventController)
- storePoint used pluck('points','competitor'), which collapses same-side
points into a single row (last value wins) and excluded same-timestamp
points (< instead of <=). Same-moment Blue+Red produced 1-0 / 0-1 instead
of 1-1. Replaced with recomputeRoundScores($roundId), a single pass over
the round ordered by (timestamp_seconds, id) that walks a running total
and updates each row. Called after create/update/delete so edits ripple.
Player-embedded capture strip (match.blade.php)
- Adding / editing points no longer opens a modal; the video's chrome
bottom-bar is swapped for a strip inside #ytpWrap that survives fullscreen.
- Editable mm:ss field + draggable red marker + −1s/+1s nudges. Zoom control
(1x / 5x / 20x) narrows the slider window around the current time for
frame-level positioning; nudges scale with zoom. Auto-recentre on edge.
- Inline form: Blue / Red / Both toggle (equal-width segments), action text,
points number. Both mode splits into two per-side rows. Values carry over
when switching modes so nothing is retyped.
- Enter saves, Esc cancels. Save button auto-disables during in-flight
requests. On mobile the buttons collapse to icon-only (× / ✓).
Grouped highlights entry
- Points sharing a timestamp within a round collapse into ONE card with a
chip per side (equal-width Blue/Red pills), a single ✏️/🗑 pair, and a
meta line coloured by role: ROUND N in amber, blue score in blue, red
score in red.
- ✏️ on a grouped entry opens the strip in Both mode with both sides
pre-filled. Saving PUTs both rows; switching to a single competitor
keeps that side and deletes the other.
- 🗑 on a grouped entry deletes both rows via the shared custom-confirm.
- loadMatchData now writes back to window.matchRounds so subsequent
edit/lookup handlers find newly-added points without a page reload.
Removed
- #pointModal HTML block and the openAddPointModal / savePoint /
confirmDeletePoint helpers (superseded by the capture strip).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Match highlights sidebar (videos/types/match.blade.php)
- Replaced hardcoded 3-round/5-point/3-review mock with server-rendered loops
over $video->matchRounds (with points) and $video->coachReviews. Owner-only
buttons carry real DB ids to the existing JS handlers.
- Fixed savePoint create branch: was doing parseInt(rawMmSs) which truncated
"1.15" -> 1 second. Now sends the parsed value that edit already used.
- Added a point-capture bar: clicking + on a round pauses the video and shows a
bottom bar with a big draggable red marker + nudge buttons. Dragging seeks
the video live; Confirm reads video.currentTime and opens the Add Point modal
with mm.ss pre-filled and Action focused. Fixed both server- and JS-rendered
round buttons so dynamic reloads keep the new flow.
Edit → sports sheet routing
- Added sports_match_id to /videos/{video}/edit JSON.
- openEditVideoModal delegates type=match videos to openSportsMatchModal —
edit mode when a SportsMatch is linked, create-with-attached-video mode
when the video is match-typed but has no SportsMatch row yet.
- Sports sheet gained attachExistingVideo() so create-mode can be seeded with
a video that's already in the library (skips the upload dropzone).
Upload chooser mobile bottom-nav
- Bottom nav "Upload" now opens the type chooser (bottom sheet on mobile,
centred modal on desktop) instead of hard-navigating to /videos/create.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- CompressVideoJob: rewrite to exec ffmpeg directly. php-ffmpeg's X264
format class validates the codec name and only accepts libx264, so
every job dispatched with h264_nvenc had been throwing within ~1s and
leaving the source untouched. The job now honours the GPU encoder
settings, and always replaces the source when the codec is HEVC/H.265
so downloads and the MP4 fallback stay playable.
- VideoController + video-player component: point HLS URLs at
playlist.m3u8 (the actual master file GenerateHlsJob writes) instead
of the non-existent master.m3u8, which was 404-ing and forcing the
player back to the raw MP4.
- VideoController@store: on the NAS-success branch, flip status to
'ready' and populate duration/is_shorts before dispatching
GenerateHlsJob, so the job no longer silently no-ops.
- Upload chooser: render as a bottom sheet on mobile, and forward the
picked type to /videos/create?type= so the create page pre-selects it.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replaces the up/down arrow buttons on each track card with a grip
handle. Dragging a card shows a red insertion indicator on the target
card's top or bottom edge and drops it into the closest slot on
release, including gaps between cards or past the last card.
The card is only marked draggable while the grip handle is being held,
so text selection and button clicks inside the card body behave
normally. Existing promote_track_id logic (secondary at position 1
becomes primary on save) is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
VideoController@update() now handles a replacement media file: if the
new file is audio the row is forced to type=music and, when the on-disk
type folder differs from the declared type (e.g. a video sitting under
users/{u}/videos/ that was later marked music), the media is relocated
into the canonical users/{u}/music/{slug}/tracks/{lang}-{id}/audio.ext
layout. Old primary file and stale meta.json are deleted from NAS; HLS
and slideshow caches are reset. The same relocation is applied by
replaceFile() so the mobile "Replace Media File" flow behaves the same.
Slide- and audio-track-management gates in update() also switched from
isAudioOnlyFile() (extension-based) to the declared type, so secondary
tracks and cover slides added mid-conversion are no longer silently
dropped. The edit modal now renders existing secondary tracks whenever
type=music regardless of the primary file extension.
Also carries an unrelated app.blade.php tweak: the mobile filter bar's
sticky reset moved from .yt-filter-bar to its wrapper, with padding
kept on the inner bar.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Removes the 12-chip cap on the language filter so every language that
appears in public content is listed (video 175 alone contributes 12).
The bar wrapper is now sticky and horizontally scrollable with chevron
buttons that appear only when the bar overflows in that direction, a
gradient fade behind each button, vertical-wheel → horizontal scroll
that hands off to the page at the edges, and sessionStorage-persisted
scroll position so navigation doesn't snap it back to the start.
Chip clicks are intercepted (only for /videos URLs; Trending/Shorts
still render different templates via a normal navigation) and swap
only the #filter-content region via fetch + DOMParser, updating
history.pushState, document.title, and active-chip state without a
full page reload. Modifier-clicks and middle-clicks fall through
to native "open in new tab" behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The merge no longer refuses when NAS is unreachable. DB changes always
run; per-side file operations are best-effort. When the NAS rename can't
happen it's recorded in a new pending_nas_moves table, and nas:auto-sync
drains the queue (rename + best-effort parent prune) once NAS is back,
retaining attempts/last_error on failure. Also plugs missing isEnabled()
guards on renameNasPath and deleteFolder so the merge cleanup path can't
hang on smbclient timeouts.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds one chip per language currently present in public content —
counting both primary language and any secondary audio track, sorted
by video count, capped at 12. Clicking a chip lists every video whose
primary is that language or that has a matching secondary track; cards
surfaced only via a secondary track link straight to /videos/{key}?track=<id>
so the pinned playback wiring loads the right audio, get a small flag
badge on the thumbnail, and their hover-preview streams the track file
instead of the primary.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Bundled pre-existing changes on the branch:
- Video merge: controller, service, admin merge modal, and a repair
console command; new video_redirects table (with source_folder)
so merged/moved videos keep resolving under their old keys.
- Default avatar SVG for users without a profile picture.
- Assorted view tweaks across profile, video actions/comments/insights,
admin layout, video-details, and the app layout.
- Small touch-ups in SuperAdminController, User model, NasSyncService.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Chunked uploads: bypass Cloudflare's 100 MB request cap by slicing the
video/audio into 8 MB chunks and reassembling server-side before the
normal store flow runs.
Track reorder: audio tracks gain a `position` column so a user's edit-
modal ordering survives reload. `Video::audioTracks()` now orders by
position, `promote_track_id` remembers the swapped row, and freshly
uploaded tracks append to the end.
Playlist per-track pinning: `playlist_videos.audio_track_id` remembers
which language the user pinned when adding a song. Same song with a
different track becomes a separate row (Spotify-style duplicates).
Player-data exposes `active_stream_url` + `active_track_id`, sidebar
cards render `?track=` from the pivot, and both `plTransitionTo` and
initial-load code activate the corresponding language option.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The match page header rendered hard-coded demo content (Sami Al Manea vs Hassan
Al Shallan, Match# 103, referee Ghassan Yusuf, external takeone-dev links) on
EVERY match video, ignoring the SportsMatch record the create form saves.
- Video::sportsMatch() hasOne relationship.
- SportsMatch::headerData() maps the record into the header's shape (fighters +
country flags, championship, weight class, referee, venue, match#/court),
null-safe, with a country-name/ISO2 -> flag-icons resolver.
- match.blade header now renders from headerData(): real names/flags, no
external links, and degrades gracefully to just title/views/date when a video
has no match data (instead of the fake card).
- sports-match-modal: add Match # and Court fields (stored in the existing
competition JSON, no migration) and convert fighter/referee country inputs to
the mandated <x-country-select> so header flags resolve from ISO2.
- Tracker updated for the new country-select usages.
Verified: empty state (no SportsMatch) shows only title/views/date; a populated
record renders fighters, flags (bh/us), championship, Match# 42, Court 3, U21,
referee; modal compiles with the new fields.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
After my previous fix the upload succeeds, but the avatar path is always
.../profile/avatar.<ext>, so its media URL never changes. Pages other than the
channel (which cache-busts with ?t=) kept serving the browser-cached OLD image
— the user "still saw the old avatar" on /videos.
- avatar_url / banner_url accessors append ?v=<updated_at timestamp>.
- updateAvatar / updateBanner now bump updated_at explicitly (the path is
unchanged, so Eloquent wouldn't otherwise touch it), changing ?v so every
page fetches the new image.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The cropper's onAvatarSaved/onBannerSaved callback used the url from
image.upload, which points at the temp buffer file. updateAvatar/updateBanner
then move that temp file to the NAS profile dir and delete the local copy, so
the url is dead the moment it's used — the avatar/banner rendered broken until
the next page load (the file itself was saved fine).
- updateAvatar/updateBanner now return the canonical media route URL
(route('media.avatar'|'media.banner', $relPath)), matching the model's
avatar_url/banner_url accessors and the project's no-asset('storage/...') rule.
- The image-cropper prefers the update endpoint's returned url over the temp
image.upload url; falls back to the old behavior when none is returned.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The base card CSS (.yt-video-card/.yt-video-thumb/.yt-video-info) lived inside
video-card's @once <style> block, so it only emitted when a video card rendered.
Playlist cards reuse those classes, so on playlist-only pages (no video cards)
they rendered completely unstyled — a mess.
Extract the shared card CSS into resources/views/components/partials/card-styles
(kept @once) and @include it from BOTH video-card and playlist-card. Now the CSS
loads whenever either card renders, and @once still dedupes it to one copy on
pages that show both (home feed verified: emitted exactly once).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Match Highlights mobile UX: windowed bottom sheet (video + list on screen,
top/bottom bars hidden, flush to player, non-blocking coach caption) and a
fullscreen landscape drawer that squeezes the video beside a solid side panel.
Better layout: when the drawer opens in fullscreen, shrink the player's inner
.ytp container to calc(100% - drawer width) so the video sits in the remaining
left area and the drawer owns the right — no overlap, both fully visible side
by side. Because controls, the coach caption and the Highlights button all live
inside .ytp, they move with the video automatically, so the previous overlay
compensations (control/gradient shift, caption re-anchor, button reposition)
are dropped. Drawer is now a clean solid panel (var(--bg-secondary)) since the
video is no longer behind it; blurred-glass background + @supports fallback
removed. Animated via a width transition on .ytp.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
On a <=991px landscape phone the mobile sheet rule (width:100% !important) beat
the fullscreen drawer's non-important width, so the drawer covered the whole
video instead of just the right side. Make the drawer width !important so it
keeps its narrow clamp(230px, 30vw, 360px).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Native fullscreen paints only #ytpWrap and its descendants, so the Highlights
pane was invisible in fullscreen. On fullscreenchange the pane is now relocated
INTO the player (and moved back on exit), and CSS reshapes it into a narrow,
transparent right-side drawer:
- Blurred-glass background (Clear) + text shadows so it barely covers the match
while staying readable; @supports fallback firms up the fill where
backdrop-filter is unsupported.
- Width clamp(230px, 30vw, 360px) — the Narrow choice, usable on any screen.
- While open: player controls + gradient pull in to the video area (scrubber
never hidden), coach caption stays bottom-left, and the Highlights button
slides to the drawer's left edge so it stays tappable to close.
- Same pane/data/tabs/CRUD; windowed bottom-sheet behavior unchanged.
Note: iPhone Safari forces native <video> fullscreen (no custom UI), so the
drawer applies where element fullscreen is supported (Android, desktop, iPad).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Sheet top now pins to the player's exact bottom edge (JS measures #ytpWrap
height into --hl-sheet-top; 56.25vw fallback), so no title/actions row shows
between the video and the sheet. Recomputes on open and on resize/rotate.
- Coach-note overlay on mobile is now a slim 2-line caption pinned to the very
bottom of the player instead of a 16px block mid-frame, so it no longer
covers the video. Full note stays readable in the sheet.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
While the sheet is open the fixed bottom nav now slides down out of view,
matching the top bar so the sheet owns the whole lower area; it slides back
the moment the sheet closes. Same body.hl-sheet-open trigger, reduced-motion
safe. The !important is needed to beat the base rule that pins the nav's
transform to none on mobile.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two mobile refinements to the bottom sheet:
- Drop the dark scrim (was 32% black over the whole screen). The backdrop is
now a transparent tap-to-dismiss catcher, so the match video keeps full
brightness and detail while the sheet is open.
- While the sheet is open, slide the fixed top bar up and float the scroll
area to top:0 so the player gets the reclaimed 56px. Both revert the instant
the sheet closes. Driven by a body.hl-sheet-open class; reduced-motion safe.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
On mobile (<=991px) the Points/Coach-review pane no longer stacks below the
comments. Tapping "Highlights" now slides it up as a bottom sheet over the
lower ~62vh, and scrolls the player back to the top so the user can watch and
read at once. Includes a drag handle, a light tap-to-dismiss scrim, and
internal scrolling. Desktop keeps its side pane exactly as before — all new
CSS is inside the max-width:991px media query.
- Sheet only restores its open state on desktop; on mobile it stays closed on
load so it never pops open unexpectedly.
- Scrim sits above the fixed bottom nav (z-index 1400 > 1000).
- Honors prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Player/UI fixes:
- Sports (match) Highlights pane: move the toggle click-handler script out of
the always-false @if(false ...) legacy block so the "Highlights" button opens,
and emit videoId/isOwner with @json instead of {{ json_encode() }} (escaped
quotes crashed the data/tab/CRUD block once route keys became hashid strings).
- Progress scrubber knob (video + audio players): center on the track line with
translate(-50%, -50%); drop the margin-top hack that floated it above the line.
- Music player volume: give the slider wrap height so overflow:hidden no longer
crops the knob; add a subtle thumb shadow for visibility.
- Music language menu: cap height to available space above the header, sticky
header, internal scroll; keep controls from auto-hiding while the menu is open.
- Description box: re-check "Show more" overflow at every settle point (fonts,
images, rAF, tab reopen) so SPA autonext keeps the expand button.
Also bundles pending work: save-to-playlist button component, playlist/channel
views, PlaylistController + NasSyncService, and component-usage tracker updates.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
emoji-decoration from a song's description while preserving every
language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.
Admin pages:
- /admin/lyrics toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu extracted GPU section, encoder picker, FFmpeg path
- /admin/backup extracted users-and-settings export/import
- /admin/settings now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.
Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
and /videos/{id}?playlist={token}. Dispatched after-response so it
never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.
Player polish:
- Floating mini-player is draggable, persists its position in
localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
(channel tabs, etc.) gets re-executed after content swaps.
Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.
Co-Authored-By: Claude Opus 4.7 <[email protected]>