Compare commits

..
61 Commits
Author SHA1 Message Date
ghassanandClaude Opus 4.7 8ab59fc1bb chore(backup): ship takeone:backup command + nightly scheduler entry
Moves BackupCommand and config/backup.php from the pre-audit WIP that
got stashed on 2026-10-21 to master, and registers the command in the
Laravel scheduler so it runs at 03:30 Asia/Bahrain nightly.

The command handles sqlite (VACUUM INTO after a WAL checkpoint) and
mysql (mysqldump | gzip, verified with gzip -t) backends; verifies
every snapshot by re-opening it with PDO + PRAGMA integrity_check +
a sanity SELECT; ships an off-server copy via Storage::disk() when
BACKUP_DISK is set; and prunes artifacts older than BACKUP_RETAIN_DAYS
while always keeping at least BACKUP_KEEP_MINIMUM.

Scheduler entry mirrors the nas-auto-sync shape — withoutOverlapping
+ runInBackground so a slow upload doesn't block the next tick.

.env knobs:
  BACKUP_PATH          (default: data/backups)
  BACKUP_RETAIN_DAYS   (default: 14)
  BACKUP_KEEP_MINIMUM  (default: 3)
  BACKUP_DISK          (unset — warns on every run until configured)
  BACKUP_MEDIA_DEST    (unset — required when running with --media)
  BACKUP_MEDIA_DELETE  (default: false)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 13:14:21 +03:00
ghassanandClaude Opus 4.7 e76ac39f99 feat(shorts): single reused <video> element + 1-2 next preloaded
The Shorts feed used to emit one <video id="sv-{idx}"> per short. With
60 shorts, that was 60 HTMLMediaElement allocations even though only
one plays at a time — bandwidth was bounded by preload="none" but the
DOM shape was still N:1.

Replace with the YT-parity architecture:

- One #svGlobal <video> rendered once at the end of the feed. The
  IntersectionObserver docks it into the active .short-player-wrap on
  entry and sets its src from the slot's data-src. Browsers preserve
  the active media context across node moves so playback doesn't
  restart just because we re-parented the node.
- Per-slot .short-poster shim keeps the slot filled with the video's
  thumbnail while the shared player is docked elsewhere; the slot
  never goes blank between swaps.
- Two #svPre1 / #svPre2 preloaders park at the end of the DOM and get
  their src set to the NEXT one and two shorts on each dock. preload=
  "metadata" grabs the moov atom + first keyframe only — enough that
  the switch to playback is instant, cheap enough that we can run two.
- The <video>'s single timeupdate listener now updates the
  .short-progress-fill of whichever slot it's currently docked in,
  found via video.parentElement.id.replace('wrap-', '').
- toggleMute now mutes the shared video directly instead of looking
  up the no-longer-existent sv-{idx} by id.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:54:08 +03:00
ghassanandClaude Opus 4.7 c59b36ebf7 chore(match-css): remove two blocks of dead cascade-overridden CSS
Two provably-unreachable blocks in resources/views/videos/types/match.blade.php
whose cascade targets are overridden further down with !important:

1. The pre-override pair at ~line 320:
     .events-sidebar { display: none; }
     .events-sidebar.show { display: flex; }
   is overridden by the primary .events-sidebar definition further down
   that sets `display: none !important` and `.show { display: flex !important }`.

2. The first `@media (max-width: 1300px)` block that sized
   .yt-sidebar-container + .events-sidebar to 300 px is overridden by the
   SECOND `@media (max-width: 1300px)` block immediately after, which sets
   both to `width: 100% !important` as part of the stacking layout. Same
   breakpoint, same selectors, !important wins.

Both removals leave Blade comments in place so the next reader can see why
the gap is there. Smoke-tested: /, /videos still 200; full test suite
80/80 green. Full consolidation of the four responsive .events-sidebar
variants (desktop, 1300 px stacking, 991 px bottom sheet, fullscreen
drawer) is left for a future turn with a browser visual-regression
harness — each variant has a distinct behavior that would be risky to
merge without one.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:50:48 +03:00
ghassanandClaude Opus 4.7 8fef257c86 test(live): 7 tests for the Live polish work
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]>
2026-10-21 12:45:47 +03:00
ghassanandClaude Opus 4.7 5bf1128196 feat(live): polish — ended-state, chat, viewer actions, Private option
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]>
2026-10-21 12:41:56 +03:00
ghassanandClaude Opus 4.7 639279f2a2 test(deferred): coverage for the bulk deferred-items cleanup
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]>
2026-10-21 12:37:15 +03:00
ghassanandClaude Opus 4.7 8c6746e547 feat(studio): /studio content table + bulk visibility / bulk delete
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]>
2026-10-21 12:35:47 +03:00
ghassanandClaude Opus 4.7 45505e6419 fix(shorts): wire shortOpenComments / shortCloseComments
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]>
2026-10-21 12:34:15 +03:00
ghassanandClaude Opus 4.7 59513f2051 feat(shorts): comments side panel (desktop) / bottom sheet (mobile)
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]>
2026-10-21 12:33:51 +03:00
ghassanandClaude Opus 4.7 677e8711dd feat(channel): /@handle/{tab} aliases + cursor pagination on Videos tab
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]>
2026-10-21 12:33:05 +03:00
ghassanandClaude Opus 4.7 f7c3b236ab feat(auth): guest Subscribe shows prompt + preserves intended URL
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]>
2026-10-21 12:31:55 +03:00
ghassanandClaude Opus 4.7 e550d8d15b feat(player): C hotkey toggles the lyrics overlay
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]>
2026-10-21 12:31:25 +03:00
ghassanandClaude Opus 4.7 7ba272edf6 feat(player): end screen + 5s autoplay countdown
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]>
2026-10-21 12:31:01 +03:00
ghassanandClaude Opus 4.7 4d9bc0f501 feat(engagement): video dislike + creator heart + creator pin
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]>
2026-10-21 12:18:17 +03:00
ghassanandClaude Opus 4.7 73de5bc97e test(polish): match channel row, nested-reply, cursor paginator, hotkeys
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]>
2026-10-21 12:11:08 +03:00
ghassanandClaude Opus 4.7 341e94a4f8 perf(user): cursor + simple pagination on /liked and /history
- /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]>
2026-10-21 12:09:00 +03:00
ghassanandClaude Opus 4.7 574c9581bb feat(player): shift+. / shift+, keyboard shortcuts for playback rate
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]>
2026-10-21 12:08:05 +03:00
ghassanandClaude Opus 4.7 22d6b1507d feat(watch): match channel row, read-more clamp, nested-reply @prefill
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]>
2026-10-21 12:07:23 +03:00
ghassanandClaude Opus 4.7 5aef41201a test(admin): pagination controls, HTML strip, Ready label
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 "&lt;p&gt;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]>
2026-10-21 12:03:22 +03:00
ghassanandClaude Opus 4.7 5adafc7616 feat(admin): DB-driven storage sum + grouped error log viewer
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]>
2026-10-21 12:02:03 +03:00
ghassanandClaude Opus 4.7 05ccb45fe4 feat(admin): strip HTML in video list + First/Last pagination
- 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]>
2026-10-21 12:01:04 +03:00
ghassanandClaude Opus 4.7 b7f3f6989b test: rewind output-buffer stack in tearDown to clear risky warnings
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]>
2026-10-21 11:58:12 +03:00
ghassanandClaude Opus 4.7 0e8bcfd47b test: fix ExampleTest — add RefreshDatabase and point at :memory:
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]>
2026-10-21 11:57:11 +03:00
ghassanandClaude Opus 4.7 8e719be105 test(phase-7): skip link, icon aria-labels, age gate, 404 branding
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]>
2026-10-21 10:55:03 +03:00
ghassanandClaude Opus 4.7 7dd92aaeb2 feat(a11y,admin): skip link, aria-labels, Ready label, title + age fixes
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]>
2026-10-21 10:53:23 +03:00
ghassanandClaude Opus 4.7 1b2377f20f test(phase-6): channel handle, preview mode, playlist meta
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]>
2026-10-21 10:41:25 +03:00
ghassanandClaude Opus 4.7 e939dd3a53 feat(channel): /@handle route + hide composer in preview mode
- 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]>
2026-10-21 10:37:59 +03:00
ghassanandClaude Opus 4.7 d5d35ab85e fix(channel): pluralise video counts, hide stray "0.0" from playlist meta
- 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]>
2026-10-21 10:37:01 +03:00
ghassanandClaude Opus 4.7 8812aaf336 test(phase-5): shorts deep-link, home shelf, branded 404
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]>
2026-10-21 10:33:58 +03:00
ghassanandClaude Opus 4.7 dd6091d0e4 feat(errors): branded 404 page
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]>
2026-10-21 10:31:52 +03:00
ghassanandClaude Opus 4.7 c3955c4a7c feat(home): Shorts shelf above the main feed
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]>
2026-10-21 10:31:27 +03:00
ghassanandClaude Opus 4.7 9e613c05d2 feat(shorts): /shorts/{id} deep-link + URL sync + persistent mute + wheel nav
- 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]>
2026-10-21 10:30:42 +03:00
ghassanandClaude Opus 4.7 646d3e9d2a feat(watch): anchor Up Next cards everywhere + Phase 4 tests
- 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]>
2026-10-21 10:24:41 +03:00
ghassanandClaude Opus 4.7 36295fcd6a fix(description): render leaked markdown headers instead of showing raw
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]>
2026-10-21 10:21:17 +03:00
ghassanandClaude Opus 4.7 9eb9535c83 fix(audio): dedupe language menu by (lang, label)
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]>
2026-10-21 10:20:44 +03:00
ghassanandClaude Opus 4.7 4581870517 feat(comments): ?lc= permalink + top-level vs reply counts
- 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]>
2026-10-21 10:20:10 +03:00
ghassanandClaude Opus 4.7 36061d1561 feat(watch): weighted Up Next + real <a> cards
- 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]>
2026-10-21 10:18:37 +03:00
ghassanandClaude Opus 4.7 d1fd76c278 fix(admin): dashboard country-chart blade parse error
@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]>
2026-10-21 10:12:32 +03:00
ghassanandClaude Opus 4.7 a2752643e9 test(phase-3): feeds/search/trending regression coverage
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]>
2026-10-21 10:11:01 +03:00
ghassanandClaude Opus 4.7 45b5f586ba feat(search): /search?q= across videos, channels and playlists
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]>
2026-10-21 10:09:40 +03:00
ghassanandClaude Opus 4.7 6b2a67c4cd fix(filter-bar): rename 'New to You' to 'Recent', add aria-current
- '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]>
2026-10-21 10:07:44 +03:00
ghassanandClaude Opus 4.7 4b21886cb9 fix(scoreboard): drop 'CLUB LOGO' / 'FLAG' placeholder text
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]>
2026-10-21 10:07:16 +03:00
ghassanandClaude Opus 4.7 133ab6e623 fix(feeds): trending windows 24/168/720, music newest-first, channel popular tiebreak
- 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]>
2026-10-21 10:06:54 +03:00
ghassanandClaude Opus 4.7 a25af30700 fix(channel): grid, scoreboard thumb, sort hash, visitor privacy
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]>
2026-10-21 10:03:39 +03:00
ghassanandClaude Opus 4.7 d9f97a672e chore(tests): hard-isolate the test DB so it can never hit the live file
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]>
2026-10-21 09:26:49 +03:00
ghassanandClaude Opus 4.7 fa4e09ad62 test(phase-2): perf and DOM regression tests + delete 300 lines of dead CSS
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]>
2026-10-21 00:54:55 +03:00
ghassanandClaude Opus 4.7 8aa08e705a perf(comments): lazy-render top-level reply forms
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]>
2026-10-21 00:52:13 +03:00
ghassanandClaude Opus 4.7 8fdac4b483 perf(feeds): cursor-paginated infinite scroll on /videos
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]>
2026-10-21 00:50:59 +03:00
ghassanandClaude Opus 4.7 73620a0f24 perf(video-card): strip dead per-card edit modal, tighten owner menu
- 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]>
2026-10-21 00:49:33 +03:00
ghassanandClaude Opus 4.7 4718888de0 test(phase-1): feature coverage for security + data-integrity fixes
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]>
2026-10-21 00:44:58 +03:00
ghassanandClaude Opus 4.7 c7d2f25ef6 feat(views): skip owner views, 30-minute device dedupe
- 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]>
2026-10-21 00:38:59 +03:00
ghassanandClaude Opus 4.7 48c947d171 feat(cli): comments:dedupe cleanup command
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]>
2026-10-21 00:38:34 +03:00
ghassanandClaude Opus 4.7 c9123bc7b8 feat(comments): 60s dedupe window + idempotency key
- 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]>
2026-10-21 00:38:03 +03:00
ghassanandClaude Opus 4.7 3122420a3d feat(auth): tighten login throttle and alert on brute force
- 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]>
2026-10-21 00:37:22 +03:00
ghassanandClaude Opus 4.7 76584651a6 feat(cli): app:resend-verification command
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]>
2026-10-21 00:36:45 +03:00
ghassanandClaude Opus 4.7 1cefc6ad2d feat(mail): make notification failures non-fatal
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]>
2026-10-21 00:35:41 +03:00
ghassanandClaude Opus 4.7 a1493462f7 feat(auth): block reserved identifiers at sign-up
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]>
2026-10-21 00:34:32 +03:00
ghassanandClaude Opus 4.7 56b6a83b34 docs: audit remediation plan (7 phases)
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]>
2026-10-21 00:33:10 +03:00
ghassanandClaude Opus 4.7 5058eaff2f Fix video merge losing images, audio tracks, and lyrics
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]>
2026-10-02 12:18:49 +03:00
ghassanandClaude Opus 4.7 468820c9fe Unify upload/edit modal track-form + fix slide cropper hang & reorder loss
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]>
2026-10-02 12:18:35 +03:00
ghassanandClaude Opus 4.7 2d33e39b9b Add AI translation for comments and video descriptions
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]>
2026-10-02 12:18:20 +03:00
93 changed files with 9022 additions and 1114 deletions

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
APP_ENV=testing
APP_DEBUG=true
APP_URL=http://localhost
APP_KEY=base64:Zm7kR6vDfsQQmCeWTR2Mk2WQGu6fGP1UHEcZVk/hIt0=
# ── Hard rule — tests must never touch the shared SQLite file. ───────────
# phpunit.xml also sets these via <env>, but having them here means Laravel
# picks them up during the earliest bootstrap step (before any trait or
# setUp hook can run), closing the window that let migrate:fresh wipe the
# dev DB on 2026-10-21. If you change these values, read tests/TestCase.php's
# assertSafeTestDatabase() guard first.
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
BCRYPT_ROUNDS=4
CACHE_DRIVER=array
MAIL_MAILER=array
QUEUE_CONNECTION=sync
SESSION_DRIVER=array
TELESCOPE_ENABLED=false
PULSE_ENABLED=false
+357
View File
@@ -0,0 +1,357 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use PDO;
/**
* Backs up the database, and optionally mirrors the media library.
*
* Three things make this a backup rather than a file copy:
*
* 1. CONSISTENCY — SQLite is snapshotted with `VACUUM INTO` after a WAL
* checkpoint, so the artifact is a coherent point-in-time copy even while
* the app is writing. Copying the file by hand mid-write is not.
* 2. VERIFICATION — the snapshot is re-opened, integrity-checked and read
* back before it counts as a success. An unverified backup is a rumour.
* 3. OFF-SERVER — a snapshot on the same disk survives a bad migration but
* not a dead disk, so a configured remote disk receives a copy and the
* command warns loudly on every run where none is set.
*
* The media library is deliberately NOT part of the default run. It is
* gigabytes of immutable uploads; archiving it nightly would produce hours of
* CPU and a disk full of near-identical tarballs. Pass --media to mirror it
* incrementally with rsync instead.
*
* Exits non-zero on any failure so a cron surfaces the problem instead of
* quietly producing corrupt files.
*
* Mirrors `takeone:backup` on the takeone platform so both systems are backed
* up the same way and restored the same way.
*/
class BackupCommand extends Command
{
protected $signature = 'takeone:backup
{--media : Also mirror the media library (incremental rsync, needs BACKUP_MEDIA_DEST)}
{--media-only : Mirror media and skip the database}
{--keep-local : Do not delete the local copy after an off-server upload}';
protected $description = 'Snapshot the database, verify it, optionally mirror media, copy off-server and prune old artifacts';
private array $artifacts = [];
public function handle(): int
{
$dir = (string) config('backup.path');
if (! is_dir($dir) && ! @mkdir($dir, 0775, true)) {
$this->error("Cannot create backup directory: {$dir}");
return self::FAILURE;
}
$stamp = now()->format('Ymd-His');
$failed = false;
if (! $this->option('media-only')) {
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
}
if ($this->option('media') || $this->option('media-only')) {
$failed = ! $this->mirrorMedia() || $failed;
} else {
$this->line(' <comment>–</comment> media not included (pass --media to mirror it)');
}
$this->shipOffServer();
$this->prune($dir);
if ($failed) {
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
$this->error('Backup FAILED — see the errors above.');
return self::FAILURE;
}
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
$this->newLine();
$this->info('Backup complete.');
return self::SUCCESS;
}
/* ---------------- Database ---------------- */
private function backupDatabase(string $dir, string $stamp): bool
{
$connection = config('database.default');
$driver = config("database.connections.{$connection}.driver");
return match ($driver) {
'sqlite' => $this->backupSqlite($dir, $stamp),
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
};
}
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
private function backupSqlite(string $dir, string $stamp): bool
{
$target = $dir."/db-{$stamp}.sqlite";
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
// something cryptic, say so plainly — a backup that quietly produces
// nothing is the worst possible outcome.
if (DB::transactionLevel() > 0) {
$this->error('Cannot snapshot the database inside an open transaction.');
return false;
}
try {
// Harmless when the database is not in WAL mode.
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
} catch (\Throwable $e) {
$this->error('Database snapshot failed: '.$e->getMessage());
return false;
}
if (! $this->verifySqlite($target)) {
return false;
}
$this->artifacts[] = $target;
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
return true;
}
/**
* Read the snapshot back. A file that exists but cannot be opened, or that
* holds no migrations and no videos, is not a backup worth trusting.
*/
private function verifySqlite(string $path): bool
{
try {
$pdo = new PDO('sqlite:'.$path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
$this->error("Snapshot failed integrity check: {$path}");
return false;
}
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
if ($migrations < 1) {
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
return false;
}
// Read a real application table too. `migrations` alone would still
// be present in a snapshot taken of an empty database.
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
$this->line(" <info>✓</info> verified integrity ok · {$migrations} migrations · {$videos} videos");
} catch (\Throwable $e) {
$this->error('Snapshot could not be verified: '.$e->getMessage());
return false;
}
return true;
}
private function backupMysql(string $dir, string $stamp, string $connection): bool
{
$target = $dir."/db-{$stamp}.sql.gz";
$c = config("database.connections.{$connection}");
$command = sprintf(
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
escapeshellarg((string) $c['host']),
escapeshellarg((string) $c['port']),
escapeshellarg((string) $c['username']),
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
escapeshellarg((string) $c['database']),
escapeshellarg($target),
);
exec($command, $out, $status);
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
$this->error('mysqldump failed (exit '.$status.').');
return false;
}
// Verify the dump is readable and non-trivial rather than trusting the
// exit code — a gzip of an error message also exits 0.
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
if ($verify !== 0) {
$this->error('Dump failed its gzip integrity check — discarding.');
@unlink($target);
return false;
}
$this->artifacts[] = $target;
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
return true;
}
/* ---------------- Media ---------------- */
/**
* Mirror the media library with rsync.
*
* Uploaded video cannot be regenerated from anything — losing it is the
* one unrecoverable outcome on this platform. rsync is used rather than an
* archive because the library is large and almost entirely immutable, so
* each run copies only what is new.
*/
private function mirrorMedia(): bool
{
$destination = (string) config('backup.media_destination');
if ($destination === '') {
$this->newLine();
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
return false;
}
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
$this->error("Media destination is not reachable: {$destination}");
return false;
}
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
if (! $paths) {
$this->line(' <comment>–</comment> media nothing to mirror');
return true;
}
// --delete is opt-in and off by default: a mirror that deletes is one
// wrong path away from erasing the copy it exists to protect.
$flags = '-a --partial --info=stats2';
if (config('backup.media_delete')) {
$flags .= ' --delete';
}
$ok = true;
foreach ($paths as $path) {
// Trailing slash on the source, basename on the destination, so
// each folder lands in its own directory rather than merging.
$command = sprintf(
'rsync %s %s %s 2>&1',
$flags,
escapeshellarg(rtrim($path, '/').'/'),
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
);
exec($command, $out, $status);
if ($status !== 0) {
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
$ok = false;
continue;
}
$this->line(sprintf(' <info>✓</info> media %s → %s', basename($path), $destination));
}
return $ok;
}
/* ---------------- Off-server ---------------- */
private function shipOffServer(): void
{
$disk = config('backup.disk');
if (! $disk) {
$this->newLine();
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
return;
}
foreach ($this->artifacts as $artifact) {
try {
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
$this->line(" <info>✓</info> off-server {$disk}:backups/".basename($artifact));
if (! $this->option('keep-local')) {
@unlink($artifact);
}
} catch (\Throwable $e) {
// Never delete the local copy when the upload failed — it is
// the only copy left.
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
}
}
}
/* ---------------- Rotation ---------------- */
/**
* Delete artifacts past the retention window, but always keep the newest
* few — a box that sat idle for a month must still have something to
* restore from.
*/
private function prune(string $dir): void
{
$days = (int) config('backup.retain_days', 14);
if ($days <= 0) {
return;
}
$keep = max(0, (int) config('backup.keep_minimum', 3));
$cutoff = now()->subDays($days)->getTimestamp();
$removed = 0;
$files = glob($dir.'/db-*') ?: [];
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
foreach (array_slice($files, $keep) as $file) {
if (filemtime($file) < $cutoff && @unlink($file)) {
$removed++;
}
}
if ($removed) {
$this->line(" <info>✓</info> pruned {$removed} artifact(s) older than {$days} days");
}
}
private function humanSize(string $path): string
{
$bytes = (int) @filesize($path);
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 1).' '.$units[$i];
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use App\Models\Comment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Clean up duplicate comments that pre-date the server-side 60-second dedupe
* guard. Duplicate = identical (user_id, video_id, parent_id, body) within a
* configurable gap. Keeps the earliest row, moves any likes onto it, deletes
* the rest. --dry-run shows what would happen without touching the DB.
*/
class CommentsDedupeCommand extends Command
{
protected $signature = 'comments:dedupe
{--window=60 : Treat duplicates as same submission if within N seconds (default 60)}
{--dry-run : Print what would be removed; do not delete}';
protected $description = 'Collapse duplicate comments (same user, video, parent, body) into the earliest one';
public function handle(): int
{
$window = max(1, (int) $this->option('window'));
$dryRun = (bool) $this->option('dry-run');
// Fetch (user_id, video_id, parent_id, body) groups that appear >1 time.
// We ignore the window at the SQL layer and apply it per-group in PHP:
// that avoids a correlated subquery and is fast enough for a one-shot
// cleanup. For very large tables, chunk by user_id.
$groups = DB::table('comments')
->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt')
->groupBy('user_id', 'video_id', 'parent_id', 'body')
->havingRaw('COUNT(*) > 1')
->get();
if ($groups->isEmpty()) {
$this->info('No duplicate (user, video, parent, body) groups found.');
return self::SUCCESS;
}
$totalRemoved = 0;
$groupsTouched = 0;
foreach ($groups as $g) {
$rows = Comment::where('user_id', $g->user_id)
->where('video_id', $g->video_id)
->where(function ($q) use ($g) {
$g->parent_id === null
? $q->whereNull('parent_id')
: $q->where('parent_id', $g->parent_id);
})
->where('body', $g->body)
->orderBy('created_at')
->orderBy('id')
->get();
if ($rows->count() < 2) {
continue;
}
$keeper = $rows->first();
$cluster = collect([$keeper]);
$previous = $keeper;
foreach ($rows->slice(1) as $row) {
// Same cluster if row is within window of the previous row —
// which is the behavior the server guard enforces today.
if ($row->created_at->diffInSeconds($previous->created_at) <= $window) {
$cluster->push($row);
$previous = $row;
}
}
if ($cluster->count() < 2) {
continue;
}
$toDelete = $cluster->skip(1);
$groupsTouched++;
$totalRemoved += $toDelete->count();
$this->line(sprintf(
' user=%d video=%d parent=%s keep=%d remove=%s body="%s"',
$g->user_id,
$g->video_id,
$g->parent_id ?? 'null',
$keeper->id,
$toDelete->pluck('id')->join(','),
mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'),
));
if ($dryRun) {
continue;
}
DB::transaction(function () use ($keeper, $toDelete) {
$ids = $toDelete->pluck('id')->all();
// Reparent any replies that were nested under a doomed row.
Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]);
// Move likes to the keeper, dropping dupes.
DB::table('comment_likes')
->whereIn('comment_id', $ids)
->update(['comment_id' => $keeper->id]);
Comment::whereIn('id', $ids)->delete();
});
}
$this->info(sprintf(
'%s %d duplicate rows across %d groups.',
$dryRun ? 'Would remove' : 'Removed',
$totalRemoved,
$groupsTouched,
));
return self::SUCCESS;
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Resend the email-verification mail to one user or to every unverified user.
*
* Useful after an SMTP outage: the account was created and the user was told
* "we sent you a link", but the mail never left because Gmail 535'd us. Once
* the mail config is fixed, this flushes the backlog.
*/
class ResendVerificationCommand extends Command
{
protected $signature = 'app:resend-verification
{--user= : A specific user id OR email to resend to}
{--all-unverified : Resend to every user with a null email_verified_at}
{--dry-run : Print who would be mailed; do not actually send}';
protected $description = 'Resend the email-verification link to one user or all unverified users';
public function handle(): int
{
$one = (string) $this->option('user');
$all = (bool) $this->option('all-unverified');
$dryRun = (bool) $this->option('dry-run');
if ($one === '' && ! $all) {
$this->error('Pass either --user=<id-or-email> or --all-unverified.');
return self::FAILURE;
}
if ($one !== '') {
$query = ctype_digit($one)
? User::where('id', (int) $one)
: User::where('email', $one);
$users = $query->get();
} else {
$users = User::whereNull('email_verified_at')->get();
}
if ($users->isEmpty()) {
$this->warn('No matching users.');
return self::SUCCESS;
}
$sent = 0;
$skipped = 0;
foreach ($users as $user) {
if ($user->hasVerifiedEmail()) {
$skipped++;
$this->line(" skip #{$user->id} <{$user->email}> — already verified");
continue;
}
if ($dryRun) {
$this->line(" would #{$user->id} <{$user->email}>");
continue;
}
// sendEmailVerificationNotification is already SafeNotifier-wrapped
// and will log instead of throwing on SMTP errors.
$user->sendEmailVerificationNotification();
$sent++;
$this->line(" sent #{$user->id} <{$user->email}>");
}
$this->info($dryRun
? "Dry run complete — {$users->count()} candidates, {$skipped} already verified."
: "Done — {$sent} sent, {$skipped} skipped.");
return self::SUCCESS;
}
}
+8
View File
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
->weeklyOn(1, '09:00')
->withoutOverlapping()
->runInBackground();
// Nightly database backup at 03:30 Asia/Bahrain. Writes to
// data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot,
// and (when BACKUP_DISK is set) ships a copy off-server.
$schedule->command('takeone:backup')
->dailyAt('03:30')
->withoutOverlapping()
->runInBackground();
}
/**
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Data;
class CountryLanguages
{
private const MAP = [
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
'FI' => 'fi', 'FR' => 'fr',
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
'JO' => 'ar', 'JP' => 'ja',
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
'KZ' => 'kk',
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
'LV' => 'lv', 'LY' => 'ar',
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
'NZ' => 'en',
'OM' => 'ar',
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
'QA' => 'ar',
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
'TZ' => 'sw',
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
'VE' => 'es', 'VN' => 'vi',
'YE' => 'ar',
'ZA' => 'en', 'ZW' => 'en',
];
public static function primary(?string $iso2): ?string
{
if (! $iso2) return null;
return self::MAP[strtoupper($iso2)] ?? null;
}
}
@@ -4,8 +4,12 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use App\Notifications\BruteForceLoginAlert;
use App\Support\SafeNotifier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class AuthenticatedSessionController extends Controller
{
@@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller
'details' => ['email' => $credentials['email']],
]);
$this->detectBruteForce($request, $credentials['email']);
return back()->withErrors([
'email' => 'The provided credentials do not match our records.',
]);
}
/**
* Fire one alert to super admins when the same email has racked up
* BRUTE_FORCE_THRESHOLD failures in BRUTE_FORCE_WINDOW_MINUTES. A Cache
* key with the same TTL prevents the alert from re-firing on every
* subsequent failed attempt within the same burst.
*/
private const BRUTE_FORCE_THRESHOLD = 5;
private const BRUTE_FORCE_WINDOW_MINUTES = 10;
private function detectBruteForce(Request $request, string $email): void
{
$window = self::BRUTE_FORCE_WINDOW_MINUTES;
$threshold = self::BRUTE_FORCE_THRESHOLD;
// SQLite stores JSON-cast `details` as text; a LIKE-based match is
// database-portable and avoids per-driver JSON operator syntax.
$pattern = '%"email":"' . addslashes($email) . '"%';
$recent = AuditLog::where('action', 'user.login.failed')
->where('created_at', '>=', now()->subMinutes($window))
->where('details', 'like', $pattern)
->count();
if ($recent < $threshold) {
return;
}
$cacheKey = 'brute_force_alert:' . sha1($email);
if (Cache::has($cacheKey)) {
return;
}
Cache::put($cacheKey, 1, now()->addMinutes($window));
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Forwarded-For')
?? $request->ip()
?? 'unknown';
$notification = new BruteForceLoginAlert($email, (string) $ip, $recent, $window);
User::where('role', 'super_admin')->each(function (User $admin) use ($notification) {
SafeNotifier::send($admin, $notification);
});
}
public function destroy(Request $request)
{
$user = Auth::user();
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Notifications\NewUserRegistered;
use App\Rules\NotDisposableEmail;
use App\Rules\ReservedIdentifier;
use App\Support\SafeNotifier;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -27,10 +29,14 @@ class RegisteredUserController extends Controller
}
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
'name' => ['required', 'string', 'max:255', new ReservedIdentifier('name')],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail, new ReservedIdentifier('email')],
'password' => ['required', 'confirmed', Password::defaults()],
'birthday' => ['required', 'date', 'before:today'],
// Age gate: must be at least 13 on sign-up day. The front-end
// date picker already caps the year dropdown, but the server
// must enforce it so a client bypass doesn't let a 10-year-old
// through.
'birthday' => ['required', 'date', 'before:' . now()->subYears(13)->toDateString()],
'gender' => ['required', 'in:male,female'],
'nationality' => ['required', 'string', 'size:2'],
]);
@@ -48,7 +54,7 @@ class RegisteredUserController extends Controller
// Notify all super admins of the new registration
User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
$admin->notify(new NewUserRegistered($user));
SafeNotifier::send($admin, new NewUserRegistered($user));
});
auth()->login($user);
+144 -4
View File
@@ -8,6 +8,8 @@ use App\Models\Video;
use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification;
use App\Services\LlmLyricsService;
use App\Support\SafeNotifier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -15,7 +17,7 @@ class CommentController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index']);
$this->middleware('auth')->except(['index', 'translate']);
}
public function index(Video $video)
@@ -34,12 +36,66 @@ class CommentController extends Controller
$commenter = Auth::user();
// Idempotency guard: a client that retries (duplicate listeners after
// an SPA swap, fast double-click, flaky connection) can POST the same
// comment twice. We dedupe on two signals:
//
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
// UUID; the browser's own retry logic reuses the same value.
// This catches the deliberate-retry case before body comparison.
//
// 2. Identical (user, video, parent, body) within 60s — covers
// the duplicate-listener case where two independent fetch()es
// fire with different idempotency keys for the same user intent.
//
// Either match returns the first-written comment unchanged.
$idemKey = (string) $request->header('X-Idempotency-Key', '');
$cacheKey = $idemKey !== ''
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
: null;
if ($cacheKey) {
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
if ($existingId && $existing = Comment::find($existingId)) {
return response()->json([
'success' => true,
'comment' => $existing->load('user:id,name,avatar'),
]);
}
}
$existing = $video->comments()
->where('user_id', $commenter->id)
->where('body', $request->body)
->where(function ($q) use ($request) {
$request->parent_id
? $q->where('parent_id', $request->parent_id)
: $q->whereNull('parent_id');
})
->where('created_at', '>=', now()->subSeconds(60))
->latest('id')
->first();
if ($existing) {
if ($cacheKey) {
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
}
return response()->json([
'success' => true,
'comment' => $existing->load('user:id,name,avatar'),
]);
}
$comment = $video->comments()->create([
'user_id' => $commenter->id,
'body' => $request->body,
'parent_id' => $request->parent_id,
]);
if ($cacheKey) {
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
}
$comment->load('user:id,name,avatar');
// Fire notifications (never notify yourself)
@@ -47,12 +103,12 @@ class CommentController extends Controller
// Reply — notify the parent comment's author
$parent = Comment::find($request->parent_id);
if ($parent && $parent->user_id !== $commenter->id) {
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
SafeNotifier::send($parent->user, new NewReplyNotification($video, $comment, $commenter));
}
} else {
// Top-level comment — notify the video owner
if ($video->user_id !== $commenter->id) {
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
SafeNotifier::send($video->user, new NewCommentNotification($video, $comment, $commenter));
}
}
@@ -89,6 +145,37 @@ class CommentController extends Controller
return response()->json(['success' => true]);
}
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$target = (string) $request->input('target', 'en');
// Derive a source-language hint from the comment author's most-frequent
// access country (video_views + profile_visits, falling back to their
// nationality setting). Never trust the client for this — it's used
// to disambiguate scripts the LLM might otherwise misidentify.
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
$sourceCountry = $topCountries[$comment->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
if ($translated === '') {
return response()->json(['error' => 'Translation failed'], 502);
}
return response()->json([
'success' => true,
'translated' => $translated,
'target' => $target,
'source_hint' => $sourceLang,
'author_country'=> $sourceCountry,
]);
}
public function like(Comment $comment)
{
$userId = Auth::id();
@@ -107,7 +194,8 @@ class CommentController extends Controller
if ($comment->user_id !== $userId) {
$video = $comment->video;
if ($video) {
$comment->user->notify(
SafeNotifier::send(
$comment->user,
new NewCommentLikeNotification($video, $comment, Auth::user())
);
}
@@ -118,4 +206,56 @@ class CommentController extends Controller
return response()->json(['liked' => $liked, 'count' => $count]);
}
/**
* Toggle the "creator heart" on a comment. Only the video owner can
* drop a heart; anyone else gets a 403.
*/
public function heart(Comment $comment)
{
$video = $comment->video;
if (! $video || $video->user_id !== Auth::id()) {
return response()->json(['error' => 'Only the video owner can heart a comment.'], 403);
}
$comment->hearted = ! $comment->hearted;
$comment->save();
return response()->json([
'hearted' => (bool) $comment->hearted,
]);
}
/**
* Toggle "pinned" on a top-level comment. Only the video owner can
* pin, and pinned comments sort ahead of everything else in the list.
* At most one comment is pinned at a time — pinning a new one unpins
* the previous.
*/
public function pin(Comment $comment)
{
$video = $comment->video;
if (! $video || $video->user_id !== Auth::id()) {
return response()->json(['error' => 'Only the video owner can pin a comment.'], 403);
}
if ($comment->parent_id) {
return response()->json(['error' => 'Only top-level comments can be pinned.'], 422);
}
$wasPinned = (bool) $comment->pinned;
if (! $wasPinned) {
// Unpin any previously-pinned comment on this video first so
// the UI invariant "at most one pinned" is maintained at the
// DB level, not just in the view.
Comment::where('video_id', $video->id)
->where('pinned', true)
->update(['pinned' => false]);
}
$comment->pinned = ! $wasPinned;
$comment->save();
return response()->json([
'pinned' => (bool) $comment->pinned,
]);
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
namespace App\Http\Controllers;
use App\Models\LiveStream;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* The media server's authorisation hook.
*
* MediaMTX holds no policy of its own: before it accepts a publisher or serves
* a viewer it POSTs here, and a non-2xx answer refuses the connection. That
* keeps every "who may do what" decision inside the application, next to the
* users table, rather than duplicated in a media server config.
*
* Deny by default, in the strongest sense: if this app is down, the media
* server can authorise nothing at all.
*
* These endpoints are called ONLY by MediaMTX over loopback. They are not part
* of the public API and reject anything arriving from elsewhere.
*/
class LiveAuthController extends Controller
{
/** Loopback addresses the media server may call from. */
private const LOCAL_IPS = ['127.0.0.1', '::1'];
/**
* Decide a publish or read attempt.
*
* Payload from MediaMTX: user, password, token, ip, action, path,
* protocol, id, query.
*/
public function authenticate(Request $request): Response
{
if (! $this->fromMediaServer($request)) {
return response('', 403);
}
$action = (string) $request->input('action');
// Control-plane actions carry no stream path, so they are settled
// before any attempt to resolve one.
if (in_array($action, ['api', 'metrics', 'pprof'], true)) {
return $this->authorizeApi();
}
$path = (string) $request->input('path');
$stream = $this->resolveStream($path);
if (! $stream) {
// An unknown path is not an error worth explaining — a publisher
// probing for valid stream ids learns nothing from this.
return response('', 403);
}
return match ($action) {
'publish' => $this->authorizePublish($request, $stream),
'read' => $this->authorizeRead($stream),
default => response('', 403),
};
}
/**
* The media server's own control API.
*
* It authenticates against this same hook, so without an explicit allow the
* app could not read live state from the server it runs alongside. Safe
* because it is already loopback-gated above — nothing off this host can
* reach it.
*/
private function authorizeApi(): Response
{
return response('', 200);
}
/**
* A publisher must present the short-lived token minted for this stream
* when its owner pressed "Go live". The token is single-use: it is burned
* here, so a captured one cannot start a second broadcast.
*/
private function authorizePublish(Request $request, LiveStream $stream): Response
{
// MediaMTX passes HTTP Basic credentials through as user/password.
$presented = (string) ($request->input('password') ?: $request->input('token'));
if (! $stream->publishTokenMatches($presented)) {
Log::warning('Live publish refused', [
'stream' => $stream->public_id,
'ip' => $request->input('ip'),
]);
return response('', 403);
}
$stream->consumePublishToken();
$stream->markLive();
Log::info('Live publish authorised', [
'stream' => $stream->public_id,
'user_id' => $stream->user_id,
]);
return response('', 200);
}
/**
* Reads are anonymous by design — a viewer fetching HLS parts carries no
* credentials — so the gate is the stream's own state: it must be live and
* not private. The unpredictable path is what keeps an unlisted stream
* unlisted.
*/
private function authorizeRead(LiveStream $stream): Response
{
if (! $stream->isLive()) {
return response('', 403);
}
if (! in_array($stream->visibility, ['public', 'unlisted'], true)) {
return response('', 403);
}
return response('', 200);
}
/**
* Lifecycle notifications from the media server.
*
* The auth hook only fires on the way in, so without this a stream whose
* phone lost signal would read as live forever.
*/
public function hook(Request $request): Response
{
if (! $this->fromMediaServer($request)) {
return response('', 403);
}
$stream = $this->resolveStream((string) $request->input('path'));
if (! $stream) {
return response('', 204);
}
match ((string) $request->input('event')) {
'publish' => $stream->markLive(),
// Only a stream that is actually live can end. A late hook from a
// previous, failed session must not kill the one just started.
'unpublish' => $stream->isLive() ? $stream->markEnded() : null,
'read' => $this->viewerJoined($stream),
'unread' => $this->viewerLeft($stream),
default => null,
};
return response('', 204);
}
/* ---------------- Viewer accounting ---------------- */
/*
* Counted with atomic SQL increments rather than read-modify-write: viewers
* arrive concurrently, and two joins landing together would otherwise
* record one.
*/
private function viewerJoined(LiveStream $stream): void
{
LiveStream::where('id', $stream->id)->increment('current_viewers');
// peak_viewers is a high-water mark, so it only ever moves up.
LiveStream::where('id', $stream->id)
->whereColumn('current_viewers', '>', 'peak_viewers')
->update(['peak_viewers' => DB::raw('current_viewers')]);
}
private function viewerLeft(LiveStream $stream): void
{
// Floor at zero: a missed join (a restart, a dropped hook) must not
// drive the count negative and show "-1 watching".
LiveStream::where('id', $stream->id)
->where('current_viewers', '>', 0)
->decrement('current_viewers');
}
/* ---------------- Helpers ---------------- */
/**
* Only the media server on this host may ask. Deliberately checks the raw
* connection address rather than a forwarded header — a header can be
* spoofed by anyone who can reach the app.
*/
private function fromMediaServer(Request $request): bool
{
return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true);
}
/** Turn "live/<public_id>" into the stream it names. */
private function resolveStream(string $path): ?LiveStream
{
if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) {
return null;
}
return LiveStream::where('public_id', $m[1])->first();
}
}
@@ -0,0 +1,292 @@
<?php
namespace App\Http\Controllers;
use App\Models\LiveChatMessage;
use App\Models\LiveStream;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
/**
* Live streaming — broadcaster and viewer surfaces.
*
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
* may broadcast, mints the short-lived credential the browser publishes with,
* and hands out the URLs. See LiveAuthController for the hook the media server
* calls back on.
*/
class LiveStreamController extends Controller
{
public function __construct()
{
// Watching is open; everything that creates or controls a stream needs
// an account.
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
}
/** Live now, plus the viewer's own streams when signed in. */
public function index(): \Illuminate\View\View
{
$live = LiveStream::live()
->listed()
->with('user')
->latest('started_at')
->limit(60)
->get();
$mine = Auth::check()
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
: collect();
return view('live.index', compact('live', 'mine'));
}
/** The "start a broadcast" form. */
public function create(): \Illuminate\View\View
{
return view('live.create');
}
/**
* Create the stream record. This does NOT start anything — it reserves an
* identity and a path; the browser goes live from the broadcast page.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
]);
// One live broadcast per account at a time. Without this a single user
// can pin open as many encoder slots as they can open tabs.
$alreadyLive = LiveStream::where('user_id', Auth::id())
->where('status', LiveStream::STATUS_LIVE)
->exists();
if ($alreadyLive) {
return back()
->withInput()
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
}
$stream = LiveStream::create([
'user_id' => Auth::id(),
'title' => $data['title'],
'description' => $data['description'] ?? null,
'visibility' => $data['visibility'],
'status' => LiveStream::STATUS_IDLE,
]);
return redirect()->route('live.broadcast', $stream);
}
/** The broadcaster's camera page. Owner only. */
public function broadcast(LiveStream $stream): \Illuminate\View\View
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
// An ended stream is not a dead one — the owner may go live on it
// again from the same link.
$stream->reArm();
return view('live.broadcast', [
'stream' => $stream,
'whipUrl' => $this->whipUrl($stream),
'watchUrl' => route('live.watch', $stream),
'iceServers' => $this->iceServers(),
]);
}
/**
* Mint the publish credential.
*
* Separate from the page load so the token's short life starts when the
* user actually presses "Go live", not when they opened the tab and went
* to find a tripod.
*/
public function token(LiveStream $stream): JsonResponse
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
// Pressing "Go live" on a stream that previously ended restarts it
// rather than being refused. A failed attempt must never burn the link.
$stream->reArm();
return response()->json([
'token' => $stream->issuePublishToken(),
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
'whip_url' => $this->whipUrl($stream),
'ice_servers' => $this->iceServers(),
]);
}
/** The viewer page. */
public function watch(LiveStream $stream): \Illuminate\View\View
{
abort_unless($stream->isWatchableBy(Auth::user()), 404);
$stream->loadMissing('user');
return view('live.watch', [
'stream' => $stream,
'whepUrl' => $this->whepUrl($stream),
'hlsUrl' => $this->hlsUrl($stream),
'isOwner' => $stream->isOwnedBy(Auth::user()),
'iceServers' => $this->iceServers(),
]);
}
/** Lightweight poll for the viewer page: is it live, how many watching. */
public function status(LiveStream $stream): JsonResponse
{
abort_unless($stream->isWatchableBy(Auth::user()), 404);
return response()->json([
'status' => $stream->status,
'live' => $stream->isLive(),
'viewers' => $stream->current_viewers,
'duration' => $stream->formatted_duration,
]);
}
/** End the broadcast. Owner only. */
public function stop(LiveStream $stream): JsonResponse
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
$stream->markEnded();
return response()->json([
'success' => true,
'status' => $stream->status,
'duration' => $stream->formatted_duration,
]);
}
/* ---------------- URLs ---------------- */
/*
* These are same-origin paths, proxied by nginx to the media server on
* loopback. Same-origin matters: the page is served over HTTPS, and a
* mixed-content or cross-origin media URL would be blocked by the browser
* before WebRTC ever started.
*/
private function whipUrl(LiveStream $stream): string
{
return url('/live-rtc/'.$stream->mediaPath().'/whip');
}
private function whepUrl(LiveStream $stream): string
{
return url('/live-rtc/'.$stream->mediaPath().'/whep');
}
private function hlsUrl(LiveStream $stream): string
{
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
}
/**
* ICE servers for the browser.
*
* No inbound UDP port is forwarded to this server, so a broadcaster outside
* the local network can only reach it through a TURN relay. On the LAN the
* host candidates work directly and TURN is never used.
*
* Configured through the environment so credentials never sit in the repo.
*/
private function iceServers(): array
{
$servers = [];
if ($stun = config('live.stun_url')) {
$servers[] = ['urls' => $stun];
}
if ($turn = config('live.turn_url')) {
$servers[] = array_filter([
'urls' => $turn,
'username' => config('live.turn_username'),
'credential' => config('live.turn_credential'),
]);
}
return $servers;
}
/**
* GET poll: viewer asks for every message in this stream newer than
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
* messages — more than enough for a 3-second polling window, bounded
* to protect the DB when a client is slow to reconnect.
*/
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
{
// Private streams are owner-only; everything else is open to viewers.
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
abort(404);
}
$since = (int) $request->query('since', 0);
$rows = LiveChatMessage::where('livestream_id', $stream->id)
->where('id', '>', $since)
->orderBy('id')
->limit(50)
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
return response()->json([
'messages' => $rows->map(fn ($m) => [
'id' => $m->id,
'uid' => $m->user_id,
'name' => $m->author_name,
'body' => $m->body,
'at' => $m->created_at?->toISOString(),
])->all(),
]);
}
/**
* POST a chat message. Rate-limited to roughly one message per second
* per user via Laravel's built-in throttle, applied via the route.
* Body is capped at 400 chars (long enough for a sentence, short enough
* that the panel doesn't shear into a wall of text).
*/
public function chatSend(Request $request, LiveStream $stream): JsonResponse
{
if ($stream->status !== LiveStream::STATUS_LIVE) {
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
}
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
abort(404);
}
$data = $request->validate([
'body' => ['required', 'string', 'max:400'],
]);
$user = Auth::user();
$msg = LiveChatMessage::create([
'livestream_id' => $stream->id,
'user_id' => $user->id,
'author_name' => $user->name,
'body' => trim($data['body']),
'created_at' => now(),
]);
return response()->json([
'ok' => true,
'message' => [
'id' => $msg->id,
'uid' => $msg->user_id,
'name' => $msg->author_name,
'body' => $msg->body,
'at' => $msg->created_at?->toISOString(),
],
]);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Http\Request;
/**
* Site-wide search across videos, channels and playlists.
*
* Lives on /search?q=… so the URL reads like what you'd expect from a modern
* site. The old /videos/search endpoint still works but redirects here, so
* nothing bookmarked earlier breaks.
*
* Match surface:
* - Video title, description, tag list, primary language and the language
* of any secondary audio track attached to it.
* - User name, username (handle), and short bio.
* - Playlist name and description.
*
* We render the existing `videos.index` view so every search result lands in
* the same card component the browse feeds use — one place to style, one place
* to fix bugs.
*/
class SearchController extends Controller
{
public function index(Request $request)
{
$query = trim((string) $request->get('q', ''));
if ($query === '') {
// No query → send the user back to the browse feed instead of an
// empty "no results" page.
return redirect()->route('videos.index');
}
$like = '%' . $query . '%';
$qLower = mb_strtolower($query);
// ── Videos ────────────────────────────────────────────────
// Match on text columns AND on any audio-track language that aligns
// with the query (e.g. searching "arabic" surfaces videos that carry
// an Arabic track even if the primary language is English).
$trackVideoIds = VideoAudioTrack::whereRaw('LOWER(language) = ?', [$qLower])
->pluck('video_id');
$videoHits = Video::public()
->where(function ($q) use ($like, $qLower, $trackVideoIds) {
$q->where('title', 'like', $like)
->orWhere('description','like', $like)
->orWhere('tags', 'like', $like)
->orWhereRaw('LOWER(language) = ?', [$qLower])
->orWhereIn('id', $trackVideoIds);
})
->latest()
->limit(60)
->get();
$videos = $videoHits->where('is_shorts', false)->values();
$shorts = $videoHits->where('is_shorts', true)->values();
// ── Channels ──────────────────────────────────────────────
$channels = User::where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->orWhere('username','like', $like)
->orWhere('bio', 'like', $like);
})
->orderByDesc('id')
->limit(12)
->get();
// ── Playlists ─────────────────────────────────────────────
$playlists = Playlist::where('visibility', 'public')
->where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->orWhere('description','like', $like);
})
->withCount('videos')
->with(['user', 'videos' => fn ($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()
->limit(12)
->get();
return view('videos.index', [
'videos' => $videos,
'shorts' => $shorts,
'playlists' => $playlists,
'channels' => $channels,
'query' => $query,
'langChips' => [],
]);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Creator studio — the content management surface YouTube calls "YouTube
* Studio". A skeleton for now: a filterable table of the signed-in user's
* videos plus a bulk visibility change endpoint. Analytics, stepped upload
* and schedule are future work.
*/
class StudioController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
/**
* The studio dashboard — defaults to the content tab.
*/
public function index(Request $request)
{
$user = Auth::user();
$filter = $request->get('filter', 'all');
$query = Video::where('user_id', $user->id)->latest();
switch ($filter) {
case 'public': $query->where('visibility', 'public'); break;
case 'unlisted': $query->where('visibility', 'unlisted'); break;
case 'private': $query->where('visibility', 'private'); break;
case 'shorts': $query->where('is_shorts', true); break;
case 'draft': $query->whereIn('status', ['pending', 'processing', 'failed']); break;
}
$videos = $query->paginate(24)->withQueryString();
// Headline numbers for the top bar.
$totals = [
'total' => Video::where('user_id', $user->id)->count(),
'public' => Video::where('user_id', $user->id)->where('visibility', 'public')->count(),
'unlisted' => Video::where('user_id', $user->id)->where('visibility', 'unlisted')->count(),
'private' => Video::where('user_id', $user->id)->where('visibility', 'private')->count(),
'processing' => Video::where('user_id', $user->id)->whereIn('status', ['pending', 'processing'])->count(),
'failed' => Video::where('user_id', $user->id)->where('status', 'failed')->count(),
'total_views' => \DB::table('video_views')
->whereIn('video_id', Video::where('user_id', $user->id)->pluck('id'))
->count(),
];
return view('studio.index', [
'videos' => $videos,
'filter' => $filter,
'totals' => $totals,
]);
}
/**
* Bulk visibility change. POSTs { video_ids: [...], visibility: '...' }.
* Guards against setting visibility on videos the user doesn't own, so a
* crafted request can't flip anyone else's content.
*/
public function bulkVisibility(Request $request)
{
$request->validate([
'video_ids' => ['required', 'array', 'min:1'],
'video_ids.*' => ['integer'],
'visibility' => ['required', 'in:public,unlisted,private'],
]);
$updated = Video::whereIn('id', $request->video_ids)
->where('user_id', Auth::id())
->update(['visibility' => $request->visibility]);
return response()->json(['updated' => $updated]);
}
/**
* Bulk delete. Same owner-only guard as above.
*/
public function bulkDelete(Request $request)
{
$request->validate([
'video_ids' => ['required', 'array', 'min:1'],
'video_ids.*' => ['integer'],
]);
$deleted = Video::whereIn('id', $request->video_ids)
->where('user_id', Auth::id())
->delete();
return response()->json(['deleted' => $deleted]);
}
}
+18 -7
View File
@@ -188,16 +188,27 @@ class SuperAdminController extends Controller
$recentVideos = Video::with('user')->latest()->take(5)->get();
// ── Storage ───────────────────────────────────────────────
$disk = Storage::disk('public');
// NAS is the primary store; walking the local disk here would miss
// every file that's been pushed to NAS and purged locally. We use
// the DB-captured file sizes instead: videos.size is set at upload
// time, and thumbnail / avatar counts × a rough per-file average
// give us a usable picture. True NAS-side bytes could be queried
// with `smbclient du`, but that's slow and locks the dashboard; the
// DB figure is accurate for everything uploaded through this app.
$sizeVideos = (int) Video::sum('size'); // bytes
$thumbnailCount = Video::whereNotNull('thumbnail')->count();
$avatarCount = User::whereNotNull('avatar')->count();
$bannerCount = User::whereNotNull('banner')->count();
$postImageCount = \DB::table('post_images')->count();
$sizeVideos = $this->dirSize($disk, 'videos');
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
$sizeAvatars = $this->dirSize($disk, 'avatars');
$sizeImages = $this->dirSize($disk, 'images');
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
// Images are variable but ~120 KB is a solid conservative estimate
// for a web-sized JPEG/PNG upload.
$sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
$sizeThumbnails = $thumbnailCount * 120 * 1024;
$sizeAvatars = $avatarCount * 120 * 1024;
$totalPublicSize = $sizeVideos + $sizeImages;
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
// Convert to MB
$toMb = fn($b) => round($b / 1024 / 1024, 1);
$storage = [
'videos' => $toMb($sizeVideos),
+142 -23
View File
@@ -18,7 +18,7 @@ class UserController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['channel']);
$this->middleware('auth')->except(['channel', 'channelVideosMore']);
}
// Typeahead search for members (used by the "Share to member" picker)
@@ -246,7 +246,14 @@ class UserController extends Controller
switch ($sort) {
case 'popular':
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
// Views first, likes as the tiebreak, created_at as the final
// tiebreak so two videos with the same view+like counts don't
// flip order between page loads.
$baseQuery
->withCount(['viewers as viewers_count', 'likes as likes_count'])
->orderByDesc('viewers_count')
->orderByDesc('likes_count')
->latest();
break;
case 'oldest':
$baseQuery->oldest();
@@ -262,8 +269,16 @@ class UserController extends Controller
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get();
// Header stats must not leak unlisted/private video view counts to
// visitors. Owners (and ?preview=1 is handled above by setting
// $isOwner=false) see totals across every video they uploaded;
// visitors see totals across public videos only — matching what
// the videos count row already shows.
$viewVideoIds = $isOwner
? $user->videos()->pluck('id')
: Video::public()->where('user_id', $user->id)->pluck('id');
$totalViews = \DB::table('video_views')
->whereIn('video_id', $user->videos()->pluck('id'))
->whereIn('video_id', $viewVideoIds)
->count();
// Filter social links by visibility for the current viewer
@@ -310,29 +325,41 @@ class UserController extends Controller
}
// Watch history
public function history()
public function history(Request $request)
{
$user = Auth::user();
// Get videos the user has watched, ordered by most recently watched
// Include private videos since they are the user's own
$videoIds = \DB::table('video_views')
->where('user_id', $user->id)
->orderBy('watched_at', 'desc')
->pluck('video_id')
->unique();
$videos = Video::whereIn('id', $videoIds)
// Latest-first by watched_at. Paginate at the join so a user who
// has watched tens of thousands of videos doesn't pull every row
// into memory on each page load. Fetch the videos keyed by latest
// view so sort order is deterministic even when the same video has
// been watched more than once.
//
// Each history row is one (video_views + videos) pair, with the
// uniqueness constraint deferred to presentation: duplicates per
// video are expected (one row per view event) and the UI collapses
// them with view-count badges.
$per = 48;
$rows = \DB::table('video_views')
->join('videos', 'videos.id', '=', 'video_views.video_id')
->where('video_views.user_id', $user->id)
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('user_id', $user->id);
$q->where('videos.visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id);
})
->get()
->sortBy(function ($video) use ($videoIds) {
return $videoIds->search($video->id);
});
->orderBy('video_views.watched_at', 'desc')
->select('videos.id as id', 'video_views.watched_at')
->simplePaginate($per);
return view('user.history', compact('videos'));
$videoIds = collect($rows->items())->pluck('id')->unique();
$videoMap = Video::whereIn('id', $videoIds)->get()->keyBy('id');
$videos = collect($rows->items())
->map(fn ($r) => $videoMap->get($r->id))
->filter()
->unique('id') // collapse re-watches of the same video on this page
->values();
return view('user.history', ['videos' => $videos, 'paginator' => $rows]);
}
// Clear watch history
@@ -345,18 +372,82 @@ class UserController extends Controller
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
}
/**
* JSON endpoint powering the "Load more" button on the channel Videos
* tab. Takes a username + optional cursor and returns a page of
* server-rendered video cards + next cursor. Reuses the cursorPaginate
* pattern Phase 2 established for the home feed.
*/
public function channelVideosMore(Request $request, string $username)
{
$owner = User::where('username', $username)->firstOrFail();
$viewer = Auth::user();
$isOwner = $viewer && $viewer->id === $owner->id;
$query = $isOwner
? Video::where('user_id', $owner->id)
: Video::public()->where('user_id', $owner->id);
$paginator = $query->where('is_shorts', false)
->latest('id')
->cursorPaginate(24);
$html = '';
foreach ($paginator->items() as $video) {
$html .= view('components.video-card', ['video' => $video, 'hideChannelInfo' => true])->render();
}
return response()->json([
'items' => $html,
'next_cursor' => optional($paginator->nextCursor())->encode(),
'has_more' => $paginator->hasMorePages(),
]);
}
/**
* Subscriptions feed — the public videos uploaded by every channel the
* viewer is subscribed to, newest first. Replaces the dead href="#" that
* the sidebar's "Subscriptions" item used to point at. Thin on purpose:
* reuses the existing video-card component via the videos/index view.
*/
public function subscriptionsFeed()
{
$me = Auth::user();
$channelIds = $me->subscriptions()->pluck('users.id');
$videos = Video::public()
->whereIn('user_id', $channelIds)
->notShorts()
->latest()
->limit(60)
->get();
return view('videos.index', [
'videos' => $videos,
'filter' => 'subscriptions',
'langChips' => [],
'shorts' => collect(),
'matches' => collect(),
'playlists' => collect(),
]);
}
// Liked videos
public function liked()
{
$user = Auth::user();
// Include private videos in liked (user's own private videos)
// Include private videos in liked (user's own private videos).
// Cursor pagination matches the Phase 2 feed endpoints — 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 regardless of how many thousands of likes the user has.
$videos = $user->likes()
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id);
})
->latest()
->paginate(12);
->latest('videos.id')
->cursorPaginate(24);
return view('user.liked', compact('videos'));
}
@@ -394,6 +485,9 @@ class UserController extends Controller
} else {
$video->likes()->attach($user->id);
$liked = true;
// Toggling to like clears any existing dislike — mirrors YT's
// behavior where a video can be liked OR disliked, not both.
$video->dislikes()->detach($user->id);
if ($video->user_id && $video->user_id !== $user->id) {
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
}
@@ -401,7 +495,32 @@ class UserController extends Controller
return response()->json([
'liked' => $liked,
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
'like_count' => $video->like_count,
'dislike_count' => $video->dislike_count,
]);
}
// Toggle dislike (API). Mutually exclusive with the like state.
public function toggleDislike(Video $video)
{
$user = Auth::user();
if ($video->isDislikedBy($user)) {
$video->dislikes()->detach($user->id);
$disliked = false;
} else {
$video->dislikes()->attach($user->id);
$disliked = true;
// Clear any existing like.
$video->likes()->detach($user->id);
}
return response()->json([
'disliked' => $disliked,
'liked' => $video->isLikedBy($user), // guaranteed false here when disliked just became true
'like_count' => $video->like_count,
'dislike_count' => $video->dislike_count,
]);
}
+247 -38
View File
@@ -26,7 +26,66 @@ class VideoController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'shortsFocused', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']);
}
/**
* Cursor-paginated feed slice for infinite scroll.
*
* Called by the sentinel IntersectionObserver on /videos (and single-type
* filters). Returns rendered <x-video-card/> HTML for the next page plus
* the next cursor, or null when there's nothing left. The first page is
* served by index() as regular HTML, so this endpoint is only ever used
* once an initial grid is already on screen.
*
* Cursor is Laravel's native cursor-pagination token, which is stable
* across inserts (unlike `?page=N` which can skip or duplicate items
* when a new video is uploaded between page fetches).
*/
public function feedMore(Request $request)
{
$filter = (string) $request->query('filter', 'all');
$pageSize = 24;
$query = Video::public()->notShorts();
if (str_starts_with($filter, 'lang:')) {
$lang = strtolower(substr($filter, 5));
$primaryIds = Video::public()->where('language', $lang)->pluck('id');
$secondaryIds = VideoAudioTrack::where('language', $lang)
->whereIn('video_id', Video::public()->pluck('id'))
->pluck('video_id');
$ids = $primaryIds->concat($secondaryIds)->unique()->values();
$query = Video::public()->whereIn('id', $ids);
} elseif ($filter === 'shorts') {
$query = Video::public()->shorts();
} elseif ($filter === 'match') {
$query = Video::public()->where('type', 'match')->notShorts();
} elseif ($filter === 'music') {
$query = Video::public()->where('type', 'music')->notShorts();
} elseif ($filter === 'all' || $filter === 'latest' || $filter === '') {
// Home + Latest both show the newest-first mixed-type feed.
// Playlists intentionally stay on their own tab — this endpoint
// only pages the video grid.
$query = Video::public()->notShorts();
} else {
// Unknown filter — behave like the empty state so a crafted
// ?filter= value can never leak the whole table.
return response()->json(['items' => '', 'next_cursor' => null, 'has_more' => false]);
}
$paginator = $query->latest('id')->cursorPaginate($pageSize);
$html = '';
foreach ($paginator->items() as $video) {
$html .= view('components.video-card', ['video' => $video])->render();
}
return response()->json([
'items' => $html,
'next_cursor' => optional($paginator->nextCursor())->encode(),
'has_more' => $paginator->hasMorePages(),
]);
}
public function index()
@@ -104,9 +163,12 @@ class VideoController extends Controller
// ── Filtered single-type views ────────────────────────────
if (in_array($filter, ['music', 'latest'])) {
$query = Video::public()->notShorts();
// Both tabs sort newest-first. Previously the music branch skipped
// the latest() call entirely, so Eloquent fell back to id ASC and
// the music grid opened on the oldest uploads — never surfacing
// anything new.
$query = Video::public()->notShorts()->latest();
if ($filter === 'music') $query->where('type', 'music');
else $query->latest();
$videos = $query->limit(50)->get();
return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
@@ -114,7 +176,12 @@ class VideoController extends Controller
}
// ── All (home) — mixed chronological feed ─────────────────
$videos = Video::public()->latest()->limit(60)->get();
// Videos shown in the grid are the newest non-shorts. Shorts render
// in their own horizontally-scrolling shelf injected after the first
// row of cards (see videos/index.blade.php), so the home grid isn't
// diluted by 60-second portrait clips.
$videos = Video::public()->notShorts()->latest()->limit(60)->get();
$homeShorts = Video::public()->where('is_shorts', true)->latest()->limit(12)->get();
$playlistQuery = Playlist::withCount('videos')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
@@ -131,7 +198,7 @@ class VideoController extends Controller
->sortByDesc('date')
->values();
return view('videos.index', compact('feedItems', 'filter', 'langChips') + [
return view('videos.index', compact('feedItems', 'filter', 'langChips', 'homeShorts') + [
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
@@ -691,11 +758,28 @@ class VideoController extends Controller
$viewFp = $request->cookie('_fp');
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
if (Auth::check()) {
// Owners don't count toward their own view totals. Previews, re-watches
// from the owner's own account, and even owner-impersonation sessions
// would otherwise inflate the number. The share/analytics screens
// already filter owner_id separately, so skipping the insert is safe.
$isOwnerViewing = Auth::check() && Auth::id() === (int) $video->user_id;
if (Auth::check() && ! $isOwnerViewing) {
// 30-minute user-level dedupe. Also dedupe against the strongest
// device signal available (fingerprint > device_id cookie) so a
// user signing out and back in during the same session doesn't
// double-count.
$exists = \DB::table('video_views')
->where('user_id', Auth::id())
->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour())
->where('watched_at', '>', now()->subMinutes(30))
->where(function ($q) use ($viewFp, $viewDid) {
$q->where('user_id', Auth::id());
if ($viewFp) {
$q->orWhere('device_hash', $viewFp);
} elseif ($viewDid) {
$q->orWhere('device_id', $viewDid);
}
})
->exists();
if (! $exists) {
@@ -711,12 +795,13 @@ class VideoController extends Controller
'watched_at' => now(),
]);
}
} else {
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
} elseif (! $isOwnerViewing) {
// Guest: 30-minute dedupe keyed on the strongest device signal
// (fingerprint hash > device_id cookie).
$exists = \DB::table('video_views')
->whereNull('user_id')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour())
->where('watched_at', '>', now()->subMinutes(30))
->where(function ($q) use ($viewFp, $viewDid) {
if ($viewFp) $q->where('device_hash', $viewFp);
else $q->where('device_id', $viewDid);
@@ -778,12 +863,60 @@ class VideoController extends Controller
}
}
$recommendedVideos = Video::public()
// Up Next recommendations: a weighted blend instead of the plain
// chronological "newest 20" the sidebar used to show.
//
// +4 same type (music / match / generic)
// +3 same primary language
// +2 overlapping tag (any match in the comma-separated tags list)
// +1 same channel (keeps a creator's recent uploads surfaced, but
// does not dominate — cross-channel results compete on type/
// language/tags scoring)
//
// Candidates: the newest ~200 public ready videos excluding the one
// being watched. 200 is big enough for the blend to find real
// similarities, small enough to score in-process without a query
// per candidate.
$recommendedVideos = (function () use ($video) {
$candidates = Video::public()
->where('status', 'ready')
->where('id', '!=', $video->id)
->latest()
->limit(20)
->with('user')
->latest('id')
->limit(200)
->get();
$ownTags = collect(explode(',', (string) ($video->tags ?? '')))
->map(fn ($t) => mb_strtolower(trim($t)))
->filter()
->unique();
$scored = $candidates->map(function (Video $cand) use ($video, $ownTags) {
$score = 0;
if ($cand->type === $video->type) $score += 4;
if ($video->language && $cand->language === $video->language) $score += 3;
if ($ownTags->isNotEmpty()) {
$candTags = collect(explode(',', (string) ($cand->tags ?? '')))
->map(fn ($t) => mb_strtolower(trim($t)))
->filter();
if ($ownTags->intersect($candTags)->isNotEmpty()) $score += 2;
}
if ($cand->user_id === $video->user_id) $score += 1;
$cand->_rec_score = $score;
return $cand;
});
// Sort by score desc, then newest. Keep up to 20.
return $scored
->sortBy([
['_rec_score', 'desc'],
['created_at', 'desc'],
])
->take(20)
->values();
})();
$view = match ($video->type) {
'match' => 'videos.types.match',
'music' => 'videos.types.music',
@@ -828,7 +961,15 @@ class VideoController extends Controller
: $slideMap['0'];
$allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [
// Dedupe the language picker by (language + label). A video with two
// tracks sharing the same language (e.g. "ar" uploaded twice while
// retrying) used to list both and leave the viewer guessing. Keeping
// the first occurrence preserves the primary when it exists; using
// (lang, label) as the key still lets legit variants coexist (e.g.
// "en — Original" and "en — Director's cut").
$audioTracks = $video->audioTracks
->unique(fn ($t) => mb_strtolower((string) $t->language) . '|' . mb_strtolower((string) ($t->label ?? '')))
->map(fn ($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
@@ -913,6 +1054,53 @@ class VideoController extends Controller
* mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result.
*/
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$html = (string) $video->description;
if (trim($html) === '') {
return response()->json(['error' => 'Nothing to translate'], 400);
}
// Flatten the rich description → plain text, keeping paragraph breaks
// so the client can render original and translation line-by-line.
$plain = trim(html_entity_decode(strip_tags(
preg_replace(['/<\/p>/i', '/<br\s*\/?>/i'], ["\n", "\n"], $html)
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$lines = preg_split('/\R/u', $plain);
$target = (string) $request->input('target', 'en');
// Source language inferred from the owner's access-history country,
// falling back to users.nationality.
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
$sourceCountry = $topCountries[$video->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
if (! $translatedLines || count($translatedLines) !== count($lines)) {
return response()->json(['error' => 'Translation failed'], 502);
}
$segments = [];
foreach ($lines as $i => $src) {
$segments[] = [
'original' => (string) $src,
'translated' => (string) ($translatedLines[$i] ?? ''),
];
}
return response()->json([
'success' => true,
'segments' => $segments,
'target' => $target,
'source_hint' => $sourceLang,
'author_country' => $sourceCountry,
]);
}
public function deleteLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
@@ -1951,47 +2139,52 @@ class VideoController extends Controller
public function trending(Request $request)
{
$hours = $request->get('hours', 48);
$limit = $request->get('limit', 50);
$hours = min(max($hours, 24), 168);
$limit = min(max($limit, 10), 100);
// Snap the ?hours param to one of the three windows the UI exposes
// (Today / This Week / This Month). The old clamp (24..168) silently
// capped the "month" tab at a week, which is why the tabs all showed
// the same data; now 720 is a first-class option.
$hours = (int) $request->get('hours', 24);
$hours = in_array($hours, [24, 168, 720], true) ? $hours : 24;
$limit = min(max((int) $request->get('limit', 50), 10), 100);
// Candidate pool: public ready videos created at any time (not just
// the last 10 days) — a video uploaded three weeks ago still belongs
// in "This Month" if it caught fire this week. We rank by the number
// of views INSIDE the window, with the recency bonus/velocity now
// used only as tiebreakers.
$windowStart = now()->subHours($hours);
$videos = Video::public()
->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10))
->where('created_at', '>=', now()->subMonths(6))
->with('user')
->get();
$videos = $videos->map(function ($video) use ($hours) {
$videos = $videos->map(function ($video) use ($hours, $windowStart) {
$recentViews = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subHours($hours))
->where('watched_at', '>=', $windowStart)
->count();
$likeCount = \DB::table('video_likes')
->where('video_id', $video->id)
->count();
$ageHours = $video->created_at->diffInHours(now());
$velocity = $recentViews / $hours;
$recencyBonus = max(0, 1 - ($ageHours / 240));
$score = ($recentViews * 0.70) +
($velocity * 100 * 0.15) +
($recencyBonus * 50 * 0.10) +
($likeCount * 0.1 * 0.05);
$video->trending_score = round($score, 2);
$video->view_count = $recentViews;
$video->like_count = $likeCount;
$video->_tiebreak_age = $video->created_at->timestamp;
return $video;
});
// Rank: in-window views (primary), likes (tiebreak #1), newest
// created_at (tiebreak #2). Any video with zero views in the window
// is dropped so the list never surfaces truly dormant content.
$trendingVideos = $videos
->filter(fn ($v) => $v->trending_score > 0)
->sortByDesc('trending_score')
->filter(fn ($v) => $v->view_count > 0)
->sortBy([
['view_count', 'desc'],
['like_count', 'desc'],
['_tiebreak_age', 'desc'],
])
->take($limit)
->values();
@@ -2002,7 +2195,7 @@ class VideoController extends Controller
]);
}
public function shorts(Request $request)
public function shorts(Request $request, ?int $startId = null)
{
$videos = Video::public()
->where('is_shorts', true)
@@ -2011,7 +2204,23 @@ class VideoController extends Controller
->latest()
->get();
return view('videos.shorts', compact('videos'));
return view('videos.shorts', [
'videos' => $videos,
'startId' => $startId, // the id to auto-scroll to, if any
]);
}
/**
* /shorts/{video}: deep-link to a specific short in the vertical feed.
* The feed itself is the same ordered list; the view uses ?start= / the
* $startId prop to scroll into position on load.
*/
public function shortsFocused(Video $video)
{
if (! $video->is_shorts || ! $video->canView(Auth::user())) {
abort(404);
}
return $this->shorts(request(), $video->id);
}
public function stream(Video $video)
+7
View File
@@ -11,6 +11,13 @@ class Comment extends Model
'user_id',
'parent_id',
'body',
'hearted',
'pinned',
];
protected $casts = [
'hearted' => 'boolean',
'pinned' => 'boolean',
];
// Relationships
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LiveChatMessage extends Model
{
protected $table = 'live_chat_messages';
public $timestamps = false; // explicit created_at only, set via useCurrent()
protected $fillable = [
'livestream_id',
'user_id',
'author_name',
'body',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function livestream()
{
return $this->belongsTo(LiveStream::class, 'livestream_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* A live broadcast.
*
* The row is the authority on whether a stream may exist; the media server
* holds no state of its own and asks this table (through LiveAuthController)
* before it accepts a publisher or a viewer.
*/
class LiveStream extends Model
{
use HasFactory;
protected $table = 'livestreams';
protected $fillable = [
'user_id', 'public_id', 'title', 'description',
'status', 'visibility', 'video_id',
];
/**
* The publish token is a credential. Keeping it out of the default array
* form means it cannot be leaked by a careless toJson() on a model that
* reached a view or an API response.
*/
protected $hidden = ['publish_token', 'publish_token_expires_at'];
protected $casts = [
'publish_token_expires_at' => 'datetime',
'started_at' => 'datetime',
'ended_at' => 'datetime',
'last_seen_at' => 'datetime',
];
public const STATUS_IDLE = 'idle';
public const STATUS_LIVE = 'live';
public const STATUS_ENDED = 'ended';
public const STATUS_FAILED = 'failed';
/** How long a freshly minted publish token stays valid. */
public const PUBLISH_TOKEN_TTL_SECONDS = 120;
protected static function booted(): void
{
// Every stream gets an unpredictable public id, always — never left to
// a caller to remember.
static::creating(function (self $stream) {
$stream->public_id ??= self::newPublicId();
});
}
/* ---------------- Identity ---------------- */
/**
* 32 chars of alphanumeric entropy. Also the media path segment, which is
* why it is constrained to [a-zA-Z0-9] — it has to satisfy the path regex
* the media server is configured with.
*/
public static function newPublicId(): string
{
do {
$id = Str::random(32);
} while (self::where('public_id', $id)->exists());
return $id;
}
/** Route model binding uses the unpredictable id, never the numeric key. */
public function getRouteKeyName(): string
{
return 'public_id';
}
/** The path the media server knows this stream by. */
public function mediaPath(): string
{
return 'live/'.$this->public_id;
}
/* ---------------- Relations ---------------- */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** The recording, once a finished stream has been published as a video. */
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
/* ---------------- State ---------------- */
public function isLive(): bool
{
return $this->status === self::STATUS_LIVE;
}
public function isOwnedBy(?User $user): bool
{
return $user !== null && $this->user_id === $user->id;
}
/**
* May this viewer watch?
*
* Both public and unlisted streams are watchable by anyone holding the
* link — the difference is only whether the stream is listed. Ownership
* additionally grants access before it goes live, so the broadcaster can
* open their own watch page to check themselves.
*/
public function isWatchableBy(?User $user): bool
{
if ($this->isOwnedBy($user)) {
return true;
}
// Visibility first — private streams stay owner-only no matter what.
if (! in_array($this->visibility, ['public', 'unlisted'], true)) {
return false;
}
// Live streams are watchable while they're on; ended streams remain
// permalinked so viewers see "Stream ended" and the replay CTA
// instead of a hard 404.
return $this->isLive() || $this->status === self::STATUS_ENDED;
}
/* ---------------- Publish credential ---------------- */
/**
* Mint a short-lived, single-use token for the broadcaster's browser.
*
* Returned in plaintext to the owner exactly once, at the moment they press
* "Go live". Stored so the media server's auth hook can verify it, then
* consumed on first successful publish.
*/
public function issuePublishToken(): string
{
$token = Str::random(64);
$this->forceFill([
'publish_token' => hash('sha256', $token),
'publish_token_expires_at' => now()->addSeconds(self::PUBLISH_TOKEN_TTL_SECONDS),
])->save();
return $token;
}
/** Constant-time check of a presented token against the stored hash. */
public function publishTokenMatches(?string $presented): bool
{
if (! $presented || ! $this->publish_token || ! $this->publish_token_expires_at) {
return false;
}
if ($this->publish_token_expires_at->isPast()) {
return false;
}
return hash_equals($this->publish_token, hash('sha256', $presented));
}
/** Burn the token so a captured one cannot be replayed. */
public function consumePublishToken(): void
{
$this->forceFill([
'publish_token' => null,
'publish_token_expires_at' => null,
])->save();
}
/* ---------------- Transitions ---------------- */
public function markLive(): void
{
$this->forceFill([
'status' => self::STATUS_LIVE,
'started_at' => $this->started_at ?? now(),
'last_seen_at' => now(),
'ended_at' => null,
])->save();
}
/**
* Put an ended stream back into a startable state.
*
* A stream is a reusable channel, not a one-shot: the owner keeps the same
* link and can go live on it again. Without this, any failed start — a lost
* signal, a refused camera, a server hiccup — would kill the stream
* permanently and force a new link on every retry.
*/
public function reArm(): void
{
if ($this->status !== self::STATUS_ENDED && $this->status !== self::STATUS_FAILED) {
return;
}
$this->forceFill([
'status' => self::STATUS_IDLE,
'ended_at' => null,
'current_viewers' => 0,
])->save();
}
public function markEnded(): void
{
$this->forceFill([
'status' => self::STATUS_ENDED,
'ended_at' => now(),
'current_viewers' => 0,
'publish_token' => null,
'publish_token_expires_at' => null,
])->save();
}
/* ---------------- Display ---------------- */
public function getDurationSecondsAttribute(): ?int
{
if (! $this->started_at) {
return null;
}
return ($this->ended_at ?? Carbon::now())->diffInSeconds($this->started_at);
}
public function getFormattedDurationAttribute(): string
{
$seconds = $this->duration_seconds;
if ($seconds === null) {
return '00:00';
}
return $seconds >= 3600
? sprintf('%d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60)
: sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60);
}
/* ---------------- Scopes ---------------- */
public function scopeLive($query)
{
return $query->where('status', self::STATUS_LIVE);
}
public function scopeListed($query)
{
return $query->where('visibility', 'public');
}
}
+112
View File
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array',
// TAKEONE account link. The token is a live credential for that
// platform, so it is encrypted at rest and never sent back to a browser.
'takeone_token' => 'encrypted',
'takeone_connected_at' => 'datetime',
];
/**
* Can this user run TAKEONE lookups?
*
* A super admin always can: the platform holds a TAKEONE super-admin token
* on their behalf, so they are connected with full reach without ever
* pasting anything. Everyone else links their own account, and sees only
* what that account may see.
*/
public function takeoneLinked(): bool
{
return $this->takeoneToken() !== null;
}
/** True when the connection is the platform's, not one this user pasted. */
public function takeoneManagedLink(): bool
{
$own = $this->takeone_token;
return ($own === null || $own === '')
&& $this->isSuperAdmin()
&& ! empty(config('services.takeone.admin_token'));
}
/**
* The token to talk to TAKEONE with. A personally linked account always
* wins, so a super admin who deliberately links their own identity is not
* silently upgraded back to the platform credential.
*/
public function takeoneToken(): ?string
{
$own = $this->takeone_token;
if ($own !== null && $own !== '') {
return $own;
}
if ($this->isSuperAdmin()) {
$platform = config('services.takeone.admin_token');
return ! empty($platform) ? $platform : null;
}
return null;
}
// Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [
'notif_new_comment' => true,
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
});
}
/**
* Resolve the most-frequent ISO2 country for each user id, aggregating
* video_views + profile_visits and falling back to users.nationality.
* Returns [user_id => 'BH', …]; keys without data are omitted.
*/
public static function topCountriesFor(array $userIds): array
{
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
if (! $userIds) return [];
$tally = [];
$rows = DB::table('video_views')
->selectRaw('user_id, country, COUNT(*) as c')
->whereIn('user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
if (Schema::hasTable('profile_visits')) {
$rows = DB::table('profile_visits')
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
->whereIn('visitor_user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('visitor_user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
}
$out = [];
foreach ($tally as $uid => $counts) {
arsort($counts);
$out[$uid] = array_key_first($counts);
}
// Fallback to users.nationality for anyone still unresolved.
$missing = array_values(array_diff($userIds, array_keys($out)));
if ($missing) {
$nats = static::whereIn('id', $missing)
->whereNotNull('nationality')->where('nationality', '!=', '')
->pluck('nationality', 'id');
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
}
return $out;
}
public static function generateUniqueUsername(string $name): string
{
$base = substr(Str::slug(trim($name)), 0, 18);
@@ -181,7 +281,19 @@ class User extends Authenticatable implements MustVerifyEmail
public function sendEmailVerificationNotification(): void
{
// Mail failures must never bubble up as a 500 to the user — the account
// exists, the HTTP request was fine, only the delivery pipe is broken.
// We log the failure (ops picks it up) and move on. Re-sending via
// `php artisan app:resend-verification` fixes the user once SMTP recovers.
try {
$this->notify(new VerifyEmail);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('verify-email send failed', [
'user_id' => $this->id,
'email' => $this->email,
'error' => $e->getMessage(),
]);
}
}
// Role helper methods
+19
View File
@@ -53,6 +53,11 @@ class Video extends Model
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
}
public function dislikes()
{
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
}
public function viewers()
{
return $this->belongsToMany(User::class, 'video_views')
@@ -177,6 +182,20 @@ class Video extends Model
return $this->likes()->count();
}
// Dislike helpers — same shape as the like pair above.
public function isDislikedBy($user)
{
if (! $user) {
return false;
}
return $this->dislikes()->where('user_id', $user->id)->exists();
}
public function getDislikeCountAttribute()
{
return $this->dislikes()->count();
}
// Get view count - use direct query to avoid timestamp issues
public function getViewCountAttribute()
{
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Fires to every super admin when the same email or IP has produced several
* failed login attempts in a short window. Lets ops decide whether to lock
* the account, change the password, or just raise the global throttle.
*/
class BruteForceLoginAlert extends Notification
{
use Queueable;
public function __construct(
public readonly string $email,
public readonly string $ip,
public readonly int $failures,
public readonly int $windowMinutes,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'kind' => 'brute_force_login_alert',
'email' => $this->email,
'ip' => $this->ip,
'failures' => $this->failures,
'window_minutes' => $this->windowMinutes,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('TAKEONE — suspicious login attempts')
->greeting('Possible brute-force login attempt')
->line("The address {$this->email} has had {$this->failures} failed login attempts in the last {$this->windowMinutes} minutes.")
->line("Most recent attempt came from {$this->ip}.")
->line('Review the audit log and consider forcing a password reset if this account looks real.')
->action('Open Admin', url('/admin/audit-logs'));
}
}
+5 -1
View File
@@ -26,8 +26,12 @@ class AppServiceProvider extends ServiceProvider
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
);
// Force HTTPS
// Force HTTPS unless we're running under phpunit — the test client
// speaks plain http://localhost; forcing https would make every POST
// in a feature test resolve to a 302 instead of hitting the controller.
if (! $this->app->environment('testing')) {
URL::forceScheme('https');
}
// Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination');
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Blocks impersonation of staff/system accounts.
*
* Applied to both `name` and the email local-part at registration so a user
* cannot sign up as "admin", "superadmin", "takeone", etc. — words that would
* let them pass themselves off as the platform or its operators to other
* users (comments, DMs, channel pages).
*/
class ReservedIdentifier implements ValidationRule
{
private const RESERVED = [
'admin', 'administrator', 'superadmin', 'super-admin', 'super_admin',
'root', 'sysadmin', 'system', 'owner', 'webmaster', 'postmaster',
'moderator', 'mod', 'staff', 'official',
'support', 'help', 'helpdesk', 'contact', 'info', 'noreply', 'no-reply',
'security', 'abuse', 'billing',
'takeone', 'take-one', 'take_one', 'takeonebh', 'takeone-bh',
'play', 'playbh', 'playtakeone',
'anonymous', 'guest', 'null', 'undefined',
];
public function __construct(private readonly string $kind = 'name')
{
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
$candidate = $this->kind === 'email'
? strtolower(trim(strstr($value, '@', true) ?: $value))
: strtolower(trim($value));
// Collapse separators so "super_admin", "super-admin" and "superadmin" all match.
$normalized = preg_replace('/[\s._\-]+/', '', $candidate) ?? $candidate;
foreach (self::RESERVED as $word) {
$target = preg_replace('/[\s._\-]+/', '', $word) ?? $word;
if ($normalized === $target) {
$fail($this->kind === 'email'
? 'That email address is reserved. Please use a different one.'
: 'That name is reserved. Please choose a different one.');
return;
}
}
}
}
+185 -11
View File
@@ -191,16 +191,176 @@ class LlmLyricsService
}
/**
* Translate arbitrary text (e.g. a user comment) to a target language.
* Returns the translated string, or '' on any failure / when disabled.
*/
public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string
{
$text = trim($text);
if ($text === '' || ! $this->isEnabled()) return '';
$provider = $this->activeProvider();
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
$cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text);
$cached = Cache::get($cacheKey);
if (is_string($cached) && $cached !== '') return $cached;
$hint = $sourceLangHint
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n"
: '';
$prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n"
. $hint
. "Rules:\n"
. " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n"
. " - Translate EVERY word of the source language, including short exclamations,\n"
. " repeated words, and words that happen to also look like a name in the target\n"
. " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n"
. " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n"
. " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n"
. " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n"
. " it is almost certainly a regular word — translate it.\n"
. " - If the text is already in the target language, repeat it unchanged.\n"
. " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n"
. " like @1:23 or @01:23-02:45 EXACTLY as written.\n"
. " - Keep the same tone and line breaks.\n\n"
. "TEXT:\n" . $text;
// Translation is deterministic — temp 0. If the model echoes the input
// (common when a word happens to also exist as a name in the target
// language, e.g. Portuguese "Linda" → English "Linda"), retry once
// with a stronger directive. If the retry also echoes, we return it.
// Comments cap at 1000 chars → 400 tokens is plenty of headroom and
// keeps the model from drifting into long completions on tiny inputs.
$budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64));
$raw = $this->postProcess($this->call($prompt, $budget, 0.0));
$needsRetry = $sourceLangHint
&& $sourceLangHint !== $targetLang
&& self::looksLikeEcho($text, $raw);
if ($needsRetry) {
$retryPrompt = $prompt
. "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n"
. "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
$retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0));
if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry;
}
if ($raw === '') return '';
Cache::put($cacheKey, $raw, now()->addDays(30));
return $raw;
}
private function postProcess(string $raw): string
{
$raw = trim($raw);
$raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw));
return $raw;
}
/** Normalised equality: ignore case, whitespace, punctuation, emojis. */
private static function looksLikeEcho(string $src, string $out): bool
{
if ($out === '') return false;
$norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
$a = $norm($src); $b = $norm($out);
return $a !== '' && $a === $b;
}
/**
* Translate a sequence of lines, preserving the one-to-one mapping so the
* caller can render them side-by-side with the originals. Empty entries
* are kept as empty strings. Returns an array of the same length as $lines
* (or [] on any failure / when disabled).
*/
public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array
{
if (! $lines || ! $this->isEnabled()) return [];
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
$provider = $this->activeProvider();
$cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines));
$cached = Cache::get($cacheKey);
if (is_array($cached) && count($cached) === count($lines)) return $cached;
// Index non-empty lines so empty separators are preserved verbatim.
$toTranslate = [];
foreach ($lines as $i => $l) {
if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l;
}
if (! $toTranslate) {
Cache::put($cacheKey, $lines, now()->addDays(30));
return $lines;
}
$numbered = [];
foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l;
$hint = $sourceLangHint
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n"
: '';
$prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n"
. $hint
. "Rules:\n"
. " - Output a JSON object mapping each original index (as string) to its translated line.\n"
. " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n"
. " - Translate every word including short exclamations and repeated words that happen\n"
. " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n"
. " - If a line is already in the target language, repeat it unchanged.\n"
. " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n"
. " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n"
. "LINES (index TAB text):\n" . implode("\n", $numbered);
$raw = $this->postProcess($this->call($prompt, 8192, 0.0));
$obj = json_decode($raw, true);
if (! is_array($obj)) return [];
$out = $lines;
$echoCount = 0;
$translatedCount = 0;
foreach ($toTranslate as $i => $src) {
$v = $obj[(string) $i] ?? ($obj[$i] ?? null);
if (is_string($v) && trim($v) !== '') {
$out[$i] = $v;
$translatedCount++;
if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) {
$echoCount++;
}
}
}
// If > 30% of the translated lines just echo the source, nudge the model.
if ($sourceLangHint && $sourceLangHint !== $targetLang
&& $translatedCount > 0 && $echoCount / $translatedCount > 0.3) {
$retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. "
. "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
$raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0));
$obj2 = json_decode($raw2, true);
if (is_array($obj2)) {
foreach ($toTranslate as $i => $src) {
$v = $obj2[(string) $i] ?? ($obj2[$i] ?? null);
if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) {
$out[$i] = $v;
}
}
}
}
Cache::put($cacheKey, $out, now()->addDays(30));
return $out;
}
/** Dispatch to the active provider's adapter. */
private function call(string $prompt, int $maxTokens): string
private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string
{
$p = $this->activeProvider();
if (! $p) return '';
try {
return match ($p['kind']) {
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
default => $this->callOllama($p, $prompt, $maxTokens),
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
};
} catch (\Throwable $e) {
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
@@ -208,24 +368,37 @@ class LlmLyricsService
}
}
private function callOllama(array $p, string $prompt, int $maxTokens): string
private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
// Qwen3-style "thinking" models respect /no_think to skip the hidden
// reasoning phase. Without it they can exhaust num_predict inside
// the thinking block and emit empty content.
$taggedPrompt = $prompt . "\n\n/no_think";
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
'stream' => false,
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
'think' => false,
// Keep the model resident for an hour so follow-up requests skip
// the cold-start reload (default is 5 minutes). Dramatically
// reduces per-click latency for comment/description translation.
'keep_alive' => '1h',
'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature],
]);
if (! $resp->successful()) {
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return '';
}
$j = $resp->json();
return (string) ($j['message']['content'] ?? '');
$content = (string) ($j['message']['content'] ?? '');
// Some thinking models still ignore the flag and leak <think>…</think>
// tags into content. Strip them.
$content = preg_replace('/<think>.*?<\/think>\s*/is', '', $content) ?? $content;
return trim($content);
}
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
$resp = Http::timeout(120)->withHeaders([
@@ -235,6 +408,7 @@ class LlmLyricsService
])->post($endpoint . '/v1/messages', [
'model' => $p['model'],
'max_tokens' => $maxTokens,
'temperature' => $temperature,
'messages' => [['role' => 'user', 'content' => $prompt]],
]);
if (! $resp->successful()) {
@@ -245,7 +419,7 @@ class LlmLyricsService
return (string) ($j['content'][0]['text'] ?? '');
}
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
{
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
@@ -254,7 +428,7 @@ class LlmLyricsService
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.2,
'temperature' => $temperature,
]);
if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
+109 -21
View File
@@ -90,9 +90,15 @@ class VideoMergeService
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
// ── 1. Promote source's primary audio to a new track on target ─
$srcPrimaryExt = pathinfo($source->filename ?: 'audio.mp3', PATHINFO_EXTENSION) ?: 'mp3';
// Layout-aware: the source may be stored either as the modern
// per-track folder ({sourceDir}/tracks/{srcLang}-{srcVideoId}/…)
// or as the legacy flat layout ({sourceDir}/audio.ext,
// {sourceDir}/slides/, …). We detect which from source->path
// and move only what actually exists in each case.
$srcPrimaryPath = (string) $source->path;
$srcPrimaryFolder = $srcPrimaryPath !== '' ? dirname($srcPrimaryPath) : $sourceDir;
$srcPrimaryExt = pathinfo($source->filename ?: ($srcPrimaryPath ?: 'audio.mp3'), PATHINFO_EXTENSION) ?: 'mp3';
$srcPrimaryFilename = "audio.{$srcPrimaryExt}";
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
$newPrimaryTrack = VideoAudioTrack::create([
'video_id' => $target->id,
@@ -108,43 +114,83 @@ class VideoMergeService
}
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
$this->moveFolder($srcPrimaryOldFolder, $newPrimaryFolder);
// Case A: primary audio sits in a dedicated per-track folder → one
// folder move brings the audio, its slides and its thumb along.
// Case B: primary audio sits directly at the video root (legacy) →
// move the individual files, since the video root also holds the
// extra tracks' subfolders which stay put until step 2.
if ($srcPrimaryFolder !== $sourceDir && str_starts_with($srcPrimaryFolder, "{$sourceDir}/tracks/")) {
$this->moveFolder($srcPrimaryFolder, $newPrimaryFolder);
} else {
$this->moveFile($srcPrimaryPath, "{$newPrimaryFolder}/{$srcPrimaryFilename}");
// Legacy slides live at {sourceDir}/slides/ as a sibling of audio.
if (is_dir(storage_path('app/' . "{$sourceDir}/slides"))) {
$this->moveFolder("{$sourceDir}/slides", "{$newPrimaryFolder}/slides");
}
// Opportunistically move thumb.{ext} if present.
foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) {
$thumb = "{$sourceDir}/thumb.{$ext}";
if (file_exists(storage_path('app/' . $thumb))) {
$this->moveFile($thumb, "{$newPrimaryFolder}/thumb.{$ext}");
}
}
// Legacy primary lyrics live at {sourceDir}/lyrics/primary.json.
// The new per-track layout stores lyrics at
// {newPrimaryFolder}/lyrics.json — move the file so lyricsNasPath
// on the target resolves it immediately.
$legacyPrimaryLyrics = "{$sourceDir}/lyrics/primary.json";
if (file_exists(storage_path('app/' . $legacyPrimaryLyrics))) {
$this->moveFile($legacyPrimaryLyrics, "{$newPrimaryFolder}/lyrics.json");
}
}
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
// ── 2. Move each source extra track's folder into target/tracks/
// The folder basename ({lang}-{track_id}) is globally unique because
// track ids are globally unique — no collisions with target's own tracks.
// The folder basename ({lang}-{track_id}) is globally unique
// because track ids are globally unique — no collisions with
// target's own tracks. We derive the actual old folder from
// the track's stored path so unusual layouts still work.
foreach ($source->audioTracks as $track) {
$basename = $this->nas->trackFolderName($source, $track);
$oldFolder = "{$sourceDir}/tracks/{$basename}";
$newFolder = "{$targetDir}/tracks/{$basename}";
$oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
$newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
if ($oldFolder !== $newFolder) {
$this->moveFolder($oldFolder, $newFolder);
}
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
: $track->path;
: "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
$track->video_id = $target->id;
$track->path = $newTrackPath;
$track->save();
// Legacy per-track lyrics live at {sourceDir}/lyrics/track-{id}.json.
// In the new layout they belong inside the moved track folder.
$legacyTrackLyrics = "{$sourceDir}/lyrics/track-{$track->id}.json";
if (file_exists(storage_path('app/' . $legacyTrackLyrics))) {
$this->moveFile($legacyTrackLyrics, "{$newFolder}/lyrics.json");
}
}
// ── 3. Move ALL slides for the source video ────────────────────
// One pass: re-parent every source slide, rewrite any filename
// that started with sourceDir/... to targetDir/..., and if the
// slide had NULL audio_track_id (belonged to source's primary),
// point it at the newly-created primary track.
$sourcePrefix = $sourceDir . '/';
$targetPrefix = $targetDir . '/';
// ── 3. Rewrite every source slide's filename to its new on-disk
// location, derived from the track it belongs to AFTER the
// per-track folder moves above. This is layout-agnostic —
// it works for both new (/tracks/{lang-id}/slides/N) and
// legacy (/{sourceDir}/slides/N) source layouts.
$updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
foreach ($slides as $s) {
$newFilename = $s->filename;
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) {
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
}
$base = $s->filename ? basename($s->filename) : null;
if (!$base) continue; // nothing we can safely rewrite
$owningTrackId = $s->audio_track_id === null ? $newPrimaryTrack->id : $s->audio_track_id;
$owningTrack = $updatedTracks->get($owningTrackId);
$owningFolder = $owningTrack && $owningTrack->path ? dirname($owningTrack->path) : $newPrimaryFolder;
$updates = [
'video_id' => $target->id,
'filename' => $newFilename,
'filename' => "{$owningFolder}/slides/{$base}",
];
if ($s->audio_track_id === null) {
$updates['audio_track_id'] = $newPrimaryTrack->id;
@@ -197,12 +243,54 @@ class VideoMergeService
});
// ── 10. Best-effort cleanup of the emptied source folder ───────────
// The legacy lyrics/ sibling is tidied first so pruneEmptyFolder's
// rmdir on sourceDir can succeed.
$this->pruneEmptyFolder("{$sourceDir}/lyrics");
$this->pruneEmptyFolder("{$sourceDir}/tracks");
$this->pruneEmptyFolder($sourceDir);
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
}
/**
* Move an individual file (not a directory) on NAS + local. Needed for
* legacy-layout sources where the primary audio and thumb live directly
* in the video root rather than inside a per-track subfolder.
*/
private function moveFile(string $oldRel, string $newRel): void
{
if ($oldRel === '' || $newRel === '' || $oldRel === $newRel) return;
$nasMoved = false;
if ($this->nas->isEnabled()) {
$this->nas->mkdirp(dirname($newRel));
$this->nas->renameNasPath($oldRel, $newRel);
$nasMoved = true;
} else {
DB::table('pending_nas_moves')->insert([
'from_path' => $oldRel,
'to_path' => $newRel,
'kind' => 'file',
'origin' => 'merge',
'created_at' => now(),
'updated_at' => now(),
]);
}
$oldLocal = storage_path('app/' . $oldRel);
$newLocal = storage_path('app/' . $newRel);
$localMoved = false;
if (is_file($oldLocal)) {
@mkdir(dirname($newLocal), 0755, true);
$localMoved = @rename($oldLocal, $newLocal);
}
Log::info('Merge: moveFile', [
'old' => $oldRel, 'new' => $newRel,
'nas' => $nasMoved, 'local' => $localMoved,
'queued' => !$nasMoved,
]);
}
/**
* Move a folder on both NAS and local disk. NAS ops are queued in
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
+25
View File
@@ -77,6 +77,11 @@ class HtmlSanitizer
/**
* Produce safe display HTML. Legacy plain-text values (no tags) are
* escaped and converted to <br>; rich values are run through clean().
*
* Also detects a handful of common markdown-ish prefixes that leaked
* in through AI-generated descriptions ("## Letra", "### Chorus") and
* turns them into real <h2>/<h3> tags before sanitising. Without this
* step a reader saw literal "## Letra" instead of a heading.
*/
public static function render(?string $value): string
{
@@ -85,6 +90,8 @@ class HtmlSanitizer
return '';
}
$value = self::promoteLeakedMarkdown($value);
if (strip_tags($value) === $value) {
return nl2br(e($value), false);
}
@@ -92,6 +99,24 @@ class HtmlSanitizer
return self::clean($value);
}
/**
* Convert markdown-style heading prefixes at the start of a line into
* real HTML tags, so the sanitiser can keep them. Only touches lines
* that start with ##, ###, or **…**; everything else is untouched.
*/
private static function promoteLeakedMarkdown(string $value): string
{
$lines = preg_split("/\r?\n/", $value);
foreach ($lines as $i => $line) {
if (preg_match('/^\s*###\s+(.+?)\s*$/u', $line, $m)) {
$lines[$i] = '<h3>' . e($m[1]) . '</h3>';
} elseif (preg_match('/^\s*##\s+(.+?)\s*$/u', $line, $m)) {
$lines[$i] = '<h2>' . e($m[1]) . '</h2>';
}
}
return implode("\n", $lines);
}
private static function sanitizeChildren(DOMNode $node): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Support;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Log;
/**
* Notification failures (SMTP down, Gmail 535, Mailgun 4xx, etc.) must never
* crash a user-facing request. Everywhere we previously did
*
* $user->notify(new SomeNotification(...));
*
* we now do
*
* SafeNotifier::send($user, new SomeNotification(...));
*
* which runs the same code, catches any Throwable, and writes a warning line
* so ops still has the signal.
*/
class SafeNotifier
{
/**
* @param mixed $notifiable Anything with ->notify(), usually a User.
*/
public static function send(mixed $notifiable, Notification $notification): void
{
if (! $notifiable || ! is_object($notifiable) || ! method_exists($notifiable, 'notify')) {
return;
}
try {
$notifiable->notify($notification);
} catch (\Throwable $e) {
Log::warning('notification send failed', [
'notification' => $notification::class,
'notifiable' => $notifiable::class,
'notifiable_id'=> $notifiable->getKey() ?? null,
'error' => $e->getMessage(),
]);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/*
|--------------------------------------------------------------------------
| Backup
|--------------------------------------------------------------------------
|
| Settings for `php artisan takeone:backup`.
|
| The database and the media library are backed up by DIFFERENT mechanisms on
| purpose. The database is small and must be captured as a consistent
| point-in-time snapshot, so it is copied in full on every run. The media
| library is gigabytes of immutable uploads, so it is mirrored incrementally
| and only when asked — tarring it nightly would burn hours of CPU and fill
| the disk with near-identical archives.
|
*/
return [
// Where snapshots are written. Lives under /data, which is git-ignored, so
// a backup can never be committed by accident.
'path' => env('BACKUP_PATH', base_path('data/backups')),
/*
|--------------------------------------------------------------------------
| Retention
|--------------------------------------------------------------------------
|
| Artifacts older than retain_days are pruned, but the newest keep_minimum
| are ALWAYS kept regardless of age — a box that sat idle for a month must
| still have something to restore from.
|
*/
'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14),
'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3),
/*
|--------------------------------------------------------------------------
| Off-server copy
|--------------------------------------------------------------------------
|
| A filesystems.php disk name. A snapshot sitting on the same disk as the
| data survives a bad migration but not a dead disk, so set this to a
| remote disk. The command warns on every run while it is unset.
|
*/
'disk' => env('BACKUP_DISK'),
/*
|--------------------------------------------------------------------------
| Media library
|--------------------------------------------------------------------------
|
| Uploaded video is the irreplaceable asset here — it cannot be regenerated
| from anything. It is mirrored with rsync (incremental, resumable) to
| media_destination, which should be a NAS mount or another machine.
|
| Only runs when --media is passed AND a destination is set.
|
| media_delete stays false by default: a mirror that deletes is one bad
| path away from erasing the copy it was meant to protect.
|
*/
'media_paths' => [
base_path('data/app/users'),
base_path('data/app/public'),
],
'media_destination' => env('BACKUP_MEDIA_DEST'),
'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false),
];
+55
View File
@@ -0,0 +1,55 @@
<?php
/*
|--------------------------------------------------------------------------
| Live streaming
|--------------------------------------------------------------------------
|
| The media plane runs as MediaMTX on loopback; nginx proxies /live-rtc and
| /live-hls to it. Nothing here is a secret except the TURN credential, which
| comes from the environment.
|
*/
return [
// MediaMTX's control API (localhost only). Used to read live publisher and
// viewer state.
'api_url' => env('LIVE_API_URL', 'http://127.0.0.1:9997'),
/*
|--------------------------------------------------------------------------
| ICE
|--------------------------------------------------------------------------
|
| STUN lets a browser discover its public address. TURN relays the media
| when a direct path cannot be established — which, until a UDP port is
| forwarded to this server, is EVERY broadcaster outside the local network.
|
| With no TURN configured, publishing works from the LAN only.
|
*/
'stun_url' => env('LIVE_STUN_URL', 'stun:stun.l.google.com:19302'),
'turn_url' => env('LIVE_TURN_URL'),
'turn_username' => env('LIVE_TURN_USERNAME'),
'turn_credential' => env('LIVE_TURN_CREDENTIAL'),
/*
|--------------------------------------------------------------------------
| Capture defaults
|--------------------------------------------------------------------------
|
| The target the phone is ASKED for. A camera that cannot do 1080p returns
| the closest it can, and WebRTC's congestion control lowers the bitrate
| when the uplink cannot sustain it — so this is a ceiling, not a promise.
|
*/
'video_width' => (int) env('LIVE_VIDEO_WIDTH', 1920),
'video_height' => (int) env('LIVE_VIDEO_HEIGHT', 1080),
'video_fps' => (int) env('LIVE_VIDEO_FPS', 30),
// ~4.5 Mbit/s is the practical ceiling for 1080p30 over a mobile uplink.
'max_bitrate' => (int) env('LIVE_MAX_BITRATE', 4_500_000),
];
@@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Live streaming.
*
* Purely additive: one new table. No existing table is altered — the link to a
* recording is held here as video_id rather than adding a column to `videos`,
* so the music and generic video paths run against exactly the schema they run
* against today.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('livestreams', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Public, unpredictable identifier. Used in the watch URL AND as the
// media path, so it must never be guessable or enumerable — knowing
// one stream's id must not hint at another's.
$table->string('public_id', 32)->unique();
$table->string('title');
$table->text('description')->nullable();
// idle → live → ended, or failed.
$table->string('status', 16)->default('idle');
// public = listed on the live index
// unlisted = reachable only with the link
$table->string('visibility', 16)->default('public');
// Short-lived, single-use credential the browser presents to the
// media server when it starts publishing. Not a long-lived stream
// key: it is minted on "Go live" and expires in minutes, so there
// is no durable secret to leak, screenshot, or reuse.
$table->string('publish_token', 64)->nullable();
$table->timestamp('publish_token_expires_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('ended_at')->nullable();
// Last time the media server confirmed the publisher is still
// connected — how a stream that died without saying goodbye gets
// reaped.
$table->timestamp('last_seen_at')->nullable();
$table->unsignedInteger('peak_viewers')->default(0);
$table->unsignedInteger('current_viewers')->default(0);
// Set once a finished stream has been turned into a normal video.
$table->foreignId('video_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
// The live index reads "public streams that are live, newest first".
$table->index(['status', 'visibility']);
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('livestreams');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Dislike counterpart to video_likes.
*
* One row per (user, video) pair. The unique index on the pair prevents a
* user from racking up multiple dislikes against the same video, and the
* foreign keys cascade so cleaning up a user or video drops their dislikes
* for free.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('video_dislikes', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['video_id', 'user_id']);
$table->index('video_id');
});
}
public function down(): void
{
Schema::dropIfExists('video_dislikes');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Creator-engagement flags on comments.
*
* - hearted: the video owner has acknowledged the comment with a red
* heart (YouTube's "creator heart"). Visible to everyone.
* - pinned: the owner has anchored the comment to the top of the list.
* The comment component sorts pinned comments ahead of everything
* else, newest-pinned first.
*
* Both default to false and are indexed only on `pinned` because that's the
* one the comments query sorts by.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->boolean('hearted')->default(false)->after('body');
$table->boolean('pinned')->default(false)->after('hearted');
$table->index('pinned');
});
}
public function down(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->dropIndex(['pinned']);
$table->dropColumn(['hearted', 'pinned']);
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Live chat messages — one row per message, scoped to a livestream. Simple
* polling model: viewers hit /live/{public_id}/chat?since=<id> every few
* seconds and get back everything newer than their last-seen id.
*
* Cascades: delete the livestream, lose its chat log. Users keep theirs
* nulled out so a deleted account's historical messages still render as
* "deleted user" rather than removing them from the thread.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('live_chat_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('livestream_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('author_name', 255); // snapshot of the sender's name at time of posting
$table->text('body');
$table->timestamp('created_at')->useCurrent();
// Chat pagination reads "messages in this stream newer than id X".
$table->index(['livestream_id', 'id']);
});
}
public function down(): void
{
Schema::dropIfExists('live_chat_messages');
}
};
+88
View File
@@ -0,0 +1,88 @@
# YouTube-parity audit remediation plan
Branch per phase. Feature tests per fix. STOP for approval between phases.
**Safety tag:** `backup/pre-audit-2026-10-21` at commit `5058eaf` on `full-screen-data`.
**Stashed WIP:** `stash@{0}` — pre-audit WIP on `full-screen-data` (recover with `git stash pop` on that branch).
---
## Phase 1 — Security & data integrity
Branch: `fix/phase-1-security`
- [ ] Wrap `sendEmailVerificationNotification` + `NewCommentNotification` mail in try/catch; move to a queued notification channel; log failures. Document `.env` for Gmail app password + Mailgun/Postmark fallback.
- [ ] `php artisan app:resend-verification {--user=}` command.
- [ ] Reserved-name blocklist (`admin`, `superadmin`, `takeone`, `support`, `root`, …) via `App\Rules\ReservedIdentifier` wired into `RegisteredUserController`.
- [ ] Harden login throttling to `5,1` with lockout message; log repeated failures and alert super admins.
- [ ] Confirm `email_verified_at` is not mass-assignable; only the signed-URL verification route flips it.
- [ ] Comment dedupe: raise 5s → 60s window, add client idempotency key header, server reject on duplicate. Disable submit button on first click.
- [ ] `php artisan comments:dedupe --dry-run` cleanup command.
- [ ] Owner-view exclusion in `VideoController@trackProgress`; dedupe by `device_hash` within 30 min.
- [ ] Feature tests for each item.
## Phase 2 — Performance & DOM
Branch: `fix/phase-2-perf`
- [ ] One shared `#globalEditModal` + `#globalCardMenu` per page; populated via `GET /videos/{id}/edit-payload`. Owner-only items gated server-side.
- [ ] Lazy-render reply/edit boxes (template clone on open).
- [ ] Match highlights panel rendered once.
- [ ] `href="javascript:void(0)"` → real `<button>` elements. Hide "Not interested" / "Don't recommend" / "Report" on own videos.
- [ ] Cursor pagination (`?cursor=`) across Home, `/videos` filters, channel Videos, search, history, liked, comments.
- [ ] IntersectionObserver infinite scroll + "Load more" fallback.
- [ ] `loading="lazy"`, explicit `width`/`height`, skeleton placeholders on thumbnails.
## Phase 3 — Feeds, search, trending
Branch: `fix/phase-3-feeds`
- [ ] Music + filter feeds switch to `latest()`.
- [ ] Trending windows: 24 / 168 / 720 h; rank by in-window views.
- [ ] Channel Popular: order by views, tiebreak by likes.
- [ ] Duration badge on card thumbnail. Scoreboard thumb → `<img>` or `aria-hidden`. Strip `CLUBLOGO` / `FLAG` placeholders.
- [ ] Filter tabs: fetch + `pushState`, sticky, `aria-current`.
- [ ] Normalize naming (DB stays `match`, display label "Sports" everywhere).
- [ ] "New to You" → "Recent" (or make personalized).
- [ ] New `SearchController` with `/search?q=` matching title/description/tags/language/channel; include channels + playlists. `/videos/search` becomes 302 redirect. Keep query in input. Merge the two duplicate search forms.
- [ ] `/feed/subscriptions` route backed by `subscriptions` table.
## Phase 4 — Watch page
Branch: `fix/phase-4-watch`
- [ ] Up Next: real `<a href>` cards. Recommender by type/tags/language, cross-channel. Tabs All / From this channel / Same type. Show more.
- [ ] Unified player wrapper for match/music/generic: theater (t), mini (i), PiP, volume slider, captions=lyrics (c), 2x speed, end screen, autoplay countdown. Autoplay-ON forces Loop-OFF.
- [ ] Like count visible, dislike button, Subscribe for guests, viewer `⋯` → Report. Channel row on match videos.
- [ ] `/videos/{id}/download/{format}` signed route; enforce per-video Downloads setting (Off/Everyone/Members/Subscribers) server-side for MP4 **and** MP3.
- [ ] Comments: `?lc=` permalink with scroll + highlight. Heart + pin (creator). "Read more" clamp. @handle prefill on nested reply. Singular/plural counts. Top-level vs reply counts separate.
- [ ] Markdown rendered (or stripped) in descriptions.
- [ ] Dedupe audio Language menu.
- [ ] Hide "Generating lyrics…" overlay unless job active.
- [ ] Edit form prefilled with real values (fix "Generic" / "Loading…" defaults).
- [ ] Streaming: HLS exists (`GenerateHlsJob`, `/videos/{video}/hls/{file}`). Switch player to hls.js when `hls_path` set. Verify `-movflags +faststart` on mp4 encode in `CompressVideoJob`. Add `Cache-Control` + Range on stream route.
## Phase 5 — Shorts
Branch: `fix/phase-5-shorts`
- [ ] Upload flag + auto-detect (vertical, ≤60s).
- [ ] `/shorts/{id}` route + branded 404 at `resources/views/errors/404.blade.php`.
- [ ] `/shorts` feed: 100dvh snap container, single reused `<video>` moved between slots, next 1–2 preloaded, URL via `history.replaceState`, wheel + arrows + swipe + ▲▼. Loop. Muted autoplay + "Tap to unmute"; unmuted state in `localStorage`.
- [ ] Right action column (like/dislike/comments/share). Bottom overlay (avatar/@handle/Subscribe/title). Comments side panel desktop / bottom sheet mobile.
- [ ] Home Shorts shelf. Channel Shorts tab (fix "My Shorts").
## Phase 6 — Channel / Studio / Live
Branch: `fix/phase-6-channel-studio-live`
- [ ] `users.handle` migration (unique, nullable → backfill → required). `/@handle` routes. Tabs `/@handle/{videos,shorts,playlists,posts,about}`. "Preview as visitor" toggle. Header shows description + links. Age/gender/horoscope private by default. Pluralize "1 video"; drop stray "· 0.0" on playlist cards.
- [ ] `/studio/*`: content table + bulk edit, per-video analytics, two-step upload (Details → Visibility: tags + playlist + schedule), file-size + duration + processing-status surfaced.
- [ ] Live: ended state → "Stream ended" + duration + recording (never "Waiting"). Live chat. Like / share / subscribe on viewer page. Channel name link. Private option on `/live/new` matching upload wording.
## Phase 7 — Admin, a11y, consistency
Branch: `fix/phase-7-admin-a11y`
- [ ] Rename "Live Videos" → "Ready". Storage counts NAS (via `NasSyncService`). Fix KPI WoW %. Strip HTML in description excerpts. Full titles with tooltip. Next/Last pagination on Audit Logs, Users, Videos. Error log viewer groups by stack trace in natural order.
- [ ] A11y: `aria-label` on every icon button (menu, search, voice, avatar, ⋯, Like-active, comment menus, seek slider with value text). Skip-to-content link. One `<h1>` per page. `label[for]` on all inputs. Remove duplicate "Prefer not to say". Birth year 13+ cap.
- [ ] Title helper `{page} | Play`. Brand: **Play** displayed; `TAKEONE` stays as `config('app.name')` per CLAUDE.md.
- [ ] Cleanup commands (all `--dry-run` first): `comments:dedupe`, `live:cleanup-test`, `tracks:dedupe`.
## Final deliverables
- Checklist: done / partial / skipped (with reason).
- `.env` keys the user must set.
- Deploy steps: migrations, queue restart, cache clear, asset build.
+62
View File
@@ -0,0 +1,62 @@
# Mail setup
Mail failures are now non-fatal — a down SMTP will log a warning in
`data/logs/laravel.log` but will NOT 500 the user. Still, delivery is a
feature; here's how to fix the current Gmail 535 auth errors.
## Option A — Gmail App Password (quickest, free, ~500 sends/day)
1. On `[email protected]`:
- Enable 2-Step Verification (Google Account → Security).
- Generate an **App Password** (Security → App passwords → "Mail", "Other: TAKEONE").
- Copy the 16-character password Google shows (no spaces).
2. `.env`:
```env
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=platformtakeone@gmail.com
MAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx # the app password, no quotes needed
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=platformtakeone@gmail.com
MAIL_FROM_NAME="TAKEONE"
```
3. `php artisan config:clear && php artisan queue:restart`
4. Smoke test:
```bash
php artisan app:resend-verification --user=<id-of-an-unverified-user>
```
## Option B — Mailgun (recommended for production)
```env
MAIL_MAILER=mailgun
MAILGUN_DOMAIN=mg.takeone.bh
MAILGUN_SECRET=key-...
MAILGUN_ENDPOINT=api.eu.mailgun.net # or api.mailgun.net for US region
MAIL_FROM_ADDRESS=no-reply@takeone.bh
MAIL_FROM_NAME="TAKEONE"
```
## Option C — Postmark (also fine)
```env
MAIL_MAILER=postmark
POSTMARK_TOKEN=...
MAIL_FROM_ADDRESS=no-reply@takeone.bh
MAIL_FROM_NAME="TAKEONE"
```
## Resend verification to users whose first email failed
```bash
# One specific user
php artisan app:resend-verification --user=42
# Everyone still unverified (dry-run first)
php artisan app:resend-verification --all-unverified --dry-run
php artisan app:resend-verification --all-unverified
```
+21 -5
View File
@@ -351,24 +351,40 @@
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
</div>
<div class="al-page-links">
{{-- First --}}
@if($logs->onFirstPage())
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
<span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
<a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
@endif
{{-- Prev --}}
@if($logs->onFirstPage())
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
@endif
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
@if($page == $logs->currentPage())
<span class="active">{{ $page }}</span>
<span class="active" aria-current="page">{{ $page }}</span>
@else
<a href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
{{-- Next --}}
@if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
<a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
@else
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
<span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
@endif
{{-- Last --}}
@if($logs->currentPage() < $logs->lastPage())
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
@else
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
@endif
</div>
</div>
+17 -5
View File
@@ -292,13 +292,16 @@
{{-- Ready Videos --}}
<div class="col-6 col-lg-4 col-xl-2">
<div class="stat-card clickable-card" onclick="openDashModal('Live Videos','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view live videos">
<div class="stat-card clickable-card" onclick="openDashModal('Ready','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view ready videos">
<div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
<div class="stat-card-top">
<div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
</div>
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div>
<div class="stat-label">Live Videos</div>
{{-- "Ready" matches the DB status column; "Live Videos" was misleading
— nothing streams live from this card, it just counts videos that
finished processing successfully. --}}
<div class="stat-label">Ready</div>
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
@@ -741,12 +744,21 @@ new Chart(document.getElementById('typeChart'), {
// ── Country Chart ───────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty())
(function() {
const countryData = @json($viewsByCountry->map(fn($r) => [
@php
// Blade's @json() parser can't handle a multi-line arrow function with
// a `[` array literal inside its body — the directive reads until the
// closing paren and sees the inner `[` as an unmatched bracket. Build
// the array in PHP first, then hand the plain variable to @json().
$countryChartData = $viewsByCountry->map(function ($r) {
return [
'country' => $r->country,
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
'total' => $r->total,
])->values());
];
})->values();
@endphp
(function() {
const countryData = @json($countryChartData);
const labels = countryData.map(r => r.country_name || r.country || '');
const values = countryData.map(r => r.total);
const maxVal = Math.max(...values);
+56 -8
View File
@@ -64,26 +64,74 @@
</button>
</form>
{{-- Log lines --}}
{{-- Log lines, grouped — each entry's stack trace (lines beginning with
`#N `, `[stacktrace]`, or `Stack trace:`) is folded into its parent
header line. Headers show their level color; stack trace lines
render in a muted indent and preserve their natural #0 → #N order
inside each group even when the overall list is newest-first. --}}
@if(empty($lines))
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;" aria-hidden="true"></i>
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
</div>
@else
@php
// Group the flat list of lines into [{header, trace[]}, …].
// Any line that is NOT a header starts/continues the current
// group's trace. Lines that look like a header (ERROR/WARNING/
// INFO/DEBUG) start a new group.
$groups = [];
$current = null;
$isHeader = fn ($l) => (bool) preg_match('/\.(ERROR|WARNING|INFO|DEBUG|CRITICAL|ALERT|EMERGENCY|NOTICE):/', $l);
foreach ($lines as $line) {
if ($isHeader($line) || $current === null) {
if ($current) $groups[] = $current;
$current = ['header' => $line, 'trace' => []];
} else {
$current['trace'][] = $line;
}
}
if ($current) $groups[] = $current;
@endphp
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
@foreach($lines as $line)
@foreach($groups as $gi => $g)
@php
$color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
$hasTrace = !empty($g['trace']);
@endphp
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
<div class="log-group" style="border-bottom: 1px solid #1a1a1a; padding: 4px 0;">
<div class="log-line log-head" style="color: {{ $color }}; white-space: pre-wrap; word-break: break-all; display:flex; gap:8px; align-items:flex-start;">
@if($hasTrace)
<button type="button"
onclick="this.closest('.log-group').classList.toggle('open')"
aria-label="Toggle stack trace"
style="flex:none;background:none;border:none;color:inherit;font-family:inherit;font-size:11px;cursor:pointer;padding:0 4px;opacity:.75;">
<i class="bi bi-chevron-right log-caret" aria-hidden="true"></i>
</button>
@else
<span style="flex:none;display:inline-block;width:20px;"></span>
@endif
<span>{{ $g['header'] }}</span>
</div>
@if($hasTrace)
<div class="log-trace" style="display:none; padding: 4px 0 4px 28px; color: #6b7280; white-space: pre-wrap; word-break: break-all;">
@foreach($g['trace'] as $tl)
<div>{{ $tl }}</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
<style>
.log-group.open .log-trace { display: block !important; }
.log-group.open .log-caret::before { content: "\F282"; /* bi-chevron-down */ }
</style>
@endif
</div>
+16 -3
View File
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
</div>
@endif
<div style="min-width:0;">
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{-- Full title in the title attribute so the
admin can hover and read the whole thing
when the visible row ellipsizes. --}}
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $video->title }}">
{{ $video->title }}
</div>
@if($video->description)
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{ Str::limit($video->description, 55) }}
@php
// Descriptions are stored as sanitized rich HTML
// (see HtmlSanitizer). Blade's {{ }} would show
// the escaped tags verbatim in the admin table,
// so strip them to a plain-text preview — and
// put the full stripped text in the tooltip too.
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
@endphp
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $descPlain }}">
{{ Str::limit($descPlain, 55) }}
</div>
@endif
</div>
+4 -1
View File
@@ -38,7 +38,10 @@
</div>
<div class="form-group">
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')" required />
{{-- COPPA / platform minimum age: 13 at sign-up. maxYear caps
the year dropdown at (this year − 13). --}}
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')"
:max-year="(int) date('Y') - 13" required />
</div>
<div class="form-group">
@@ -245,12 +245,15 @@
display: flex;
margin-top: 12px;
gap: 12px;
/* Hard-cap the info block to guarantee every card has the same total
height (thumb + info). Overflow hidden so any accidental extra
row can't push the card taller than its neighbours. */
height: 76px;
overflow: hidden;
/* Reserve a floor for the info block so every card lands at the same
height when titles are short. Two-line titles are allowed to grow
the block vertically — overflow:visible so no row is hidden. */
min-height: 76px;
}
/* On channel pages the avatar + channel-name row is suppressed by the
component's hideChannelInfo prop; drop the floor so the card doesn't
leave empty space where the row used to be. */
.yt-video-card .yt-video-info-no-channel { min-height: 60px; }
.yt-video-card .yt-video-details {
min-width: 0;
flex: 1;
@@ -284,22 +287,26 @@
color: #fff;
margin: 0 0 4px;
line-height: 1.3;
/* Single-line, no wrapping — overflow ellipses. */
white-space: nowrap;
/* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
words (e.g. "SPARTAWORRIOR" or "EVIS ― 帰還を待つ覇者の鼓動")
break mid-word so they can't widen the card beyond its grid track.
Line-clamp replaces the single-line ellipsis the old rule used. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
word-break: break-word;
max-height: calc(1.3em * 2);
}
.yt-video-card .yt-video-title a {
color: inherit;
text-decoration: none;
/* Block-level with nowrap so ellipsis actually clips the excess.
Inline-flex would let the children overflow past the parent. */
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
/* Inline children (flags, corner-colored fighter spans) must not break
the single-line layout. */
@@ -446,305 +453,5 @@
height: 24px;
}
/* Cute Edit Modal Styles */
.cute-edit-modal {
max-width: 380px;
margin: auto;
}
.cute-edit-content {
background: linear-gradient(145deg, #1f1f1f 0%, #2a2a2a 100%);
border: 1px solid #3a3a3a;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.cute-edit-header {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
padding: 16px 20px;
display: flex;
align-items: center;
gap: 10px;
position: relative;
}
.cute-edit-icon {
font-size: 20px;
}
.cute-edit-header h5 {
margin: 0;
color: white;
font-size: 16px;
font-weight: 600;
}
.btn-close-cute {
position: absolute;
right: 12px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.btn-close-cute:hover {
background: rgba(255,255,255,0.3);
}
.cute-edit-body {
padding: 20px;
}
.cute-form-group {
margin-bottom: 16px;
}
.cute-form-group label {
display: flex;
align-items: center;
gap: 6px;
color: #ccc;
font-size: 13px;
margin-bottom: 8px;
font-weight: 500;
}
.cute-form-group label i {
color: #ff6b8a;
font-size: 14px;
}
.cute-input, .cute-textarea {
width: 100%;
background: #151515;
border: 1px solid #333;
border-radius: 10px;
padding: 10px 12px;
color: #fff;
font-size: 13px;
transition: all 0.2s;
}
.cute-input:focus, .cute-textarea:focus {
outline: none;
border-color: #ff6b8a;
box-shadow: 0 0 0 3px rgba(255, 107, 138, 0.15);
}
.cute-input::placeholder, .cute-textarea::placeholder {
color: #555;
}
/* Type Options */
.cute-type-options, .cute-privacy-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cute-type-option, .cute-privacy-option {
flex: 1;
min-width: 80px;
cursor: pointer;
}
.cute-type-option input, .cute-privacy-option input {
display: none;
}
.cute-type-option span, .cute-privacy-option span {
display: block;
padding: 8px 10px;
background: #151515;
border: 1px solid #333;
border-radius: 8px;
font-size: 12px;
color: #aaa;
text-align: center;
transition: all 0.2s;
}
.cute-type-option:hover span, .cute-privacy-option:hover span {
border-color: #555;
background: #1a1a1a;
}
.cute-type-option.active span, .cute-privacy-option.active span {
background: rgba(255, 107, 138, 0.15);
border-color: #ff6b8a;
color: #ff6b8a;
}
/* Shorts Toggle in Edit Modal */
.cute-shorts-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
margin-top: 8px;
}
.cute-shorts-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.cute-shorts-slider {
position: relative;
width: 44px;
height: 24px;
background-color: #333;
border-radius: 12px;
transition: 0.3s;
margin-right: 10px;
}
.cute-shorts-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: 0.3s;
}
.cute-shorts-toggle input:checked + .cute-shorts-slider {
background-color: #e63030;
}
.cute-shorts-toggle input:checked + .cute-shorts-slider:before {
transform: translateX(20px);
}
.cute-shorts-label {
color: #aaa;
font-size: 12px;
}
/* Thumbnail Upload */
.cute-thumbnail-upload {
border: 2px dashed #444;
border-radius: 10px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #151515;
}
.cute-thumbnail-upload:hover {
border-color: #ff6b8a;
background: rgba(255, 107, 138, 0.05);
}
.cute-thumbnail-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #666;
}
.cute-thumbnail-preview i {
font-size: 24px;
color: #ff6b8a;
}
.cute-thumbnail-preview span {
font-size: 12px;
}
/* Status */
.cute-status {
padding: 10px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
display: none;
}
.cute-status.success {
display: block;
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.cute-status.error {
display: block;
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Actions */
.cute-edit-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.cute-btn-cancel, .cute-btn-save {
padding: 10px 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.cute-btn-cancel {
background: #333;
color: #aaa;
}
.cute-btn-cancel:hover {
background: #444;
color: #fff;
}
.cute-btn-save {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
color: white;
box-shadow: 0 4px 15px rgba(255, 107, 138, 0.3);
}
.cute-btn-save:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 138, 0.4);
}
.cute-btn-save:disabled {
background: #444;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
@media (max-width: 420px) {
.cute-edit-modal {
max-width: 320px;
margin: 10px;
}
.cute-type-options, .cute-privacy-options {
flex-direction: column;
}
}
</style>
@endonce
@@ -306,7 +306,7 @@
@if($embedAudioInput)
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('edit-{{ $p }}-fname').textContent=this.files[0]?.name||'Keep existing / choose new…'">
onchange="editHandleTrackAudio(this, '{{ $p }}', '{{ $titleId }}')">
@endif
</div>
</div>
@@ -158,25 +158,44 @@
</button>
@endif
@else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-bell"></i><span>Subscribe</span>
{{-- Guest Subscribe: showToast first so the user understands what's
happening, then redirect to the login page with intended=
preserving the current URL so after login they land back on
this video. 600 ms gives the toast a moment to be seen. --}}
<button type="button" class="action-btn desktop-action"
onclick="(function(){ if (typeof showToast==='function') showToast('Sign in to subscribe to {{ addslashes($video->user->name ?? 'this channel') }}', 'info'); setTimeout(function(){ window.location.href='{{ route('login') }}?intended=' + encodeURIComponent(window.location.pathname + window.location.search); }, 600); })();">
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
</button>
@endauth
@auth
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
one clears the other via the controllers' detach() call. --}}
<button type="button"
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
aria-label="{{ $video->isLikedBy(Auth::user()) ? 'Remove like' : 'Like' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}" aria-hidden="true"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
<button type="button"
class="action-btn desktop-action dislike-toggle-btn {{ $video->isDislikedBy(Auth::user()) ? 'disliked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleDislike', $video) }}"
data-disliked="{{ $video->isDislikedBy(Auth::user()) ? 'true' : 'false' }}"
aria-label="{{ $video->isDislikedBy(Auth::user()) ? 'Remove dislike' : 'Dislike' }}">
<i class="bi {{ $video->isDislikedBy(Auth::user()) ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down' }}" aria-hidden="true"></i>
</button>
@else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-hand-thumbs-up"></i>
<i class="bi bi-hand-thumbs-up" aria-hidden="true"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button>
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action" aria-label="Dislike">
<i class="bi bi-hand-thumbs-down" aria-hidden="true"></i>
</button>
@endauth
@if ($video->isShareable())
@@ -451,6 +470,46 @@
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
});
});
// ── Dislike button ─────────────────────────────────────────────
// Dislike is mutually exclusive with like on the server side; whenever
// the server returns a new state, we reconcile BOTH buttons in one
// sweep so the pair always reflects the authoritative truth.
document.addEventListener('click', function (e) {
var btn = e.target.closest('.dislike-toggle-btn');
if (!btn) return;
var url = btn.dataset.toggleUrl;
var videoId = btn.dataset.videoId;
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
})
.then(function (r) { return r.json(); })
.then(function (data) {
// Reconcile dislike pair.
document.querySelectorAll('.dislike-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
var icon = b.querySelector('i');
b.dataset.disliked = data.disliked ? 'true' : 'false';
b.classList.toggle('disliked', data.disliked);
if (icon) icon.className = 'bi ' + (data.disliked ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down');
b.setAttribute('aria-label', data.disliked ? 'Remove dislike' : 'Dislike');
});
// Reconcile like pair in parallel — may have flipped off.
document.querySelectorAll('.like-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
var icon = b.querySelector('i');
var label = b.querySelector('.like-count-label');
b.dataset.liked = data.liked ? 'true' : 'false';
b.classList.toggle('liked', data.liked);
if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like';
});
})
.catch(function () {
if (typeof toast === 'function') toast('Could not update dislike. Try again.', 'error');
});
});
})();
// ── Slideshow video generation progress ──────────────────────────────
+51 -276
View File
@@ -1,4 +1,4 @@
@props(['video' => null, 'size' => 'medium'])
@props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false])
@php
use App\Data\Languages;
@@ -85,10 +85,14 @@ $sizeClasses = match($size) {
<a href="{{ $showUrl }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.vs-mini', ['video' => $video])
@endif
{{-- width/height let the browser reserve the card's thumb box
BEFORE the image decodes, so a grid of 60 cards doesn't shift
as each thumbnail finishes loading. The ratio is honored by
.yt-video-thumb's aspect-ratio: 16/9 regardless of actual px. --}}
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}"
width="320" height="180"
loading="lazy" decoding="async"
onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@if($videoUrl)
{{-- Match cards preload metadata so the first frame is ready the
instant the user hovers (otherwise the browser fetches, buffers
@@ -98,9 +102,13 @@ $sizeClasses = match($size) {
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video>
@endif
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.scorebar-mini', ['video' => $video])
@endif
{{-- Match cards used to inline the full VS portrait + the live-scoring
scorebar as thumbnail overlays. Those partials leaked fighter
names, "AKA / AO", "ROUND 1", "0:00" and club text into the
card DOM — showing stale 0-0 for finished matches, breaking
the title layout at narrow widths, and inflating card HTML.
The regular <img> above is the thumbnail now; the VS intro
and live scorebar live on the full match player page only. --}}
{{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay">
<div class="audio-eq">
@@ -132,13 +140,15 @@ $sizeClasses = match($size) {
@endif
</div>
</a>
<div class="yt-video-info">
<div class="yt-video-info {{ $hideChannelInfo ? 'yt-video-info-no-channel' : '' }}">
@unless($hideChannelInfo)
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();">
@if($video && $video->user && $video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
@endif
</a>
@endunless
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ $showUrl }}">
@@ -152,7 +162,7 @@ $sizeClasses = match($size) {
@endif
</a>
</h3>
@if($video && $video->user)
@if($video && $video->user && ! $hideChannelInfo)
<a href="{{ route('channel', $video->user->channel) }}"
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
@endif
@@ -171,15 +181,15 @@ $sizeClasses = match($size) {
</div>
@if($video)
<div class="position-relative">
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
<i class="bi bi-three-dots-vertical"></i>
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More actions">
<i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($isOwner)
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</a>
</button>
</li>
<li>
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
@@ -189,17 +199,17 @@ $sizeClasses = match($size) {
<li><hr class="dropdown-divider"></li>
@endif
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
<button type="button" class="dropdown-item" onclick="addToQueue('{{ $video->getRouteKey() }}')">
<i class="bi bi-list-nested"></i> Add to queue
</a>
</button>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
<button type="button" class="dropdown-item" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
<i class="bi bi-clock"></i> Save to Watch later
</a>
</button>
</li>
<li>
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
<x-save-to-playlist-button :video="$video" tag="button" class="dropdown-item">
<i class="bi bi-bookmark"></i> Save to playlist
</x-save-to-playlist-button>
</li>
@@ -212,133 +222,45 @@ $sizeClasses = match($size) {
@endif
@if($video->isShareable())
<li>
<x-share-button :video="$video" tag="a" class="dropdown-item">
<x-share-button :video="$video" tag="button" class="dropdown-item">
<i class="bi bi-share"></i> Share
</x-share-button>
</li>
@endif
{{-- "Not interested", "Don't recommend channel" and "Report"
only make sense for someone else's video. On your own, the
Edit/Delete items above are what you want instead. --}}
@unless($isOwner)
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
<button type="button" class="dropdown-item" onclick="notInterested('{{ $video->getRouteKey() }}')">
<i class="bi bi-dash-circle"></i> Not interested
</a>
</button>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')">
<button type="button" class="dropdown-item" onclick="dontRecommendChannel('{{ $video->user_id }}')">
<i class="bi bi-x-circle"></i> Don't recommend channel
</a>
</button>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
<button type="button" class="dropdown-item" onclick="reportVideo('{{ $video->getRouteKey() }}')">
<i class="bi bi-flag"></i> Report
</a>
</button>
</li>
@endunless
</ul>
</div>
@endif
</div>
</div>
@php $vk = $video->getRouteKey() ?? ''; @endphp
<!-- Cute Edit Video Modal -->
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
<div class="modal-content cute-edit-content">
<div class="cute-edit-header">
<span class="cute-edit-icon">✏️</span>
<h5>Edit Video</h5>
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
</div>
<div class="cute-edit-body">
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Title -->
<div class="cute-form-group">
<label><i class="bi bi-card-heading"></i> Title</label>
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
</div>
<!-- Description -->
<div class="cute-form-group">
<label><i class="bi bi-text-paragraph"></i> Description</label>
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
</div>
<!-- Video Type -->
<div class="cute-form-group">
<label><i class="bi bi-collection-play"></i> Type</label>
<div class="cute-type-options">
<label class="cute-type-option active" data-type="generic">
<input type="radio" name="type" value="generic" checked>
<span>🎬 Generic</span>
</label>
<label class="cute-type-option" data-type="music">
<input type="radio" name="type" value="music">
<span>🎵 Music</span>
</label>
<label class="cute-type-option" data-type="match">
<input type="radio" name="type" value="match">
<span>🏆 Match</span>
</label>
</div>
</div>
<!-- Shorts Toggle -->
<div class="cute-form-group">
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
<label class="cute-shorts-toggle">
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
<span class="cute-shorts-slider"></span>
<span class="cute-shorts-label">Mark as Short</span>
</label>
</div>
<!-- Thumbnail -->
<div class="cute-form-group">
<label><i class="bi bi-image"></i> Thumbnail</label>
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
<i class="bi bi-camera"></i>
<span>Click to change</span>
</div>
</div>
</div>
<!-- Privacy -->
<div class="cute-form-group">
<label><i class="bi bi-shield-lock"></i> Privacy</label>
<div class="cute-privacy-options">
<label class="cute-privacy-option active" data-privacy="public">
<input type="radio" name="visibility" value="public" checked>
<span>🌐 Public</span>
</label>
<label class="cute-privacy-option" data-privacy="unlisted">
<input type="radio" name="visibility" value="unlisted">
<span>🔗 Unlisted</span>
</label>
<label class="cute-privacy-option" data-privacy="private">
<input type="radio" name="visibility" value="private">
<span>🔒 Private</span>
</label>
</div>
</div>
<!-- Status -->
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
<!-- Buttons -->
<div class="cute-edit-actions">
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
<button type="submit" class="cute-btn-save">Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{--
The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
defines a global window.openEditVideoModal that always wins the last-definition race,
so clicking "Edit" on a card has always been going through the singleton. Removing the
per-card copy drops ~6000 lines of HTML from a 60-card feed page.
--}}
@include('components.partials.card-styles')
@@ -393,155 +315,8 @@ function stopVideo(element) {
element.classList.remove('audio-playing');
}
// Edit Modal Functions
let currentEditVideoId = null;
function openEditVideoModal(videoId) {
currentEditVideoId = videoId;
const modalId = 'editVideoModal' + (videoId || '');
const modal = new bootstrap.Modal(document.getElementById(modalId));
modal.show();
// Fetch video data
fetch(`/videos/${videoId}/edit`, {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const video = data.video;
document.getElementById('edit-title-' + videoId).value = video.title || '';
document.getElementById('edit-description-' + videoId).value = video.description || '';
// Set type
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
typeOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.type === (video.type || 'generic')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set privacy
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
privacyOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.privacy === (video.visibility || 'public')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set shorts toggle
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
if (shortsCheckbox) {
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
}
// Clear status
const statusEl = document.getElementById('edit-status-' + videoId);
statusEl.className = 'cute-status';
statusEl.textContent = '';
}
})
.catch(error => {
console.error('Error:', error);
});
}
function closeEditVideoModal(videoId) {
const modalId = 'editVideoModal' + (videoId || '');
const modalEl = document.getElementById(modalId);
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
}
// SPA navigation re-executes this once-protected script on every page swap.
// Guard the document-level listeners so they don't stack up across navs.
if (!window._videoCardListenersBound) {
window._videoCardListenersBound = true;
// Type option click handlers
document.addEventListener('click', function(e) {
if (e.target.closest('.cute-type-option')) {
const option = e.target.closest('.cute-type-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
if (e.target.closest('.cute-privacy-option')) {
const option = e.target.closest('.cute-privacy-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
});
// Thumbnail preview
document.addEventListener('change', function(e) {
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
const file = e.target.files[0];
if (file) {
const videoId = e.target.id.replace('edit-thumbnail-', '');
const preview = document.getElementById('thumbnail-preview-' + videoId);
preview.innerHTML = `<span>${file.name}</span>`;
}
}
});
// Form submission
document.addEventListener('submit', function(e) {
const form = e.target;
if (form.id && form.id.startsWith('edit-video-form-')) {
e.preventDefault();
const videoId = form.id.replace('edit-video-form-', '');
const formData = new FormData(form);
const statusEl = document.getElementById('edit-status-' + videoId);
const submitBtn = form.querySelector('.cute-btn-save');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
fetch(`/videos/${videoId}`, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusEl.className = 'cute-status success';
statusEl.textContent = '✓ Saved successfully!';
setTimeout(() => {
closeEditVideoModal(videoId);
window.location.reload();
}, 1000);
} else {
throw new Error(data.message || 'Update failed');
}
})
.catch(error => {
statusEl.className = 'cute-status error';
statusEl.textContent = '✗ ' + error.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
});
}
});
} // end _videoCardListenersBound guard
// Edit-modal open/close + its form handlers live in the singleton at
// layouts/partials/edit-video-modal.blade.php (not here) — see the
// removed-markup note above the dropdown for why.
</script>
@endonce
@@ -4,8 +4,31 @@
use App\Models\CommentLike;
$currentUserId = Auth::id() ?? 0;
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
// Flag per commenter: most-frequent access country, falling back to the
// user's nationality. Batched so the comments list stays a single query.
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
// Pinned comments bubble to the top of the list regardless of recency,
// then everything else sorts newest-first. reorder() wipes the
// ->latest() default the comments() relation already applied, so
// pinned DESC becomes the PRIMARY sort instead of a tiebreaker.
$topComments = $video->comments()
->whereNull('parent_id')
->with('user', 'replies.user')
->reorder()
->orderByDesc('pinned')
->latest()
->get();
$topLevelCount = $topComments->count();
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
// Permalink: ?lc=<comment-id> scrolls to and highlights a specific
// comment or reply so a shared link lands on the exact message. The
// id is validated against the current video's comment ids so the
// highlight only applies when the target really belongs here.
$permalinkId = (int) request('lc', 0);
$allCommentIds = $topComments->pluck('id')
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
@@ -40,7 +63,17 @@
{{-- ── Header ── --}}
<div class="ytc-header">
<span class="ytc-count" id="ytcCount">{{ number_format($commentCount) }} Comments</span>
{{-- Top-level count on the header; reply count surfaces inside each
thread's "N replies" expander. "1 Comment" singular when there's
exactly one — never "1 Comments". --}}
<span class="ytc-count" id="ytcCount">
{{ number_format($topLevelCount) }} {{ Str::plural('Comment', $topLevelCount) }}
@if($replyCount > 0)
<span class="ytc-count-replies" aria-label="reply count">
· {{ number_format($replyCount) }} {{ Str::plural('reply', $replyCount) }}
</span>
@endif
</span>
<div class="ytc-sort-wrap" id="ytcSortWrap">
<button class="ytc-sort-btn" id="ytcSortBtn">
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
@@ -82,18 +115,41 @@
{{-- ── Comments list ── --}}
<div id="ytcList">
@forelse($topComments as $comment)
@php $isOwn = $comment->user_id === $currentUserId; @endphp
<div class="ytc-comment" id="comment-{{ $comment->id }}">
@php
$isOwn = $comment->user_id === $currentUserId;
$isVideoOwner = $currentUserId === $video->user_id;
@endphp
<div class="ytc-comment{{ $comment->pinned ? ' ytc-pinned' : '' }}" id="comment-{{ $comment->id }}">
@if($comment->pinned)
<div class="ytc-pinned-badge" aria-label="Pinned by the creator">
<i class="bi bi-pin-fill" aria-hidden="true"></i> Pinned by {{ $video->user->name ?? 'the creator' }}
</div>
@endif
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
</a>
<div class="ytc-body-wrap">
<div class="ytc-meta">
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a>
<span class="ytc-time">{{ ytcTime($comment->created_at) }}</span>
<a class="ytc-time ytc-permalink"
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
title="Copy link to this comment"
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
@if($comment->hearted)
<span class="ytc-heart-badge"
aria-label="Hearted by the creator"
title="Hearted by {{ $video->user->name ?? 'the creator' }}">
<i class="bi bi-heart-fill" aria-hidden="true"></i>
</span>
@endif
</div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
@if($llmEnabled)
<div class="ytc-translation" id="translation-{{ $comment->id }}" style="display:none"></div>
@endif
{{-- Edit form --}}
@if($isOwn)
@@ -118,8 +174,31 @@
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button>
@auth
{{-- Creator-only heart + pin toggles. The server
rejects anyone else, but hiding the buttons
keeps the UI uncluttered for regular viewers. --}}
@if($isVideoOwner)
<button class="ytc-creator-btn ytc-heart-toggle{{ $comment->hearted ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->hearted ? 'true' : 'false' }}"
title="{{ $comment->hearted ? 'Remove heart' : 'Heart this comment' }}">
<i class="bi {{ $comment->hearted ? 'bi-heart-fill' : 'bi-heart' }}" aria-hidden="true"></i>
</button>
<button class="ytc-creator-btn ytc-pin-toggle{{ $comment->pinned ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->pinned ? 'true' : 'false' }}"
title="{{ $comment->pinned ? 'Unpin' : 'Pin to top' }}">
<i class="bi {{ $comment->pinned ? 'bi-pin-fill' : 'bi-pin' }}" aria-hidden="true"></i>
</button>
@endif
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
@endauth
@if($llmEnabled)
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $comment->id }}" title="Translate this comment">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="ytc-translate-label">Translate</span>
</button>
@endif
@if($isOwn)
<div class="ytc-more-wrap">
<button class="ytc-more-btn" title="More actions">
@@ -139,20 +218,12 @@
@endif
</div>
{{-- Reply form --}}
{{-- Reply form is lazy-cloned from #ytc-reply-form-tpl on
first click; the ~13-line markup used to render for
every top-level comment (so a 50-comment video shipped
~650 lines of hidden form markup to every viewer). --}}
@auth
<div class="ytc-reply-form" id="replyForm{{ $comment->id }}">
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
<div class="ytc-input-wrap">
<textarea class="ytc-textarea ytc-reply-textarea" id="replyBody{{ $comment->id }}"
placeholder="Add a reply..." rows="1"></textarea>
<div class="ytc-form-actions">
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger" data-comment-id="{{ $comment->id }}">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger"
data-video-id="{{ $video->getRouteKey() }}" data-parent-id="{{ $comment->id }}">Reply</button>
</div>
</div>
</div>
<div class="ytc-reply-slot" data-comment-id="{{ $comment->id }}" data-video-id="{{ $video->getRouteKey() }}"></div>
@endauth
{{-- Replies --}}
@@ -172,10 +243,18 @@
</a>
<div class="ytc-body-wrap">
<div class="ytc-meta">
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a>
<span class="ytc-time">{{ ytcTime($reply->created_at) }}</span>
<a class="ytc-time ytc-permalink"
href="?lc={{ $reply->id }}#comment-{{ $reply->id }}"
title="Copy link to this reply"
aria-label="Permalink">{{ ytcTime($reply->created_at) }}</a>
</div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
@if($llmEnabled)
<div class="ytc-translation" id="translation-{{ $reply->id }}" style="display:none"></div>
@endif
@if($replyIsOwn)
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
@@ -197,6 +276,21 @@
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button>
@auth
{{-- Reply to a reply: opens the parent comment's reply form and
prefills "@AuthorName " so the responder's reader knows which
reply they're answering. The DB still stores it as a one-level
thread (parent_id = top-level), matching the existing model. --}}
<button class="ytc-reply-btn _comment-reply-nested-trigger"
data-parent-id="{{ $comment->id }}"
data-handle="{{ $reply->user->username ?? $reply->user->name ?? '' }}">Reply</button>
@endauth
@if($llmEnabled)
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="ytc-translate-label">Translate</span>
</button>
@endif
@if($replyIsOwn)
<div class="ytc-more-wrap">
<button class="ytc-more-btn">
@@ -270,6 +364,66 @@
font-weight: 400;
color: var(--text-primary);
}
.ytc-count-replies {
font-size: 13px;
color: var(--text-secondary);
margin-left: 4px;
}
/* ── Creator engagement (heart + pin) ── */
.ytc-pinned-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.ytc-pinned-badge i { color: #ef4444; }
.ytc-comment.ytc-pinned {
background: rgba(239, 68, 68, 0.03);
border-left: 2px solid rgba(239, 68, 68, 0.5);
padding-left: 10px;
margin-left: -12px;
}
.ytc-heart-badge {
display: inline-flex;
align-items: center;
margin-left: 4px;
color: #ef4444;
font-size: 13px;
}
.ytc-creator-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
padding: 4px;
border-radius: 50%;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .15s, color .15s;
}
.ytc-creator-btn:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
.ytc-creator-btn.active { color: #ef4444; }
.ytc-creator-btn.active:hover { background: rgba(239, 68, 68, 0.15); }
/* Comment permalink highlight — brief glow on the targeted row when a
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
.ytc-comment.ytc-highlight,
.ytc-reply.ytc-highlight {
animation: ytc-highlight-pulse 2.8s ease-out;
}
@keyframes ytc-highlight-pulse {
0% { background-color: rgba(239, 68, 68, 0.18); }
40% { background-color: rgba(239, 68, 68, 0.18); }
100% { background-color: transparent; }
}
.ytc-sort-wrap { position: relative; }
.ytc-sort-btn {
display: flex;
@@ -440,7 +594,11 @@
.ytc-time {
font-size: 12px;
color: var(--text-secondary);
text-decoration: none;
}
/* The time stamp is now an <a> permalink. Underline on hover so it's
discoverable but doesn't scream "link" at rest. */
.ytc-time.ytc-permalink:hover { text-decoration: underline; cursor: pointer; }
/* ── Comment text ── */
.ytc-text {
@@ -451,6 +609,31 @@
white-space: pre-wrap;
}
/* ── Read more clamp ──
Long comments collapse to ~4 lines by default; the "Read more" button
under the body expands them. The clamp uses line-clamp (not a fixed
max-height) so the first few lines are always complete, never cut
mid-word, and the fade-mask eases into the trailing ellipsis.
*/
.ytc-text.ytc-clamp {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
}
.ytc-read-more {
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 0 0;
margin-top: 2px;
}
.ytc-read-more:hover { color: var(--text-primary); }
/* ── Actions ── */
.ytc-actions {
display: flex;
@@ -498,6 +681,57 @@
}
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
/* ── Translate ── */
.ytc-translate-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 6px 10px;
border-radius: 18px;
transition: background .15s, color .15s;
font-family: inherit;
}
.ytc-translate-btn:hover { background: rgba(255,255,255,.1); color: var(--text-primary); }
.ytc-translate-btn svg { width: 16px; height: 16px; fill: currentColor; }
.ytc-translate-btn.loading { opacity: .6; cursor: wait; }
.ytc-translate-btn.active { color: #3ea6ff; }
.ytc-flag {
display: inline-block;
width: 18px;
height: 13px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
vertical-align: middle;
transform: translateY(-1px);
flex-shrink: 0;
}
.ytc-translation {
margin-top: 6px;
padding: 10px 12px;
background: rgba(62,166,255,.08);
border-left: 2px solid #3ea6ff;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.ytc-translation-meta {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: .4px;
}
/* ── Three-dot menu ── */
.ytc-more-wrap {
position: relative;
@@ -695,9 +929,16 @@ const YTC = {
videoId: '{{ $video->getRouteKey() }}',
csrf: '{{ csrf_token() }}',
userId: {{ $currentUserId }},
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
deleteId: null,
toastTimer: null,
};
const YTC_TRANSLATE_SVG = '<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>';
function targetLang() {
const l = (navigator.language || 'en').toLowerCase().split('-')[0];
return l || 'en';
}
// ── Helpers ──────────────────────────────────────────
function esc(s) {
@@ -877,6 +1118,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
const rt = e.target.closest('._comment-reply-trigger');
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
// Replying to a reply — open the parent-level reply form and
// prefill it with the reply author's @handle so the thread stays
// readable even though the DB only stores one-level nesting.
const rtn = e.target.closest('._comment-reply-nested-trigger');
if (rtn) {
e.stopPropagation();
const parentId = rtn.dataset.parentId;
const handle = (rtn.dataset.handle || '').replace(/\s+/g, '');
toggleReplyForm(parentId);
const ta = document.getElementById('replyBody' + parentId);
if (ta && handle) {
const mention = '@' + handle + ' ';
if (!ta.value.startsWith(mention)) ta.value = mention + ta.value;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
autoResize(ta);
}
return;
}
// Edit trigger
const et = e.target.closest('._comment-edit-trigger');
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
@@ -904,12 +1165,148 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
// Dislike btn
const db = e.target.closest('.ytc-dislike-btn');
if (db) { e.stopPropagation(); toggleDislike(db); return; }
// Creator heart — owner-only, server rejects anyone else
const hb = e.target.closest('.ytc-heart-toggle');
if (hb) {
e.stopPropagation();
const id = hb.dataset.commentId;
fetch('/comments/' + id + '/heart', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.hearted !== undefined) {
hb.classList.toggle('active', d.hearted);
hb.setAttribute('aria-pressed', d.hearted ? 'true' : 'false');
hb.querySelector('i').className = 'bi ' + (d.hearted ? 'bi-heart-fill' : 'bi-heart');
// Also toggle the visible heart badge in the comment meta.
const meta = hb.closest('.ytc-comment').querySelector('.ytc-meta');
let badge = meta?.querySelector('.ytc-heart-badge');
if (d.hearted && !badge && meta) {
badge = document.createElement('span');
badge.className = 'ytc-heart-badge';
badge.innerHTML = '<i class="bi bi-heart-fill" aria-hidden="true"></i>';
meta.appendChild(badge);
} else if (!d.hearted && badge) {
badge.remove();
}
}
}).catch(() => {});
return;
}
// Creator pin — owner-only, pins top-level comments to the list head
const pb = e.target.closest('.ytc-pin-toggle');
if (pb) {
e.stopPropagation();
const id = pb.dataset.commentId;
fetch('/comments/' + id + '/pin', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.pinned !== undefined) {
// Reload to re-sort — pin changes the order of top-level
// comments and the DOM move is cleaner server-side than
// trying to shuffle the live list.
location.reload();
}
}).catch(() => {});
return;
}
});
// ── Reply form ────────────────────────────────────────
// Translate — bound at `document` level once, dispatches to the LATEST
// IIFE's handler via window.__ytcToggleTranslate. SPA video-to-video swaps
// replace #ytcSection and re-run this script; the new IIFE overwrites
// window.__ytcToggleTranslate so it keeps using fresh state (YTC.csrf etc.).
window.__ytcToggleTranslate = function (btn) { toggleTranslate(btn); };
if (!window.__ytcTranslateDelegated) {
window.__ytcTranslateDelegated = true;
document.addEventListener('click', e => {
const tb = e.target.closest('._comment-translate-trigger');
if (!tb) return;
e.stopPropagation();
if (typeof window.__ytcToggleTranslate === 'function') window.__ytcToggleTranslate(tb);
});
}
// ── Translate ─────────────────────────────────────────
async function toggleTranslate(btn) {
const id = btn.dataset.commentId;
const panel = document.getElementById('translation-' + id);
const label = btn.querySelector('.ytc-translate-label');
if (!panel) return;
// Toggle off if already shown.
if (panel.style.display !== 'none' && panel.dataset.loaded === '1') {
panel.style.display = 'none';
btn.classList.remove('active');
if (label) label.textContent = 'Translate';
return;
}
// Already loaded — just reveal.
if (panel.dataset.loaded === '1') {
panel.style.display = 'block';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
return;
}
btn.classList.add('loading');
if (label) label.textContent = 'Translating…';
try {
const r = await fetch('/comments/' + id + '/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ target: targetLang() })
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
panel.innerHTML = '';
const meta = document.createElement('span');
meta.className = 'ytc-translation-meta';
meta.textContent = 'Translated → ' + (d.target || targetLang()).toUpperCase();
const body = document.createElement('div');
body.textContent = d.translated;
panel.appendChild(meta);
panel.appendChild(body);
panel.dataset.loaded = '1';
panel.style.display = 'block';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
} catch (err) {
toast(err.message || 'Translation failed', 'error');
if (label) label.textContent = 'Translate';
} finally {
btn.classList.remove('loading');
}
}
// ── Reply form (lazy-rendered from a <template>) ──────
// Replaces the per-comment inline markup that used to live in the DOM
// pre-hidden. First click on "Reply" clones the template into the comment's
// .ytc-reply-slot and wires its IDs. Subsequent toggles just show/hide.
function toggleReplyForm(commentId) {
const form = document.getElementById('replyForm' + commentId);
if (!form) return;
let form = document.getElementById('replyForm' + commentId);
if (!form) {
const slot = document.querySelector('.ytc-reply-slot[data-comment-id="' + commentId + '"]');
const tpl = document.getElementById('ytc-reply-form-tpl');
if (!slot || !tpl) return;
const frag = tpl.content.cloneNode(true);
const root = frag.querySelector('.ytc-reply-form');
const ta = frag.querySelector('.ytc-reply-textarea');
const cancel = frag.querySelector('._comment-cancel-reply-trigger');
const submit = frag.querySelector('._comment-submit-reply-trigger');
root.id = 'replyForm' + commentId;
ta.id = 'replyBody' + commentId;
cancel.dataset.commentId = commentId;
submit.dataset.parentId = commentId;
submit.dataset.videoId = slot.dataset.videoId || YTC.videoId;
slot.appendChild(frag);
form = root;
}
const isOpen = form.classList.contains('open');
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
if (!isOpen) {
@@ -970,16 +1367,26 @@ async function saveEdit(commentId) {
// ── Post comment ──────────────────────────────────────
async function postComment() {
if (postComment._busy) return; // guard against double-submit (fast dbl-click, Enter+click, duplicate listeners after SPA swap)
const ta = document.getElementById('ytcTextarea');
if (!ta) return;
const text = ta.value.trim();
if (!text) { toast('Write something first', 'error'); return; }
postComment._busy = true;
submitBtn.disabled = true;
submitBtn.textContent = 'Posting…';
try {
// Idempotency key: random per attempt. On retry the client reuses the
// same key so the server returns the original comment instead of
// writing a second one.
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': YTC.csrf,
'X-Idempotency-Key': idemKey,
},
body: JSON.stringify({ body: text })
});
if (!(r.headers.get('content-type') || '').includes('application/json')) {
@@ -1000,6 +1407,7 @@ async function postComment() {
} catch (err) {
toast(err.message, 'error');
} finally {
postComment._busy = false;
submitBtn.disabled = false;
submitBtn.textContent = 'Comment';
}
@@ -1012,11 +1420,21 @@ async function postReply(vid, parentId) {
const text = input.value.trim();
if (!text) { toast('Write something first', 'error'); return; }
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
if (btn) {
if (btn.dataset.busy === '1') return; // guard against double-submit
btn.dataset.busy = '1';
btn.disabled = true;
btn.textContent = 'Posting…';
}
try {
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
const r = await fetch('/videos/' + vid + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': YTC.csrf,
'X-Idempotency-Key': idemKey,
},
body: JSON.stringify({ body: text, parent_id: parentId })
});
if (!(r.headers.get('content-type') || '').includes('application/json')) {
@@ -1035,7 +1453,7 @@ async function postReply(vid, parentId) {
} catch (err) {
toast(err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; }
if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
}
}
@@ -1133,6 +1551,12 @@ function buildCommentEl(c, isReply) {
</div>` : '';
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
const translateBtn = YTC.llmEnabled ? `
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="${id}" title="Translate this comment">
${YTC_TRANSLATE_SVG}<span class="ytc-translate-label">Translate</span>
</button>` : '';
const translationPanel = YTC.llmEnabled ? `<div class="ytc-translation" id="translation-${id}" style="display:none"></div>` : '';
const flagEl = YTC.meCountry ? `<span class="ytc-flag fi fi-${YTC.meCountry}"></span>` : '';
const div = document.createElement('div');
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
@@ -1143,10 +1567,12 @@ function buildCommentEl(c, isReply) {
</a>
<div class="ytc-body-wrap">
<div class="ytc-meta">
${flagEl}
<span class="ytc-author">${name}</span>
<span class="ytc-time">${time}</span>
</div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
${translationPanel}
${editForm}
<div class="ytc-actions">
<button class="ytc-like-btn" data-id="${id}" title="Like">
@@ -1157,6 +1583,7 @@ function buildCommentEl(c, isReply) {
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button>
${replyBtn}
${translateBtn}
${moreMenu}
</div>
${replyForm}
@@ -1226,6 +1653,32 @@ function toggleDislike(btn) {
function initLikeStates() {} // server-rendered state is already in the HTML
// ── Timestamp & @mention parsing ─────────────────────
// ── Read-more clamp ──
// Flags long bodies (>4 lines once rendered) so a "Read more" button
// below the body expands them. Measured in computed line-height × 4.
function maybeClampBody(el) {
// Deferred one microtask so el.scrollHeight is correct even if the
// enclosing comment card just got inserted into the DOM.
requestAnimationFrame(() => {
const lh = parseFloat(getComputedStyle(el).lineHeight) || 22;
const limit = lh * 4;
if (el.scrollHeight <= limit + 1) return;
el.classList.add('ytc-clamp');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ytc-read-more';
btn.textContent = 'Read more';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const expanded = el.classList.toggle('ytc-clamp') === false;
btn.textContent = expanded ? 'Show less' : 'Read more';
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
el.insertAdjacentElement('afterend', btn);
});
}
function enhanceBody(el) {
if (!el || el.dataset._commentEnhanced === '1') return;
let text = el.textContent || '';
@@ -1255,6 +1708,9 @@ function enhanceBody(el) {
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
});
});
// Add the Read more affordance after enhancing (so links and timestamps
// are rendered first and the measured height is final).
maybeClampBody(el);
}
function enhanceAll() {
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
@@ -1309,15 +1765,38 @@ const listObserver = new MutationObserver(() => {
const list = document.getElementById('ytcList');
if (list) listObserver.observe(list, { childList: true, subtree: true });
// ── Init ──────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
enhanceAll();
initLikeStates();
});
if (document.readyState !== 'loading') {
enhanceAll();
initLikeStates();
// ── Comment permalink (?lc=<id>) ─────────────────────
// Scroll to #comment-<id> and briefly highlight the row so a shared link
// lands on the exact message. The reply row is nested inside a .ytc-replies
// expander by default — we auto-open the parent thread first so the target
// is visible before we scroll.
function openCommentPermalink(id) {
if (!id) return;
const target = document.getElementById('comment-' + id);
if (!target) return;
// Find and expand the parent replies section, if any.
const repliesSection = target.closest('.ytc-replies-section');
if (repliesSection) {
const toggle = repliesSection.querySelector('.ytc-replies-toggle');
if (toggle && toggle.dataset.open !== '1') toggle.click();
}
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('ytc-highlight');
setTimeout(() => target.classList.remove('ytc-highlight'), 2800);
});
}
// ── Init ──────────────────────────────────────────────
function _ytcInit() {
enhanceAll();
initLikeStates();
@if($permalinkId)
openCommentPermalink({{ (int) $permalinkId }});
@endif
}
document.addEventListener('DOMContentLoaded', _ytcInit);
if (document.readyState !== 'loading') _ytcInit();
// ── Public API (for backward compat) ─────────────────
window._comment = {
@@ -1327,3 +1806,22 @@ window._comment = {
})();
</script>
@auth
{{-- Reply-form template. Cloned into .ytc-reply-slot the first time a user
clicks Reply on a given comment. Keeping it here (not in the layout)
means the markup travels with the comments component — the only place
that uses it. --}}
<template id="ytc-reply-form-tpl">
<div class="ytc-reply-form">
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
<div class="ytc-input-wrap">
<textarea class="ytc-textarea ytc-reply-textarea" placeholder="Add a reply..." rows="1"></textarea>
<div class="ytc-form-actions">
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger">Reply</button>
</div>
</div>
</div>
</template>
@endauth
@@ -205,12 +205,99 @@
</div>{{-- /chrome-controls --}}
</div>{{-- /chrome-bottom --}}
{{-- End screen: shown when the video reaches its 'ended' event. Carries
a thumbnail + title for the next recommended video and a radial
countdown ring; auto-advances when the ring completes, cancelled on
any click inside the player. --}}
<div class="ytp-end-screen" id="ytpEndScreen" aria-hidden="true">
<div class="ytp-end-inner">
<div class="ytp-end-label">Up next</div>
<a class="ytp-end-card" id="ytpEndCard" href="#">
<div class="ytp-end-thumb">
<img id="ytpEndThumb" src="" alt="" loading="lazy">
<div class="ytp-end-countdown">
<svg viewBox="0 0 36 36" aria-hidden="true">
<circle class="ytp-end-track" cx="18" cy="18" r="16"></circle>
<circle class="ytp-end-fill" cx="18" cy="18" r="16" id="ytpEndRing"></circle>
</svg>
<span class="ytp-end-timer" id="ytpEndTimer">5</span>
</div>
</div>
<div class="ytp-end-title" id="ytpEndTitle">Loading…</div>
</a>
<div class="ytp-end-actions">
<button type="button" class="ytp-end-btn ytp-end-cancel" id="ytpEndCancel">Cancel</button>
<button type="button" class="ytp-end-btn ytp-end-play" id="ytpEndPlay">Play now</button>
</div>
</div>
</div>
</div>{{-- /ytp --}}
</div>{{-- /ytp-wrap --}}
@once
<style>
/* ── End screen (autoplay countdown) ─────────────────────── */
.ytp-end-screen {
position: absolute; inset: 0;
display: none;
align-items: center; justify-content: center;
background: rgba(0,0,0,.78);
backdrop-filter: blur(6px);
z-index: 20;
color: #fff;
}
.ytp-end-screen.visible { display: flex; }
.ytp-end-inner {
display: flex; flex-direction: column; align-items: center;
gap: 16px; max-width: 560px; padding: 24px; text-align: center;
}
.ytp-end-label {
font-size: 12px; letter-spacing: .22em; text-transform: uppercase;
color: #aaa;
}
.ytp-end-card {
display: flex; flex-direction: column; gap: 10px;
text-decoration: none; color: inherit; cursor: pointer;
border-radius: 12px; padding: 12px; max-width: 420px;
transition: background .15s;
}
.ytp-end-card:hover { background: rgba(255,255,255,.06); color: #fff; }
.ytp-end-thumb {
position: relative; width: 100%; aspect-ratio: 16/9;
border-radius: 10px; overflow: hidden; background: #1a1a1a;
}
.ytp-end-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ytp-end-countdown {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.4);
}
.ytp-end-countdown svg { width: 72px; height: 72px; transform: rotate(-90deg); }
.ytp-end-track { fill: none; stroke: rgba(255,255,255,.25); stroke-width: 3; }
.ytp-end-fill { fill: none; stroke: #fff; stroke-width: 3;
stroke-dasharray: 100.5; stroke-dashoffset: 100.5;
transition: stroke-dashoffset .1s linear; }
.ytp-end-timer {
position: absolute; font-size: 22px; font-weight: 700;
color: #fff; text-shadow: 0 1px 4px rgba(0,0,0,.6);
}
.ytp-end-title {
font-size: 16px; font-weight: 500;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.ytp-end-actions { display: flex; gap: 10px; }
.ytp-end-btn {
padding: 8px 18px; border-radius: 20px; font-size: 13px; font-weight: 600;
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
cursor: pointer; transition: background .15s;
}
.ytp-end-btn:hover { background: rgba(255,255,255,.12); }
.ytp-end-play { background: #ef4444; border-color: #ef4444; }
.ytp-end-play:hover { background: #dc2626; }
/* ══════════════════════════════════════════════════════════
YTP PLAYER — YouTube-identical custom player
══════════════════════════════════════════════════════════ */
@@ -1304,17 +1391,96 @@ function navigatePrev() {
}
window._ytpNav = { next: navigateNext, prev: navigatePrev };
// ── End screen + autoplay countdown ─────────────────────
// Picks the first "Up Next" sidebar card as the next target and shows a
// 5-second countdown overlay. If the viewer doesn't cancel, we hand off to
// the existing _plOnVideoEnd / navigateNext flow.
const endScreen = document.getElementById('ytpEndScreen');
const endRing = document.getElementById('ytpEndRing');
const endTimer = document.getElementById('ytpEndTimer');
const endCard = document.getElementById('ytpEndCard');
const endThumb = document.getElementById('ytpEndThumb');
const endTitle = document.getElementById('ytpEndTitle');
const endCancel = document.getElementById('ytpEndCancel');
const endPlay = document.getElementById('ytpEndPlay');
let endInterval = null;
let endTargetUrl = null;
function closeEndScreen() {
if (endScreen) endScreen.classList.remove('visible');
if (endInterval) { clearInterval(endInterval); endInterval = null; }
}
function _findNextRecCard() {
// Prefer the first unwatched recommendation; fall back to the first card.
const list = document.querySelector('#recList');
if (!list) return null;
return list.querySelector('.sidebar-video-card[data-rec-url]');
}
function openEndScreen() {
if (!endScreen) {
// No end-screen markup (e.g. shorts). Fall back to the original behavior.
if (window._plOnVideoEnd) window._plOnVideoEnd();
else if (autoplayOn || isLooping) navigateNext();
return;
}
const card = _findNextRecCard();
if (!card) {
// Nothing to count down to — restore the old no-UI behavior.
if (window._plOnVideoEnd) window._plOnVideoEnd();
else if (autoplayOn || isLooping) navigateNext();
return;
}
endTargetUrl = card.dataset.recUrl;
endCard.href = endTargetUrl;
const img = card.querySelector('img');
const title = card.querySelector('.sidebar-title');
if (img && endThumb) endThumb.src = img.src;
if (title && endTitle) endTitle.textContent = title.textContent.trim();
endScreen.classList.add('visible');
let left = 5;
endTimer.textContent = left;
endRing.style.strokeDashoffset = '100.5';
const start = Date.now();
const total = 5000;
endInterval = setInterval(() => {
const elapsed = Date.now() - start;
const remaining = Math.max(0, total - elapsed);
left = Math.ceil(remaining / 1000);
endTimer.textContent = left;
endRing.style.strokeDashoffset = String(100.5 * (remaining / total));
if (remaining <= 0) {
closeEndScreen();
if (window._plOnVideoEnd) window._plOnVideoEnd();
else navigateNext();
}
}, 100);
}
endCancel?.addEventListener('click', closeEndScreen);
endPlay?.addEventListener('click', () => {
closeEndScreen();
if (window._plOnVideoEnd) window._plOnVideoEnd();
else navigateNext();
});
video.addEventListener('ended', () => {
updatePlayIcon();
largePlay.classList.add('visible');
clearTimeout(hideTimer);
releaseWakeLock();
if (window._plOnVideoEnd) {
window._plOnVideoEnd();
} else if (autoplayOn || isLooping) {
navigateNext();
if (isLooping) { navigateNext(); return; }
if (autoplayOn || window._plOnVideoEnd) {
openEndScreen();
}
});
// Any interaction inside the player cancels the countdown so the viewer
// can scrub back, replay, or just stop.
video.addEventListener('play', closeEndScreen);
video.addEventListener('seeking', closeEndScreen);
video.addEventListener('volumechange', updateVolumeIcon);
// ── Keyboard shortcuts ────────────────────────────────
@@ -1339,10 +1505,39 @@ document.addEventListener('keydown', e => {
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
case 'i': case 'I':
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
case 'c': case 'C':
// Captions / lyrics toggle — click the lyrics button when the
// page has one (music type). On a video that has no lyrics
// the key is a no-op; we don't throw up a toast because the
// viewer may just be hitting letters next to K / L / M.
{
const lyr = document.getElementById('ytpLyricsBtn');
if (lyr && lyr.offsetParent !== null) { e.preventDefault(); lyr.click(); showControls(); }
}
break;
case 'j': case 'J':
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
case 'l': case 'L':
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
case '>':
case '.':
// YouTube parity: shift+. (which produces ">") steps playback
// rate up in 0.25 jumps. "." alone also works for keyboards
// where ">" needs a modifier the browser doesn't surface.
if (e.shiftKey || e.key === '>') {
e.preventDefault();
video.playbackRate = Math.min(2, Math.round((video.playbackRate + 0.25) * 100) / 100);
showControls();
}
break;
case '<':
case ',':
if (e.shiftKey || e.key === '<') {
e.preventDefault();
video.playbackRate = Math.max(0.25, Math.round((video.playbackRate - 0.25) * 100) / 100);
showControls();
}
break;
case 'ArrowLeft':
e.preventDefault(); seekRelative(-5); showControls(); break;
case 'ArrowRight':
+74
View File
@@ -0,0 +1,74 @@
@extends('layouts.app')
@section('title', 'Page not found | ' . config('app.name'))
@section('content')
<style>
.err-404 {
max-width: 560px;
margin: 80px auto;
padding: 48px 32px;
text-align: center;
}
.err-404-emoji {
font-size: 72px;
line-height: 1;
margin-bottom: 12px;
}
.err-404 h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 10px;
color: var(--text-primary);
}
.err-404 p {
font-size: 15px;
color: var(--text-secondary);
margin: 0 0 24px;
line-height: 1.6;
}
.err-404-code {
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 4px;
}
.err-404-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-top: 24px;
}
@media (max-width: 480px) {
.err-404 { margin: 32px 12px; padding: 32px 16px; }
.err-404-emoji { font-size: 56px; }
.err-404 h1 { font-size: 22px; }
}
</style>
<div class="err-404">
<div class="err-404-emoji" aria-hidden="true">🎞️</div>
<div class="err-404-code">Error 404</div>
<h1>Can't find that one</h1>
<p>
The page or video you were looking for isn't here. It might have
been removed, renamed, or the link might have a typo.
</p>
<div class="err-404-actions">
<a href="{{ route('home') }}" class="action-btn action-btn-primary">
<i class="bi bi-house-door-fill" aria-hidden="true"></i>
<span>Back to Home</span>
</a>
<a href="{{ route('videos.index') }}" class="action-btn">
<i class="bi bi-grid-fill" aria-hidden="true"></i>
<span>Browse Videos</span>
</a>
<a href="{{ route('videos.shorts') }}" class="action-btn">
<i class="bi bi-lightning-charge-fill" aria-hidden="true"></i>
<span>Watch Shorts</span>
</a>
</div>
</div>
@endsection
+34 -6
View File
@@ -1218,6 +1218,20 @@
<script src="{{ asset('fp.js') }}" async></script>
</head>
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
{{-- Skip link: keyboard users land here first and can jump straight to
the main content region, past the header + sidebar nav. Hidden
off-screen until focused. --}}
<a href="#main" class="skip-to-content">Skip to content</a>
<style>
.skip-to-content {
position: absolute; top: -40px; left: 8px;
padding: 8px 14px; z-index: 10000;
background: #ef4444; color: #fff; border-radius: 0 0 8px 8px;
text-decoration: none; font-weight: 600;
transition: top .15s ease;
}
.skip-to-content:focus { top: 0; outline: 2px solid #fff; outline-offset: 2px; }
</style>
<!-- Header -->
@include('layouts.partials.header')
@@ -1237,10 +1251,11 @@
<!-- Mobile Search Overlay -->
<div class="mobile-search-overlay" id="mobileSearchOverlay">
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
<button type="submit" class="mobile-search-submit">
<i class="bi bi-search"></i>
<form action="{{ route('search') }}" method="GET" class="mobile-search-form" role="search">
<label for="mobile-search-input" class="visually-hidden">Search</label>
<input type="search" id="mobile-search-input" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
<button type="submit" class="mobile-search-submit" aria-label="Submit search">
<i class="bi bi-search" aria-hidden="true"></i>
</button>
</form>
</div>
@@ -1697,11 +1712,24 @@
};
function loadNotifications() {
// Switch the pre-rendered "No notifications" placeholder to a
// spinner while the fetch is in flight, then let the response
// handler replace it with the real list, an empty state, or
// an error message. Previously the panel just said "Loading…"
// forever if the fetch threw synchronously or returned non-JSON.
list.innerHTML = '<div class="yt-notif-empty"><i class="bi bi-arrow-repeat spin"></i> Loading…</div>';
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (r) {
if (!r.ok) throw new Error('http ' + r.status);
return r.json();
})
.then(function (data) { loaded = true; renderNotifications(data); })
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; });
.catch(function () {
list.innerHTML = '<div class="yt-notif-empty">Could not load notifications. <button type="button" class="yt-notif-retry" onclick="loadNotifications()">Retry</button></div>';
});
}
// Expose for the inline Retry button above.
window.loadNotifications = loadNotifications;
window.toggleNotifPanel = function () {
var btn = document.getElementById('notifBtn');
@@ -178,22 +178,9 @@
</div>
</div>
{{-- ── Secondary track form template (cloned by JS for each extra track) ──── --}}
<div id="edit-extra-track-tpl" style="display:none;" aria-hidden="true">
<x-track-editor-form
prefix="__TPL__"
:is-primary="false"
language-name="__LANGNAME__"
language-id="csd_v___TPL__"
title-name="__TITLENAME__"
title-id="edit-__TPL__-title"
desc-name="__DESCNAME__"
desc-id="edit-__TPL__-desc"
:embed-audio-input="true"
audio-input-name="__AUDIONAME__"
video-file-input-id=""
/>
</div>
{{-- Secondary tracks in this modal are cloned from the SHARED
#um-track-form-template defined in layouts.partials.upload-modal. One
template → identical UX in both the create (upload) modal and this modal. --}}
<style>
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
@@ -720,6 +707,8 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
function editHandleSlides(fileList, tid) {
tid = tid || 't1';
_editSlidesCropStart(fileList, tid, _editRenderSlides);
const inp = document.getElementById('edit-' + tid + '-slides-input');
if (inp) inp.value = '';
}
// ── Slides crop queue: every added image is cropped before it enters the strip ──
@@ -740,8 +729,12 @@ function _editSlidesCropLoadNext() {
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
const f = _editSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit === 'function') {
window.tcPreload_slides_edit(f);
// Open modal first so the cropper canvas has a real layout box when
// initCropper measures. Preloading first (prior order) caused the
// second batch to open with stale zero-sized measurements and a Save
// button that silently did nothing.
window.openCropperModal_slides_edit();
requestAnimationFrame(() => window.tcPreload_slides_edit(f));
}
}
@@ -827,6 +820,8 @@ function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getEleme
function editHandleSlidesForTrack(tid, fileList) {
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
const inp = document.getElementById('edit-slides-input-' + tid);
if (inp) inp.value = '';
}
function editClearSlidesForTrack(e, tid) {
@@ -885,7 +880,9 @@ function _editRenderSlidesForTrack(tid) {
// ── Track popup ───────────────────────────────────────────────────────────────
function editOpenTrackPopup(trackId) {
document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none');
const form = document.getElementById('edit-tf-' + trackId);
// Primary track form uses the legacy id (edit-tf-t1); extra tracks are
// cloned from the SHARED #um-track-form-template and use id um-tf-{id}.
const form = document.getElementById('edit-tf-' + trackId) || document.getElementById('um-tf-' + trackId);
if (form) form.style.display = 'block';
const popup = document.getElementById('edit-track-popup');
popup.style.opacity = '0';
@@ -902,6 +899,10 @@ function editCloseTrackPopup() {
}
}
// For t1 the inputs are the primary-track Blade ids. For extra tracks (both
// newly-added and existing-track rows) the inputs come from the SHARED
// #um-track-form-template, so they use the shared ids track-lang-{id} /
// track-title-{id}. One function reads whichever is present.
function editUpdateTrackCard(trackId) {
let langCode = '', title = '';
if (trackId === 't1') {
@@ -910,9 +911,8 @@ function editUpdateTrackCard(trackId) {
langCode = li ? li.value : '';
title = ti ? ti.value.trim() : '';
} else {
const n = trackId.replace('e','');
const li = document.getElementById('csd_v_e' + n);
const ti = document.getElementById('edit-e' + n + '-title');
const li = document.getElementById('track-lang-' + trackId);
const ti = document.getElementById('track-title-' + trackId);
langCode = li ? li.value : '';
title = ti ? ti.value.trim() : '';
}
@@ -932,24 +932,26 @@ function editUpdateTrackCard(trackId) {
// ── Extra track management ────────────────────────────────────────────────────
const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage());
// Clone the SHARED upload-modal track-form partial into the edit popup. Both
// "add new track" and "load existing track" go through here, so there is one
// source of truth for the form markup. The caller picks the submission names:
// - new track → extra_track_*[]
// - existing track → track_*_updates[trackId]
function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) {
const tpl = document.getElementById('edit-extra-track-tpl');
if (!tpl) return null;
const html = tpl.innerHTML
.replaceAll('__TPL__', prefix)
.replaceAll('__LANGNAME__', langName)
.replaceAll('__TITLENAME__', titleName)
.replaceAll('__DESCNAME__', descName)
.replaceAll('__AUDIONAME__', audioName);
const tmp = document.createElement('div');
tmp.innerHTML = html;
const formEl = tmp.querySelector('.um-track-form');
if (!formEl) return null;
formEl.style.display = 'none';
document.getElementById('edit-tf-extra').appendChild(formEl);
_editSlidesData[prefix] = [];
if (window.CSD) new CSD('csd_' + prefix);
return formEl;
const frag = window.cloneTrackFormTemplate({
key: prefix, langName, titleName, descName, audioName,
});
if (!frag) return null;
const host = document.getElementById('edit-tf-extra');
host.appendChild(frag);
// _slidesData is the shared state used by the shared slides-zone handlers.
// Mirror the handle under _editSlidesData so the edit submit code stays
// oblivious to the migration (one array, two aliases).
window._slidesData = window._slidesData || {};
_slidesData[prefix] = [];
_editSlidesData[prefix] = _slidesData[prefix];
window.initTrackFormNode(prefix, pfx => editUpdateTrackCard(pfx));
return document.getElementById('um-tf-' + prefix);
}
function _editTrackCard(n, trackId, isExisting) {
@@ -990,34 +992,47 @@ function _editAddExistingTrack(track) {
'track_description_updates[' + track.id + ']',
'track_file_updates[' + track.id + ']'
);
const titleEl = document.getElementById('edit-' + pfx + '-title');
// Shared partial field ids: track-title-{pfx} / track-desc-{pfx} / track-lang-{pfx}
const titleEl = document.getElementById('track-title-' + pfx);
if (titleEl) titleEl.value = track.title || '';
const descEl = document.getElementById('edit-' + pfx + '-desc');
const descEl = document.getElementById('track-desc-' + pfx);
if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
if (track.language && window.CSD) {
const hi = document.getElementById('csd_v_' + pfx);
const wrap = document.getElementById('csd_' + pfx);
const hi = document.getElementById('track-lang-' + pfx);
const wrap = document.getElementById('lsd_track_' + pfx);
if (hi) hi.value = track.language;
if (wrap) _editSetLangCsd(wrap, track.language);
}
const hi = document.getElementById('csd_v_' + pfx);
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
editUpdateTrackCard(pfx);
_editUpdateTrackPositions();
}
function editDeleteTrack(trackId, n) {
_editDeleteTrackIds.push(trackId);
document.getElementById('edit-tc-e'+n)?.remove();
document.getElementById('edit-tf-e'+n)?.remove();
function _editRemoveTrackForms(n) {
const pfx = 'e' + n;
document.getElementById('edit-tc-' + pfx)?.remove();
// Form id depends on which cloner built it: shared template → um-tf-{pfx},
// legacy edit template → edit-tf-{pfx}. Clean up whichever exists.
document.getElementById('um-tf-' + pfx)?.remove();
document.getElementById('edit-tf-' + pfx)?.remove();
delete _editSlidesData[pfx];
delete (window._slidesData || {})[pfx];
const popup = document.getElementById('edit-track-popup');
if (popup.style.display !== 'none') {
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
if (!vis || vis.id === 'edit-tf-' + pfx || vis.id === 'um-tf-' + pfx) popup.style.display = 'none';
}
}
function editDeleteTrack(trackId, n) {
_editDeleteTrackIds.push(trackId);
_editRemoveTrackForms(n);
_editUpdateTrackPositions();
}
function editRemoveNewTrack(n) {
_editRemoveTrackForms(n);
}
function editAddExtraTrack() {
const n = ++_editExtraCount;
const pfx = 'e' + n;
@@ -1029,23 +1044,10 @@ function editAddExtraTrack() {
'extra_track_descriptions[]',
'extra_track_files[]'
);
const hi = document.getElementById('csd_v_' + pfx);
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
_editUpdateTrackPositions();
editOpenTrackPopup(pfx);
}
function editRemoveNewTrack(n) {
document.getElementById('edit-tc-e'+n)?.remove();
document.getElementById('edit-tf-e'+n)?.remove();
delete _editSlidesData['e'+n];
const popup = document.getElementById('edit-track-popup');
if (popup.style.display !== 'none') {
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
}
}
// Wire primary language → update card
;(function() {
const li = document.getElementById('edit_primary_language');
@@ -1,28 +1,32 @@
<!-- Header -->
<header class="yt-header">
<div class="yt-header-left">
<button class="yt-menu-btn" onclick="toggleSidebar()">
<i class="bi bi-list fs-5"></i>
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar menu" aria-controls="sidebar">
<i class="bi bi-list fs-5" aria-hidden="true"></i>
</button>
<a href="{{ route('home') }}" class="yt-logo">
{{-- Two responsive logos. The visible one at a given viewport
carries the app name; its sibling is display:none and gets
alt="" so a screen reader doesn't announce "Play Play". --}}
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height:30px;">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="" class="d-none d-md-block" style="height:30px;">
</a>
</div>
<div class="yt-header-center d-none d-md-flex">
<div class="yt-search">
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form">
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button>
<form action="{{ route('search') }}" method="GET" class="yt-search-form" role="search">
<label for="header-search-input" class="visually-hidden">Search</label>
<input type="search" id="header-search-input" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
<button type="submit" class="yt-search-btn" aria-label="Submit search"><i class="bi bi-search" aria-hidden="true"></i></button>
</form>
</div>
</div>
<div class="yt-header-right">
<!-- Mobile search -->
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()">
<i class="bi bi-search"></i>
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()" aria-label="Open search">
<i class="bi bi-search" aria-hidden="true"></i>
</button>
@auth
@@ -48,7 +52,7 @@
onclick="markAllRead()" style="display:none;">Mark all read</button>
</div>
<div class="yt-notif-list" id="notifList">
<div class="yt-notif-empty">Loading…</div>
<div class="yt-notif-empty" id="notifInitialState">No notifications</div>
</div>
</div>
</div>
@@ -56,9 +60,10 @@
<!-- User dropdown -->
<div class="yt-user-dropdown" id="headerUserDropdown">
<button class="yt-icon-btn" id="headerUserBtn"
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true">
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true"
aria-label="Account menu for {{ Auth::user()->name }}">
<img src="{{ Auth::user()->avatar_url }}"
class="yt-user-avatar" alt="{{ Auth::user()->name }}">
class="yt-user-avatar" alt="">
</button>
<div class="yt-user-panel" id="headerUserPanel" role="menu">
@@ -16,7 +16,8 @@
<span>Trending</span>
</a>
@auth
<a href="#" class="yt-sidebar-link">
<a href="{{ route('subscriptions.feed') }}"
class="yt-sidebar-link {{ request()->routeIs('subscriptions.feed') ? 'active' : '' }}">
<i class="bi bi-collection-play-fill"></i>
<span>Subscriptions</span>
</a>
@@ -56,6 +57,10 @@
<i class="bi bi-hand-thumbs-up-fill"></i>
<span>Liked Videos</span>
</a>
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
<i class="bi bi-sliders"></i>
<span>Studio</span>
</a>
</div>
@endauth
@@ -338,9 +338,31 @@
</div>
</div>{{-- /um-tf-t1 --}}
{{-- Extra track forms appended by JS --}}
{{-- Extra track forms appended by JS (cloned from the template below) --}}
<div id="um-tf-extra"></div>
{{-- SHARED track form template.
This is the single source of truth for the "Add Language Track"
dialog used by BOTH the create (upload) modal AND the edit modal.
Any CSS/markup/behaviour change here automatically applies to both.
Cloners replace the placeholders at clone time:
__N__ → track key (e.g. "e1" for create, "editE1" for edit)
__LANGNAME__ → form field name for the language select
__TITLENAME__ → form field name for the title input
__DESCNAME__ → form field name for the description textarea
__AUDIONAME__ → form field name for the audio file input --}}
<template id="um-track-form-template">
@include('layouts.partials.upload-modal.track-form', [
'key' => '__N__',
'showPrimaryNote' => false,
'langName' => '__LANGNAME__',
'titleName' => '__TITLENAME__',
'descName' => '__DESCNAME__',
'audioName' => '__AUDIONAME__',
'langRequired' => true,
])
</template>
</div>{{-- /um-tp-body --}}
<div class="um-tp-footer">
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
@@ -1372,6 +1394,10 @@ function slidesZoneDrop(e, tid) {
function handleSlidesForTrack(tid, fileList) {
_slidesCropStart(fileList, tid);
// Reset the file input so picking the SAME filename a second time still
// fires change (browsers suppress change when FileList is identical).
const inp = document.getElementById('slides-input-' + tid);
if (inp) inp.value = '';
}
// ── Slides crop queue: every added image is cropped before it enters the strip ──
@@ -1390,8 +1416,12 @@ function _slidesCropLoadNext() {
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
const f = _slidesCropQueue.shift();
if (typeof window.tcPreload_slides_upload === 'function') {
window.tcPreload_slides_upload(f);
// Open the modal FIRST so the canvas has a non-zero layout box when
// initCropper measures clientWidth/clientHeight. Preloading first
// (the previous order) caused the second batch to open with stale
// zero-sized measurements and a Save button that silently did nothing.
window.openCropperModal_slides_upload();
requestAnimationFrame(() => window.tcPreload_slides_upload(f));
}
}
@@ -1400,7 +1430,13 @@ function uploadSlidesCropDone(file) {
if (tid && file) {
if (!_slidesData[tid]) _slidesData[tid] = [];
if (_slidesData[tid].length < 10) {
_slidesData[tid].push(file);
// Cache a per-file object URL once so renderSlidesStrip can reuse it
// on every reorder without spawning a fresh FileReader per slide.
// The old approach (new FileReader per file per render) ran dozens
// of readers after a reorder and silently dropped some under memory
// pressure, leaving the strip with broken thumbnails.
const url = URL.createObjectURL(file);
_slidesData[tid].push({ file, url });
renderSlidesStrip(tid);
}
}
@@ -1422,7 +1458,10 @@ function renderSlidesStrip(tid) {
if (zone) zone.style.borderColor = '';
return;
}
files.forEach((f, index) => {
files.forEach((item, index) => {
// Entries are {file, url}; cached url is stable across reorders.
const url = item.url || (item.file ? URL.createObjectURL(item.file) : '');
if (item.file && !item.url) item.url = url;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
@@ -1441,10 +1480,15 @@ function renderSlidesStrip(tid) {
const rb = document.createElement('button');
rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
rb.innerHTML='<i class="bi bi-x"></i>';
rb.addEventListener('click', function(ev) { ev.stopPropagation(); _slidesData[tid].splice(index,1); renderSlidesStrip(tid); });
rb.addEventListener('click', function(ev) {
ev.stopPropagation();
const removed = _slidesData[tid].splice(index,1)[0];
if (removed && removed.url) URL.revokeObjectURL(removed.url);
renderSlidesStrip(tid);
});
const img = document.createElement('img');
img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt='';
const reader = new FileReader(); reader.onload = ev2 => { img.src=ev2.target.result; }; reader.readAsDataURL(f);
img.src = url;
wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap);
});
if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback';
@@ -1455,6 +1499,7 @@ function renderSlidesStrip(tid) {
function clearSlidesForTrack(e, tid) {
e.preventDefault(); e.stopPropagation();
(_slidesData[tid] || []).forEach(s => { if (s && s.url) URL.revokeObjectURL(s.url); });
_slidesData[tid] = [];
const input = document.getElementById('slides-input-' + tid);
if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); }
@@ -1494,10 +1539,8 @@ function updateTrackCard(trackId) {
langCode = li ? li.value : '';
title = ti ? ti.value.trim() : '';
} else {
const n = trackId.replace('e', '');
const uid = 'ltme_' + n;
const li = document.getElementById('csd_v_' + uid);
const ti = document.getElementById('um-tf-title-' + trackId);
const li = document.getElementById('track-lang-' + trackId);
const ti = document.getElementById('track-title-' + trackId);
langCode = li ? li.value : '';
title = ti ? ti.value.trim() : '';
}
@@ -1512,49 +1555,111 @@ function updateTrackCard(trackId) {
if (card) card.style.display = hasContent ? '' : 'none';
}
const flagEl = document.getElementById('um-tc-flag-' + trackId);
const titleEl = document.getElementById('um-tc-title-' + trackId);
if (flagEl) flagEl.className = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
if (titleEl) {
if (lang && title) titleEl.textContent = title + ' — ' + lang.label;
else if (lang) titleEl.textContent = lang.label;
else if (title) titleEl.textContent = title;
else titleEl.textContent = '';
}
// Update WHICHEVER card id convention exists — the create modal uses
// `um-tc-*-${id}` and the edit modal uses `edit-tc-*-${id}`. Writing to
// both keeps this helper a single source of truth for any shared template.
const flagEls = [
document.getElementById('um-tc-flag-' + trackId),
document.getElementById('edit-tc-flag-' + trackId),
];
const titleEls = [
document.getElementById('um-tc-title-' + trackId),
document.getElementById('edit-tc-title-' + trackId),
];
const flagClass = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
let display;
if (lang && title) display = title + ' — ' + lang.label;
else if (lang) display = lang.label;
else if (title) display = title;
else display = '';
flagEls.forEach(el => { if (el) el.className = flagClass; });
titleEls.forEach(el => { if (el) el.textContent = display; });
}
// ── Extra track management ────────────────────────────────────
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
function _ltBuildOptsHtml() {
return LANG_OPTIONS_MODAL.map(o =>
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
<span class="csd-opt-main">${o.label}</span>
<span class="csd-opt-sub">${o.native}</span>
</li>`
).join('');
// Audio picker handler shared by every track form (primary + extras) cloned
// from the <template id="um-track-form-template"> blade partial.
function handleTrackAudio(key, input) {
const file = input.files && input.files[0];
const fnameEl = document.getElementById('track-audio-fname-' + key);
const box = document.getElementById('track-audio-box-' + key);
if (fnameEl) fnameEl.textContent = file ? file.name : 'Click to choose file…';
if (box) box.style.borderColor = file ? '#22c55e' : '';
// Auto-fill the track title from the filename (same behavior as primary track)
if (file) {
const titleEl = document.getElementById('track-title-' + key);
if (titleEl && !titleEl.value) {
titleEl.value = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
updateTrackCard(key);
}
}
}
// Shared cloner for #um-track-form-template. Both the create modal and the
// edit modal call this so there is ONE source for the track-form markup.
// Returns a DocumentFragment with every placeholder (__N__, __LANGNAME__, …)
// substituted. The caller is responsible for appending it + initialising the
// language picker + RTE on the new nodes.
window.cloneTrackFormTemplate = window.cloneTrackFormTemplate || function (opts) {
const tpl = document.getElementById('um-track-form-template');
if (!tpl) { console.error('[track-form] missing #um-track-form-template'); return null; }
const frag = tpl.content.cloneNode(true);
const subs = {
'__N__': opts.key,
'__LANGNAME__': opts.langName,
'__TITLENAME__': opts.titleName,
'__DESCNAME__': opts.descName,
'__AUDIONAME__': opts.audioName,
};
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_ELEMENT, null);
const nodes = [];
for (let el = walker.nextNode(); el; el = walker.nextNode()) nodes.push(el);
nodes.forEach(node => {
for (const attr of Array.from(node.attributes)) {
let v = attr.value;
for (const k in subs) if (v.indexOf(k) !== -1) v = v.split(k).join(subs[k]);
if (v !== attr.value) node.setAttribute(attr.name, v);
}
});
return frag;
};
// Shared post-clone wiring: CSD language picker + RTE on fresh markup, plus
// change listeners that keep whichever card is on-screen in sync with the form.
window.initTrackFormNode = window.initTrackFormNode || function (key, onChange) {
if (window.CSD) new CSD('lsd_track_' + key);
const rteWrap = document.querySelector('#um-tf-' + key + ' .rte-wrap:not([data-rte-ready])');
if (rteWrap && window.RTE && typeof window.RTE.init === 'function') window.RTE.init(rteWrap);
const langInput = document.getElementById('track-lang-' + key);
const titleInput = document.getElementById('track-title-' + key);
const cb = onChange || (() => updateTrackCard(key));
if (langInput) langInput.addEventListener('change', () => cb(key));
if (titleInput) titleInput.addEventListener('input', () => cb(key));
};
function addExtraTrackModal() {
const n = ++_umExtraCount;
const trackNum = n + 1;
const uid = 'ltme_' + n;
const key = 'e' + n;
// Track card (shown in the list on the main modal body)
const card = document.createElement('div');
card.className = 'um-track-card';
card.id = 'um-tc-e' + n;
card.id = 'um-tc-' + key;
card.innerHTML = `
<div class="um-tc-body">
<div class="um-tc-left">
<div class="um-tc-num">${trackNum}</div>
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-e${n}"></span>
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-${key}"></span>
<div class="um-tc-info">
<span class="um-tc-title" id="um-tc-title-e${n}">Track — select language</span>
<span class="um-tc-title" id="um-tc-title-${key}">Track — select language</span>
</div>
</div>
<div class="um-tc-right">
<button type="button" class="action-btn" onclick="openTrackPopup('e${n}')">
<button type="button" class="action-btn" onclick="openTrackPopup('${key}')">
<i class="bi bi-pencil"></i> <span>Edit</span>
</button>
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
@@ -1564,100 +1669,24 @@ function addExtraTrackModal() {
</div>`;
document.getElementById('um-tc-extra').appendChild(card);
const formEl = document.createElement('div');
formEl.className = 'um-track-form';
formEl.id = 'um-tf-e' + n;
formEl.style.display = 'none';
formEl.innerHTML = `
<div class="um-tf-row2" style="margin-bottom:14px;">
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language <span class="um-req">*</span></label>
<div class="csd-wrap" id="${uid}">
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
<span class="csd-val ph">Select language</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch"><i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">${_ltBuildOptsHtml()}</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
</div>
</div>
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Title <span class="um-lbl-hint">optional</span>
</label>
<input type="text" name="extra_track_titles[]" id="um-tf-title-e${n}" class="um-input"
style="font-size:13px;padding:9px 12px;" placeholder="Title in this language…">
</div>
</div>
<div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Description <span class="um-lbl-hint">optional</span>
</label>
<div class="rte-wrap" data-min-height="100px">
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
placeholder="Description in this language…"></textarea>
</div>
</div>
<div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
<i class="bi bi-music-note-beamed"></i> Audio File
</label>
<label class="lta-file-label"
onmouseenter="this.style.borderColor='#e61e1e'"
onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:15px;flex-shrink:0;"></i>
<span class="lta-file-name" id="um-tf-fname-e${n}">Choose audio file…</span>
<input type="file" name="extra_track_files[]"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('um-tf-fname-e${n}').textContent=this.files[0]?.name||'Choose audio file…'">
</label>
</div>
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Cover Slides <span class="um-lbl-hint">optional · drag to reorder</span>
</label>
<div id="slides-zone-e${n}" class="um-slides-zone"
onclick="slidesZoneClick(event,'e${n}')"
ondragover="slidesZoneDragover(event,'e${n}')"
ondragleave="slidesZoneDragleave(event,'e${n}')"
ondrop="slidesZoneDrop(event,'e${n}')">
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-e${n}"
onchange="handleSlidesForTrack('e${n}',this.files)">
<div class="um-slides-ph" id="slides-ph-e${n}">
<i class="bi bi-images"></i>
<span>Click or drag to add cover images</span>
</div>
<div id="slides-preview-e${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
<div id="slides-strip-e${n}" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span id="slides-count-e${n}" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="clearSlidesForTrack(event,'e${n}')"
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
</div>
</div>
</div>`;
// Clone the SHARED track-form template and substitute placeholders. The same
// template is cloned by the edit modal (editAddExtraTrack / existing-track
// loader), so a change here automatically shows up in both flows.
const frag = window.cloneTrackFormTemplate({
key: key,
langName: 'extra_track_languages[]',
titleName: 'extra_track_titles[]',
descName: 'extra_track_descriptions[]',
audioName: 'extra_track_files[]',
});
if (!frag) return;
document.getElementById('um-tf-extra').appendChild(formEl);
_slidesData['e' + n] = [];
const host = document.getElementById('um-tf-extra');
host.appendChild(frag);
_slidesData[key] = [];
if (window.CSD) new CSD(uid);
const hiddenInput = document.getElementById('csd_v_' + uid);
if (hiddenInput) {
hiddenInput.addEventListener('change', function() { updateTrackCard('e' + n); });
}
openTrackPopup('e' + n);
window.initTrackFormNode(key);
openTrackPopup(key);
}
function removeExtraTrackModal(n) {
@@ -1686,13 +1715,15 @@ function removeExtraTrackModal(n) {
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
async function _chunkedUploadVideo(file, onProgress) {
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit
const CHUNK = 2 * 1024 * 1024; // 2 MB per chunk
const CONCURRENCY = 3; // parallel in-flight chunks
const total = Math.max(1, Math.ceil(file.size / CHUNK));
const uploadId = (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
for (let i = 0; i < total; i++) {
let uploadedBytes = 0;
const uploadOne = async (i) => {
const start = i * CHUNK;
const end = Math.min(start + CHUNK, file.size);
const blob = file.slice(start, end);
@@ -1700,6 +1731,7 @@ async function _chunkedUploadVideo(file, onProgress) {
const fd = new FormData();
fd.append('upload_id', uploadId);
fd.append('chunk_index', i);
fd.append('chunk_offset', start);
fd.append('total_chunks', total);
fd.append('filename', file.name);
fd.append('chunk', blob, 'chunk');
@@ -1715,8 +1747,20 @@ async function _chunkedUploadVideo(file, onProgress) {
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
throw new Error(msg);
}
if (onProgress) onProgress({ loaded: end, total: file.size });
uploadedBytes += (end - start);
if (onProgress) onProgress({ loaded: Math.min(uploadedBytes, file.size), total: file.size });
};
let next = 0;
const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, async () => {
while (true) {
const i = next++;
if (i >= total) return;
await uploadOne(i);
}
});
await Promise.all(workers);
return { uploadId: uploadId, filename: file.name };
}
@@ -1753,8 +1797,9 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
}
const formData = new FormData(this);
const _slideFile = s => (s && s.file) ? s.file : s; // tolerate legacy raw-File entries
if (_isMusicMode) {
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
(_slidesData['t1'] || []).forEach(s => formData.append('slides[]', _slideFile(s)));
// Walk extra-track form sections in DOM order so the slide index lines up
// with the positional `extra_track_files[]` the form already submits.
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
@@ -1763,8 +1808,8 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
if (!m) return;
var tid = m[1];
var files = _slidesData[tid] || [];
files.forEach(function (f) {
formData.append('extra_track_slides[' + domIdx + '][]', f);
files.forEach(function (s) {
formData.append('extra_track_slides[' + domIdx + '][]', _slideFile(s));
});
});
}
+446
View File
@@ -0,0 +1,446 @@
@extends('layouts.app')
@section('title', 'Go Live — '.$stream->title)
@section('extra_styles')
<style>
.lv-wrap { max-width: 1100px; margin: 0 auto; padding: 16px; }
/* The preview owns the screen on a phone. Everything else floats on it. */
.lv-stage {
position: relative; background: #000; border-radius: 16px; overflow: hidden;
aspect-ratio: 16 / 9; box-shadow: 0 10px 40px rgba(0,0,0,.45);
}
.lv-stage video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
/* Mirror only the selfie camera — a mirrored rear camera looks broken. */
.lv-stage video.mirrored { transform: scaleX(-1); }
.lv-badge {
position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px;
padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
background: rgba(0,0,0,.6); color: #fff; backdrop-filter: blur(8px);
}
.lv-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; }
.lv-badge.is-live { background: rgba(239,68,68,.92); }
.lv-badge.is-live .dot { background: #fff; animation: lvPulse 1.4s ease-in-out infinite; }
@keyframes lvPulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.75); } }
.lv-stats {
position: absolute; top: 12px; right: 12px; display: flex; flex-direction: column; gap: 4px;
padding: 8px 12px; border-radius: 12px; background: rgba(0,0,0,.55); backdrop-filter: blur(8px);
color: #e5e7eb; font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; min-width: 120px;
}
.lv-stats b { color: #fff; font-weight: 600; }
.lv-stats .warn { color: #fbbf24; }
.lv-stats .bad { color: #f87171; }
.lv-controls {
position: absolute; left: 0; right: 0; bottom: 0; padding: 16px;
display: flex; align-items: center; justify-content: center; gap: 12px;
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
}
.lv-btn {
width: 52px; height: 52px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
background: rgba(255,255,255,.12); color: #fff; font-size: 20px; cursor: pointer;
display: grid; place-items: center; backdrop-filter: blur(8px); transition: transform .12s ease, background .15s ease;
}
.lv-btn:hover { background: rgba(255,255,255,.22); }
.lv-btn:active { transform: scale(.92); }
.lv-btn.off { background: rgba(239,68,68,.85); border-color: transparent; }
.lv-btn:disabled { opacity: .4; cursor: not-allowed; }
.lv-golive {
min-width: 150px; height: 52px; padding: 0 26px; border-radius: 999px; border: 0;
background: #ef4444; color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
display: inline-flex; align-items: center; gap: 9px; transition: transform .12s ease, background .15s ease;
}
.lv-golive:hover { background: #dc2626; }
.lv-golive:active { transform: scale(.97); }
.lv-golive:disabled { opacity: .55; cursor: not-allowed; }
.lv-golive.is-live { background: #111827; border: 1px solid rgba(255,255,255,.25); }
.lv-meta { margin-top: 18px; display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 860px) { .lv-meta { grid-template-columns: 1.4fr 1fr; } }
.lv-card { background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); border-radius: 14px; padding: 16px; }
.lv-card h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .6; margin: 0 0 10px; }
.lv-share { display: flex; gap: 8px; }
.lv-share input {
flex: 1; min-width: 0; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12);
border-radius: 10px; padding: 10px 12px; color: inherit; font-size: 13px;
}
.lv-note { font-size: 12px; opacity: .6; line-height: 1.6; margin-top: 10px; }
.lv-msg { margin-top: 14px; padding: 12px 14px; border-radius: 12px; font-size: 14px; display: none; }
.lv-msg.err { display: block; background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.35); color: #fca5a5; }
.lv-msg.ok { display: block; background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.35); color: #86efac; }
</style>
@endsection
@section('content')
<div class="lv-wrap">
<div class="lv-stage">
<video id="lvPreview" autoplay playsinline muted></video>
<div class="lv-badge" id="lvBadge"><span class="dot"></span><span id="lvBadgeText">READY</span></div>
<div class="lv-stats" id="lvStats" style="display:none;">
<div><b id="lvRes">—</b> · <b id="lvFps">—</b> fps</div>
<div><b id="lvBitrate">—</b></div>
<div id="lvHealth">—</div>
<div><b id="lvTimer">00:00</b></div>
</div>
<div class="lv-controls">
<button class="lv-btn" id="lvMic" title="Mute microphone"><i class="bi bi-mic-fill"></i></button>
<button class="lv-golive" id="lvGoLive"><i class="bi bi-broadcast"></i><span id="lvGoLiveText">Go Live</span></button>
<button class="lv-btn" id="lvFlip" title="Switch camera"><i class="bi bi-arrow-repeat"></i></button>
</div>
</div>
<div class="lv-msg" id="lvMsg"></div>
<div class="lv-meta">
<div class="lv-card">
<h3>{{ $stream->title }}</h3>
@if ($stream->description)
<p style="margin:0 0 10px;font-size:14px;opacity:.8;">{{ $stream->description }}</p>
@endif
<div class="lv-share">
<input type="text" id="lvWatchUrl" value="{{ $watchUrl }}" readonly onclick="this.select()">
<button class="action-btn" type="button" id="lvCopy"><i class="bi bi-clipboard"></i> <span>Copy</span></button>
</div>
<p class="lv-note">
Anyone with this link can watch while you are live.
{{ $stream->visibility === 'public' ? 'This stream is also listed on the Live page.' : 'This stream is unlisted — only people with the link can find it.' }}
</p>
</div>
<div class="lv-card">
<h3>Quality</h3>
<p class="lv-note" style="margin-top:0;">
Targeting {{ config('live.video_width') }}×{{ config('live.video_height') }} at {{ config('live.video_fps') }} fps.
If your upload speed drops, the picture softens instead of freezing — that is the connection adapting, not a fault.
Wi-Fi or 5G will hold 1080p most reliably.
</p>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
(function () {
'use strict';
var CFG = {
whipUrl: @json($whipUrl),
tokenUrl: @json(route('live.token', $stream)),
stopUrl: @json(route('live.stop', $stream)),
csrf: @json(csrf_token()),
iceServers: @json($iceServers),
width: {{ (int) config('live.video_width') }},
height: {{ (int) config('live.video_height') }},
fps: {{ (int) config('live.video_fps') }},
maxBitrate: {{ (int) config('live.max_bitrate') }}
};
var el = function (id) { return document.getElementById(id); };
var preview = el('lvPreview'), badge = el('lvBadge'), badgeText = el('lvBadgeText');
var goLive = el('lvGoLive'), goLiveText = el('lvGoLiveText');
var msg = el('lvMsg'), stats = el('lvStats');
var stream = null, pc = null, statsTimer = null, clockTimer = null;
var facing = 'environment', live = false, startedAt = 0;
function say(text, kind) {
msg.textContent = text;
msg.className = 'lv-msg ' + (kind || 'err');
}
function clearSay() { msg.className = 'lv-msg'; msg.textContent = ''; }
/* ---------------- Camera ---------------- */
// Ask for 1080p as an IDEAL, never `exact`: a phone whose camera cannot do
// it should hand back its best effort, not refuse to open at all.
function constraints() {
return {
video: {
width: { ideal: CFG.width },
height: { ideal: CFG.height },
frameRate: { ideal: CFG.fps },
facingMode: { ideal: facing }
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
};
}
async function openCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
say('This browser cannot open a camera. On iPhone use Safari; on Android use Chrome.');
goLive.disabled = true;
return false;
}
// getUserMedia only exists in a secure context. Saying so plainly beats
// a bare "Permission denied" the user cannot act on.
if (!window.isSecureContext) {
say('The camera needs a secure (HTTPS) connection. Open this page over https.');
goLive.disabled = true;
return false;
}
try {
if (stream) { stream.getTracks().forEach(function (t) { t.stop(); }); }
stream = await navigator.mediaDevices.getUserMedia(constraints());
} catch (e) {
if (e && (e.name === 'NotAllowedError' || e.name === 'SecurityError')) {
say('Camera and microphone access was blocked. Allow it in your browser settings, then reload.');
} else if (e && e.name === 'NotFoundError') {
say('No camera was found on this device.');
} else if (e && e.name === 'NotReadableError') {
say('The camera is already in use by another app. Close it and try again.');
} else {
say('Could not start the camera: ' + (e && e.message ? e.message : e));
}
goLive.disabled = true;
return false;
}
preview.srcObject = stream;
preview.classList.toggle('mirrored', facing === 'user');
goLive.disabled = false;
clearSay();
return true;
}
/* ---------------- Publish (WHIP) ---------------- */
async function startBroadcast() {
goLive.disabled = true;
goLiveText.textContent = 'Starting…';
clearSay();
try {
// The publish credential is minted now, not at page load, so its
// short life starts when the user actually goes live.
var tokenRes = await fetch(CFG.tokenUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!tokenRes.ok) { throw new Error('Could not get permission to publish (' + tokenRes.status + ').'); }
var auth = await tokenRes.json();
pc = new RTCPeerConnection({
iceServers: auth.ice_servers && auth.ice_servers.length ? auth.ice_servers : CFG.iceServers,
bundlePolicy: 'max-bundle'
});
pc.addEventListener('connectionstatechange', function () {
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
say('The connection to the server dropped. Check your signal and go live again.');
stopBroadcast(true);
}
});
stream.getTracks().forEach(function (track) {
// maintain-resolution keeps 1920x1080 and sheds frame rate when
// the uplink tightens. For a match or a performance, a sharp
// 20fps reads better than a soft, blocky 30.
if (track.kind === 'video') { track.contentHint = 'detail'; }
pc.addTrack(track, stream);
});
var videoSender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
if (videoSender) {
var params = videoSender.getParameters();
params.encodings = params.encodings && params.encodings.length ? params.encodings : [{}];
params.encodings[0].maxBitrate = CFG.maxBitrate;
params.encodings[0].maxFramerate = CFG.fps;
params.degradationPreference = 'maintain-resolution';
try { await videoSender.setParameters(params); } catch (e) { /* older browsers ignore this */ }
}
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await iceGatheringComplete(pc);
var res = await fetch(auth.whip_url || CFG.whipUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
// Basic auth keeps the credential out of the URL, and so
// out of proxy and access logs.
'Authorization': 'Basic ' + btoa('publisher:' + auth.token)
},
body: pc.localDescription.sdp
});
if (res.status === 401 || res.status === 403) { throw new Error('The server refused the broadcast. Reload the page and try again.'); }
if (!res.ok) { throw new Error('The server rejected the stream (' + res.status + ').'); }
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
live = true;
startedAt = Date.now();
badge.classList.add('is-live');
badgeText.textContent = 'LIVE';
goLive.classList.add('is-live');
goLiveText.textContent = 'End Stream';
goLive.disabled = false;
stats.style.display = 'flex';
say('You are live. Share the link below.', 'ok');
statsTimer = setInterval(sampleStats, 1000);
clockTimer = setInterval(tickClock, 1000);
} catch (e) {
say(e && e.message ? e.message : String(e));
await stopBroadcast(true);
}
}
// Wait for ICE to finish rather than trickling: WHIP is a single
// offer/answer exchange, so the offer must already carry the candidates.
function iceGatheringComplete(pc) {
if (pc.iceGatheringState === 'complete') { return Promise.resolve(); }
return new Promise(function (resolve) {
var done = false;
function finish() { if (!done) { done = true; resolve(); } }
pc.addEventListener('icegatheringstatechange', function () {
if (pc.iceGatheringState === 'complete') { finish(); }
});
// Never hang on a candidate that will not arrive.
setTimeout(finish, 3000);
});
}
async function stopBroadcast(silent) {
clearInterval(statsTimer); statsTimer = null;
clearInterval(clockTimer); clockTimer = null;
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
if (live) {
try {
await fetch(CFG.stopUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
credentials: 'same-origin'
});
} catch (e) { /* the server also reaps on unpublish */ }
}
live = false;
badge.classList.remove('is-live');
badgeText.textContent = 'READY';
goLive.classList.remove('is-live');
goLiveText.textContent = 'Go Live';
goLive.disabled = false;
stats.style.display = 'none';
if (!silent) { say('Stream ended.', 'ok'); }
}
/* ---------------- Telemetry ---------------- */
var lastBytes = 0, lastAt = 0;
async function sampleStats() {
if (!pc) { return; }
var report = await pc.getStats();
var kbps = null, fps = null, w = null, h = null, lossPct = null;
report.forEach(function (s) {
if (s.type === 'outbound-rtp' && s.kind === 'video') {
var now = s.timestamp;
if (lastAt && s.bytesSent > lastBytes) {
kbps = Math.round(((s.bytesSent - lastBytes) * 8) / ((now - lastAt) / 1000) / 1000);
}
lastBytes = s.bytesSent; lastAt = now;
if (s.framesPerSecond) { fps = Math.round(s.framesPerSecond); }
if (s.frameWidth) { w = s.frameWidth; h = s.frameHeight; }
}
if (s.type === 'remote-inbound-rtp' && s.kind === 'video' && s.packetsLost != null && s.packetsReceived) {
lossPct = (s.packetsLost / (s.packetsLost + s.packetsReceived)) * 100;
}
});
if (w && h) { el('lvRes').textContent = w + '×' + h; }
if (fps !== null) { el('lvFps').textContent = fps; }
if (kbps !== null) { el('lvBitrate').textContent = (kbps / 1000).toFixed(1) + ' Mbps'; }
// Report the connection honestly — a broadcaster who can see the uplink
// struggling can move rather than wonder why the picture went soft.
var health = el('lvHealth');
if (lossPct === null) { health.textContent = 'connection ok'; health.className = ''; }
else if (lossPct < 2) { health.textContent = 'connection good'; health.className = ''; }
else if (lossPct < 8) { health.textContent = 'connection weak'; health.className = 'warn'; }
else { health.textContent = 'connection poor'; health.className = 'bad'; }
}
function tickClock() {
var s = Math.floor((Date.now() - startedAt) / 1000);
var m = Math.floor(s / 60), sec = s % 60;
el('lvTimer').textContent = (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
}
/* ---------------- Controls ---------------- */
goLive.addEventListener('click', function () {
if (live) { stopBroadcast(false); } else { startBroadcast(); }
});
el('lvFlip').addEventListener('click', async function () {
facing = (facing === 'environment') ? 'user' : 'environment';
var wasLive = live;
var ok = await openCamera();
if (!ok) { return; }
// Swap the track inside the existing connection so flipping the camera
// mid-broadcast does not drop the stream.
if (wasLive && pc) {
var newTrack = stream.getVideoTracks()[0];
var sender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
if (sender && newTrack) { try { await sender.replaceTrack(newTrack); } catch (e) {} }
}
});
el('lvMic').addEventListener('click', function () {
if (!stream) { return; }
var track = stream.getAudioTracks()[0];
if (!track) { return; }
track.enabled = !track.enabled;
this.classList.toggle('off', !track.enabled);
this.innerHTML = track.enabled ? '<i class="bi bi-mic-fill"></i>' : '<i class="bi bi-mic-mute-fill"></i>';
});
el('lvCopy').addEventListener('click', function () {
var input = el('lvWatchUrl');
input.select();
navigator.clipboard && navigator.clipboard.writeText(input.value);
var span = this.querySelector('span');
var original = span.textContent;
span.textContent = 'Copied';
setTimeout(function () { span.textContent = original; }, 1400);
});
// A broadcaster who closes the tab must not leave a stream stuck live.
window.addEventListener('pagehide', function () {
if (live && navigator.sendBeacon) {
var body = new FormData();
body.append('_token', CFG.csrf);
navigator.sendBeacon(CFG.stopUrl, body);
}
});
openCamera();
})();
</script>
@endsection
+89
View File
@@ -0,0 +1,89 @@
@extends('layouts.app')
@section('title', 'Go Live')
@section('extra_styles')
<style>
.lc-wrap { max-width: 560px; margin: 0 auto; padding: 24px 16px 60px; }
.lc-wrap h1 { font-size: 22px; font-weight: 700; margin: 0 0 6px; display: flex; align-items: center; gap: 10px; }
.lc-sub { font-size: 13px; opacity: .6; margin: 0 0 26px; line-height: 1.6; }
.lc-field { margin-bottom: 18px; }
.lc-field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 7px; }
.lc-field input[type=text], .lc-field textarea {
width: 100%; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14);
border-radius: 11px; padding: 11px 13px; color: inherit; font-size: 14px; font-family: inherit;
}
.lc-field input:focus, .lc-field textarea:focus { outline: none; border-color: #ef4444; }
.lc-field textarea { min-height: 88px; resize: vertical; }
.lc-err { font-size: 12px; color: #f87171; margin-top: 6px; }
.lc-vis { display: grid; gap: 10px; }
.lc-vis label {
display: flex; gap: 12px; align-items: flex-start; padding: 13px 15px; cursor: pointer;
border: 1px solid rgba(255,255,255,.14); border-radius: 12px; transition: border-color .15s ease, background .15s ease;
}
.lc-vis label:hover { background: rgba(255,255,255,.04); }
.lc-vis input { margin-top: 3px; accent-color: #ef4444; }
.lc-vis .t { font-size: 14px; font-weight: 600; }
.lc-vis .d { font-size: 12px; opacity: .6; margin-top: 2px; line-height: 1.5; }
.lc-vis input:checked + div .t { color: #ef4444; }
</style>
@endsection
@section('content')
<div class="lc-wrap">
<h1><i class="bi bi-broadcast" style="color:#ef4444;"></i> Go Live</h1>
<p class="lc-sub">Stream straight from your phone's camera. You'll get a link to share the moment you start.</p>
<form method="POST" action="{{ route('live.store') }}">
@csrf
<div class="lc-field">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="{{ old('title') }}" maxlength="120" required
placeholder="What are you streaming?" autofocus>
@error('title')<div class="lc-err">{{ $message }}</div>@enderror
</div>
<div class="lc-field">
<label for="description">Description <span style="opacity:.5;font-weight:400;">(optional)</span></label>
<textarea id="description" name="description" maxlength="2000" placeholder="Tell viewers what to expect">{{ old('description') }}</textarea>
@error('description')<div class="lc-err">{{ $message }}</div>@enderror
</div>
<div class="lc-field">
<label>Who can find it</label>
<div class="lc-vis">
<label>
<input type="radio" name="visibility" value="public" {{ old('visibility', 'public') === 'public' ? 'checked' : '' }}>
<div>
<div class="t">Public</div>
<div class="d">Listed on the Live page — anyone can discover and watch it.</div>
</div>
</label>
<label>
<input type="radio" name="visibility" value="unlisted" {{ old('visibility') === 'unlisted' ? 'checked' : '' }}>
<div>
<div class="t">Unlisted</div>
<div class="d">Not listed anywhere. Only people you send the link to can watch.</div>
</div>
</label>
{{-- Mirror the upload-modal's Private wording: only the owner
sees it. Useful for test runs and private rehearsals. --}}
<label>
<input type="radio" name="visibility" value="private" {{ old('visibility') === 'private' ? 'checked' : '' }}>
<div>
<div class="t">Private</div>
<div class="d">Only you can see this stream. Perfect for testing without an audience.</div>
</div>
</label>
</div>
@error('visibility')<div class="lc-err">{{ $message }}</div>@enderror
</div>
<button type="submit" class="action-btn action-btn-primary" style="width:100%;justify-content:center;height:46px;">
<i class="bi bi-camera-video"></i> <span>Set up camera</span>
</button>
</form>
</div>
@endsection
+95
View File
@@ -0,0 +1,95 @@
@extends('layouts.app')
@section('title', 'Live')
@section('extra_styles')
<style>
.li-wrap { max-width: 1300px; margin: 0 auto; padding: 16px; }
.li-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.li-head h1 { font-size: 22px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 10px; }
.li-head h1 .dot { width: 9px; height: 9px; border-radius: 50%; background: #ef4444; animation: liPulse 1.4s ease-in-out infinite; }
@keyframes liPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.li-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.li-card { display: block; text-decoration: none; color: inherit; border-radius: 14px; overflow: hidden; background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); transition: transform .15s ease, border-color .15s ease; }
.li-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.2); }
.li-thumb { position: relative; aspect-ratio: 16/9; background: linear-gradient(135deg, #1f2937, #111827); display: grid; place-items: center; }
.li-thumb i { font-size: 32px; opacity: .3; }
.li-live { position: absolute; top: 8px; left: 8px; background: #ef4444; color: #fff; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 4px; letter-spacing: .04em; }
.li-viewers { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,.75); color: #fff; font-size: 11px; padding: 3px 7px; border-radius: 4px; }
.li-body { padding: 12px 14px; }
.li-body h3 { font-size: 14px; font-weight: 600; margin: 0 0 5px; line-height: 1.35; }
.li-body p { font-size: 12px; opacity: .6; margin: 0; }
.li-empty { text-align: center; padding: 60px 20px; opacity: .55; }
.li-empty i { font-size: 40px; display: block; margin-bottom: 14px; opacity: .5; }
.li-section-title { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .55; margin: 34px 0 14px; }
.li-status { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,.1); }
.li-status.live { background: #ef4444; color: #fff; }
</style>
@endsection
@section('content')
<div class="li-wrap">
<div class="li-head">
<h1><span class="dot"></span> Live now</h1>
@auth
<a class="action-btn action-btn-primary" href="{{ route('live.create') }}">
<i class="bi bi-broadcast"></i> <span>Go Live</span>
</a>
@endauth
</div>
@if ($live->isEmpty())
<div class="li-empty">
<i class="bi bi-broadcast"></i>
<div>Nobody is streaming right now.</div>
@auth<div style="margin-top:8px;font-size:13px;">Be the first — hit <strong>Go Live</strong>.</div>@endauth
</div>
@else
<div class="li-grid">
@foreach ($live as $s)
<a class="li-card" href="{{ route('live.watch', $s) }}">
<div class="li-thumb">
<i class="bi bi-broadcast"></i>
<span class="li-live">LIVE</span>
<span class="li-viewers"><i class="bi bi-eye"></i> {{ $s->current_viewers }}</span>
</div>
<div class="li-body">
<h3>{{ $s->title }}</h3>
<p>{{ $s->user->name ?? 'Unknown' }}</p>
</div>
</a>
@endforeach
</div>
@endif
@auth
@if ($mine->isNotEmpty())
<div class="li-section-title">Your streams</div>
<div class="li-grid">
@foreach ($mine as $s)
<a class="li-card" href="{{ $s->status === 'ended' ? route('live.watch', $s) : route('live.broadcast', $s) }}">
<div class="li-thumb">
<i class="bi bi-{{ $s->isLive() ? 'broadcast' : 'camera-video-off' }}"></i>
@if ($s->isLive())<span class="li-live">LIVE</span>@endif
</div>
<div class="li-body">
<h3>{{ $s->title }}</h3>
<p>
<span class="li-status {{ $s->isLive() ? 'live' : '' }}">{{ ucfirst($s->status) }}</span>
@if ($s->started_at)
· {{ $s->formatted_duration }}
@endif
</p>
</div>
</a>
@endforeach
</div>
@endif
@endauth
</div>
@endsection
+501
View File
@@ -0,0 +1,501 @@
@extends('layouts.app')
@section('title', $stream->title.' — Live')
@section('extra_styles')
<style>
.lw-wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
.lw-stage { position: relative; background: #000; border-radius: 16px; overflow: hidden; aspect-ratio: 16 / 9; }
.lw-stage video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
.lw-badge {
position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px;
padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
background: rgba(239,68,68,.92); color: #fff;
}
.lw-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: lwPulse 1.4s ease-in-out infinite; }
.lw-badge.offline { background: rgba(0,0,0,.65); }
.lw-badge.offline .dot { background: #9ca3af; animation: none; }
@keyframes lwPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.lw-mode {
position: absolute; top: 12px; right: 12px; padding: 5px 10px; border-radius: 999px;
background: rgba(0,0,0,.55); color: #d1d5db; font-size: 11px; backdrop-filter: blur(8px);
}
.lw-overlay {
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
background: rgba(0,0,0,.72); color: #e5e7eb; padding: 24px; gap: 10px;
}
.lw-overlay i { font-size: 34px; opacity: .5; display: block; margin-bottom: 10px; }
.lw-overlay .sub { font-size: 13px; opacity: .65; margin-top: 6px; }
.lw-head { display: flex; gap: 14px; align-items: flex-start; margin-top: 18px; flex-wrap: wrap; }
.lw-head h1 { font-size: 20px; font-weight: 700; margin: 0 0 6px; }
.lw-meta { font-size: 13px; opacity: .65; display: flex; gap: 14px; flex-wrap: wrap; }
.lw-desc { margin-top: 14px; font-size: 14px; line-height: 1.65; opacity: .85; white-space: pre-wrap; }
.lw-spin { width: 28px; height: 28px; border: 3px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: lwSpin .8s linear infinite; }
@keyframes lwSpin { to { transform: rotate(360deg); } }
</style>
@endsection
@section('content')
<div class="lw-wrap">
@php
$isEnded = $stream->status === \App\Models\LiveStream::STATUS_ENDED;
$isLive = $stream->status === \App\Models\LiveStream::STATUS_LIVE;
@endphp
<div class="lw-stage">
<video id="lwVideo" autoplay playsinline controls></video>
<div class="lw-badge" id="lwBadge"><span class="dot"></span><span id="lwBadgeText">LIVE</span></div>
<div class="lw-mode" id="lwMode" style="display:none;"></div>
{{-- Ended stream: show "Stream ended" + duration right from the
server; if a recording exists, surface a Watch Replay CTA. --}}
@if ($isEnded)
<div class="lw-overlay" id="lwOverlay" style="background: rgba(0,0,0,.86);">
<div>
<i class="bi bi-stop-circle" aria-hidden="true" style="opacity:.85;color:#f87171;"></i>
<div style="font-size:18px;font-weight:600;margin-bottom:4px;">Stream ended</div>
@if ($stream->started_at && $stream->ended_at)
<div class="sub">
{{ $stream->ended_at->diffForHumans($stream->started_at, ['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) }}
· ended {{ $stream->ended_at->diffForHumans() }}
</div>
@endif
@if ($stream->video_id)
<a href="{{ route('videos.show', ['video' => $stream->video->getRouteKey()]) }}"
class="action-btn action-btn-primary"
style="margin-top:16px;">
<i class="bi bi-play-fill" aria-hidden="true"></i>
<span>Watch replay</span>
</a>
@endif
</div>
</div>
@else
<div class="lw-overlay" id="lwOverlay">
<div>
<div class="lw-spin" style="margin:0 auto 12px;"></div>
<div id="lwOverlayText">Connecting…</div>
<div class="sub" id="lwOverlaySub"></div>
</div>
</div>
@endif
</div>
<div class="lw-head">
<div style="flex:1;min-width:240px;">
<h1>{{ $stream->title }}</h1>
<div class="lw-meta">
{{-- Channel name is now a real link to the uploader's channel. --}}
@if ($stream->user)
<a href="{{ route('channel', $stream->user->channel) }}"
style="color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:4px;">
<i class="bi bi-person-circle" aria-hidden="true"></i>
<span style="color:var(--text-primary);">{{ $stream->user->name }}</span>
</a>
@endif
<span id="lwViewers"><i class="bi bi-eye" aria-hidden="true"></i> {{ $stream->current_viewers }} watching</span>
@if ($stream->started_at)
<span><i class="bi bi-clock" aria-hidden="true"></i> started {{ $stream->started_at->diffForHumans() }}</span>
@endif
</div>
@if ($stream->description)
<div class="lw-desc">{{ $stream->description }}</div>
@endif
</div>
@if ($isOwner)
<a class="action-btn" href="{{ route('live.broadcast', $stream) }}"><i class="bi bi-broadcast" aria-hidden="true"></i> <span>Broadcast controls</span></a>
@endif
</div>
{{-- Viewer actions row — mirrors the watch page's action bar on a smaller
canvas. Share works for everyone; Subscribe is auth-gated with the
Phase-7 ?intended= bounce; the owner never sees Subscribe. --}}
@if ($stream->user && (! Auth::check() || Auth::id() !== $stream->user_id))
<div class="lw-actions" style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;">
@auth
@php $isSub = Auth::user()->isSubscribedTo($stream->user); @endphp
<button type="button"
class="action-btn {{ $isSub ? '' : 'action-btn-primary' }}"
onclick="(function(){
const url = {{ Js::from(route('channel.subscribe', $stream->user_id)) }};
const csrf = document.querySelector('meta[name=\'csrf-token\']')?.content || '';
fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf, 'Accept':'application/json' }})
.then(r=>r.json()).then(d=>{
if (d.subscribed !== undefined) {
event.target.closest('button').classList.toggle('action-btn-primary', !d.subscribed);
event.target.closest('button').querySelector('span').textContent = d.subscribed ? 'Subscribed' : 'Subscribe';
}
}).catch(()=>{});
})()">
<i class="bi {{ $isSub ? 'bi-bell-fill' : 'bi-bell' }}" aria-hidden="true"></i>
<span>{{ $isSub ? 'Subscribed' : 'Subscribe' }}</span>
</button>
@else
<button type="button" class="action-btn action-btn-primary"
onclick="if(typeof showToast==='function')showToast('Sign in to subscribe','info');setTimeout(()=>{location.href='{{ route('login') }}?intended='+encodeURIComponent(location.pathname+location.search);},600);">
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
</button>
@endauth
<button type="button" class="action-btn"
onclick="(function(){
const url = window.location.href;
if (navigator.share) { navigator.share({ title: {{ Js::from($stream->title) }}, url: url }).catch(()=>{}); return; }
navigator.clipboard?.writeText(url).then(()=>{ if(typeof showToast==='function')showToast('Link copied','success'); });
})()">
<i class="bi bi-share" aria-hidden="true"></i>
<span>Share</span>
</button>
</div>
@endif
{{-- ── Live chat panel ─────────────────────────────── --}}
@if ($isLive)
<div class="lw-chat" id="lwChat"
data-poll-url="{{ route('live.chat.poll', $stream) }}"
data-send-url="{{ route('live.chat.send', $stream) }}"
data-signed-in="{{ Auth::check() ? '1' : '0' }}"
data-login-url="{{ route('login') }}?intended={{ urlencode('/live/' . $stream->public_id) }}">
<div class="lw-chat-head">
<i class="bi bi-chat-dots-fill" aria-hidden="true" style="color:#ef4444;"></i>
<span>Live chat</span>
</div>
<div class="lw-chat-messages" id="lwChatMessages" aria-live="polite" aria-atomic="false">
<div style="color:var(--text-secondary);font-size:13px;text-align:center;padding:16px;">Say hi 👋</div>
</div>
<form class="lw-chat-form" id="lwChatForm" onsubmit="return lwChatSubmit(event);">
@csrf
<input type="text" id="lwChatInput" maxlength="400"
placeholder="{{ Auth::check() ? 'Say something…' : 'Sign in to chat' }}"
{{ Auth::check() ? '' : 'disabled' }}
aria-label="Chat message">
<button type="submit" aria-label="Send"
{{ Auth::check() ? '' : 'disabled' }}>
<i class="bi bi-send-fill" aria-hidden="true"></i>
</button>
</form>
</div>
<style>
.lw-chat {
margin-top: 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
display: flex; flex-direction: column;
max-height: 420px;
}
.lw-chat-head {
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
font-weight: 600; font-size: 14px;
display: flex; align-items: center; gap: 8px;
}
.lw-chat-messages {
flex: 1; overflow-y: auto; padding: 10px 14px;
display: flex; flex-direction: column; gap: 6px;
}
.lw-chat-msg {
font-size: 13px; line-height: 1.4;
display: flex; gap: 6px; align-items: flex-start;
padding: 4px 0;
}
.lw-chat-msg .n { font-weight: 600; color: #93c5fd; }
.lw-chat-msg .b { color: var(--text-primary); word-break: break-word; min-width: 0; }
.lw-chat-form {
display: flex; gap: 8px; padding: 10px 12px;
border-top: 1px solid var(--border-color);
}
.lw-chat-form input {
flex: 1; background: var(--bg-dark);
border: 1px solid var(--border-color); border-radius: 20px;
padding: 8px 14px; color: var(--text-primary); font-size: 13px;
}
.lw-chat-form input:focus { outline: none; border-color: #ef4444; }
.lw-chat-form button {
width: 36px; height: 36px; border-radius: 50%;
background: #ef4444; color: #fff; border: none;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; flex: 0 0 auto;
}
.lw-chat-form button:disabled {
background: var(--border-color); color: var(--text-secondary); cursor: not-allowed;
}
</style>
<script>
(function () {
const panel = document.getElementById('lwChat');
if (!panel) return;
const list = document.getElementById('lwChatMessages');
const input = document.getElementById('lwChatInput');
const form = document.getElementById('lwChatForm');
const pollU = panel.dataset.pollUrl;
const sendU = panel.dataset.sendUrl;
const loginU = panel.dataset.loginUrl;
const authed = panel.dataset.signedIn === '1';
let since = 0, timer = null, firstPaint = true;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function append(msg) {
const row = document.createElement('div');
row.className = 'lw-chat-msg';
row.innerHTML = '<span class="n">' + escapeHtml(msg.name) + ':</span>'
+ ' <span class="b">' + escapeHtml(msg.body) + '</span>';
list.appendChild(row);
list.scrollTop = list.scrollHeight;
}
async function poll() {
try {
const r = await fetch(pollU + '?since=' + since, { credentials: 'same-origin', headers: {'Accept':'application/json'} });
if (!r.ok) return;
const d = await r.json();
if (d.messages && d.messages.length) {
if (firstPaint) { list.innerHTML = ''; firstPaint = false; }
d.messages.forEach(m => { append(m); if (m.id > since) since = m.id; });
}
} catch (e) {}
}
window.lwChatSubmit = function (ev) {
ev.preventDefault();
if (!authed) { location.href = loginU; return false; }
const body = (input.value || '').trim();
if (!body) return false;
input.disabled = true;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch(sendU, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ body }),
}).then(r => r.json()).then(d => {
if (d.message) { append(d.message); since = Math.max(since, d.message.id); }
input.value = '';
}).catch(() => {
if (typeof showToast === 'function') showToast('Could not send message.', 'error');
}).finally(() => { input.disabled = false; input.focus(); });
return false;
};
poll();
timer = setInterval(poll, 3000);
window.addEventListener('beforeunload', () => clearInterval(timer));
})();
</script>
@endif
</div>
@endsection
@section('scripts')
@unless($isEnded)
<script>
(function () {
'use strict';
var CFG = {
whep: @json($whepUrl),
hls: @json($hlsUrl),
statusUrl: @json(route('live.status', $stream)),
iceServers: @json($iceServers),
isLive: @json($stream->isLive())
};
var video = document.getElementById('lwVideo');
var overlay = document.getElementById('lwOverlay');
var overlayText = document.getElementById('lwOverlayText');
var overlaySub = document.getElementById('lwOverlaySub');
var badge = document.getElementById('lwBadge');
var badgeText = document.getElementById('lwBadgeText');
var modeChip = document.getElementById('lwMode');
var pc = null, hls = null, started = false;
function showOverlay(text, sub, spinning) {
overlay.style.display = 'grid';
overlayText.textContent = text;
overlaySub.textContent = sub || '';
overlay.querySelector('.lw-spin').style.display = spinning === false ? 'none' : 'block';
}
function hideOverlay() { overlay.style.display = 'none'; }
function setMode(label) {
modeChip.textContent = label;
modeChip.style.display = 'block';
}
function setOffline() {
badge.classList.add('offline');
badgeText.textContent = 'OFFLINE';
showOverlay('This stream has ended', 'The recording may appear here shortly.', false);
teardown();
}
function teardown() {
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
if (hls) { try { hls.destroy(); } catch (e) {} hls = null; }
}
/* ---------------- WHEP: sub-second path ---------------- */
// Tried first because it is the lowest latency. If anything about it fails
// — no UDP path, a restrictive network, an old browser — we fall through
// to LL-HLS rather than showing the viewer an error.
async function tryWhep() {
if (!window.RTCPeerConnection) { return false; }
try {
pc = new RTCPeerConnection({ iceServers: CFG.iceServers || [], bundlePolicy: 'max-bundle' });
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
var streamIn = new MediaStream();
pc.addEventListener('track', function (ev) {
streamIn.addTrack(ev.track);
video.srcObject = streamIn;
});
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await gathered(pc);
var res = await fetch(CFG.whep, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
if (!res.ok) { throw new Error('whep ' + res.status); }
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
return await new Promise(function (resolve) {
var settled = false;
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
pc.addEventListener('connectionstatechange', function () {
if (pc.connectionState === 'connected') { decide(true); }
if (pc.connectionState === 'failed') { decide(false); }
});
// Don't let a half-open connection stall the viewer forever.
setTimeout(function () { decide(pc && pc.connectionState === 'connected'); }, 6000);
});
} catch (e) {
return false;
}
}
function gathered(pc) {
if (pc.iceGatheringState === 'complete') { return Promise.resolve(); }
return new Promise(function (resolve) {
var done = false;
function finish() { if (!done) { done = true; resolve(); } }
pc.addEventListener('icegatheringstatechange', function () {
if (pc.iceGatheringState === 'complete') { finish(); }
});
setTimeout(finish, 3000);
});
}
/* ---------------- LL-HLS: the path that always works ---------------- */
function loadHlsLibrary() {
return new Promise(function (resolve, reject) {
if (window.Hls) { return resolve(); }
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
async function tryHls() {
// Safari plays HLS natively and does it better than hls.js does.
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = CFG.hls;
return true;
}
try { await loadHlsLibrary(); } catch (e) { return false; }
if (!window.Hls || !window.Hls.isSupported()) { return false; }
return await new Promise(function (resolve) {
hls = new Hls({ lowLatencyMode: true, backBufferLength: 30, liveSyncDurationCount: 2 });
var settled = false;
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(function () {}); decide(true); });
hls.on(Hls.Events.ERROR, function (_e, data) {
if (data && data.fatal) { decide(false); }
});
hls.loadSource(CFG.hls);
hls.attachMedia(video);
setTimeout(function () { decide(false); }, 12000);
});
}
/* ---------------- Orchestration ---------------- */
async function connect() {
if (started) { return; }
started = true;
showOverlay('Connecting…', 'Finding the fastest route');
if (await tryWhep()) {
setMode('Ultra-low latency');
hideOverlay();
video.play().catch(function () {});
return;
}
teardown();
showOverlay('Connecting…', 'Using the standard route');
if (await tryHls()) {
setMode('Low latency');
hideOverlay();
return;
}
started = false;
showOverlay('Could not start playback', 'The stream may still be starting up. Retrying…', false);
setTimeout(function () { if (!started) { connect(); } }, 5000);
}
/* ---------------- Live state ---------------- */
// Polling is a deliberate, cheap fallback: this platform has no realtime
// channel of its own, and one small request every 10s per viewer is far
// less costly than a socket layer built only for this page.
async function pollStatus() {
try {
var res = await fetch(CFG.statusUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } });
if (!res.ok) { return; }
var data = await res.json();
document.getElementById('lwViewers').innerHTML = '<i class="bi bi-eye"></i> ' + data.viewers + ' watching';
if (data.live && !started) { connect(); }
if (!data.live && data.status === 'ended') { setOffline(); }
} catch (e) { /* transient */ }
}
if (CFG.isLive) {
connect();
} else {
showOverlay('Waiting for the broadcast to start', 'This page will begin automatically.', true);
}
setInterval(pollStatus, 10000);
window.addEventListener('pagehide', teardown);
})();
</script>
@endunless
@endsection
+31 -9
View File
@@ -37,14 +37,25 @@ a.pag-item:hover {
@endonce
<nav class="pag-nav" aria-label="Pagination">
{{-- Prev --}}
{{-- First --}}
@if ($paginator->onFirstPage())
<span class="pag-item disabled" aria-disabled="true">
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
<span class="pag-item disabled" aria-disabled="true" title="First page">
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
</span>
@else
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous">
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
<a class="pag-item" href="{{ $paginator->url(1) }}" aria-label="First page" title="First page">
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
</a>
@endif
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="pag-item disabled" aria-disabled="true" title="Previous page">
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
</span>
@else
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous page" title="Previous page">
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
</a>
@endif
@@ -66,12 +77,23 @@ a.pag-item:hover {
{{-- Next --}}
@if ($paginator->hasMorePages())
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next">
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next page" title="Next page">
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
</a>
@else
<span class="pag-item disabled" aria-disabled="true">
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
<span class="pag-item disabled" aria-disabled="true" title="Next page">
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
</span>
@endif
{{-- Last --}}
@if ($paginator->hasMorePages() && $paginator->lastPage() > $paginator->currentPage())
<a class="pag-item" href="{{ $paginator->url($paginator->lastPage()) }}" aria-label="Last page" title="Last page ({{ $paginator->lastPage() }})">
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
</a>
@else
<span class="pag-item disabled" aria-disabled="true" title="Last page">
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
</span>
@endif
+238
View File
@@ -0,0 +1,238 @@
@extends('layouts.app')
@section('title', 'Studio | ' . config('app.name'))
@section('content')
<style>
.st-head { padding: 24px 32px 8px; }
.st-head h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }
.st-head h1 i { color: #ef4444; }
.st-head p { margin: 0 0 14px; color: var(--text-secondary); font-size: 13px; }
.st-stat-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 10px; margin: 12px 0 20px;
}
.st-stat-card {
background: var(--bg-secondary);
border-radius: 10px; padding: 12px 14px;
display: flex; flex-direction: column; gap: 2px;
}
.st-stat-card .k { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); }
.st-stat-card .v { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.st-filter-bar {
display: flex; gap: 8px; overflow-x: auto;
padding: 4px 0; scrollbar-width: none;
margin-bottom: 14px;
}
.st-filter-bar::-webkit-scrollbar { display: none; }
.st-filter-pill {
padding: 6px 12px; border-radius: 999px;
background: var(--bg-secondary); color: var(--text-primary);
font-size: 12px; font-weight: 600; text-decoration: none;
white-space: nowrap; border: 1px solid transparent;
}
.st-filter-pill:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
.st-filter-pill.active { background: #ef4444; color: #fff; }
.st-toolbar {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px; margin-bottom: 8px;
background: var(--bg-secondary); border-radius: 10px;
}
.st-toolbar.hidden { display: none; }
.st-toolbar-count { font-size: 13px; color: var(--text-secondary); }
.st-toolbar-actions { display: flex; gap: 8px; align-items: center; }
.st-visibility-select {
background: var(--bg-dark); color: var(--text-primary);
border: 1px solid var(--border-color); border-radius: 6px;
padding: 6px 10px; font-size: 13px;
}
.st-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.st-table th, .st-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border-color);
text-align: left;
}
.st-table th { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); font-weight: 600; }
.st-table tbody tr:hover { background: rgba(255,255,255,.03); }
.st-table .st-check { width: 32px; }
.st-table .st-thumb { width: 76px; height: 44px; object-fit: cover; border-radius: 6px; }
.st-table .st-title-cell { display: flex; gap: 10px; align-items: flex-start; max-width: 320px; }
.st-table .st-title-name {
font-weight: 500; color: var(--text-primary);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.st-table .st-title-desc {
font-size: 11px; color: var(--text-secondary); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.st-badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600;
}
.st-badge.pub { background: rgba(34,197,94,.15); color: #4ade80; }
.st-badge.unl { background: rgba(234,179,8,.15); color: #fde047; }
.st-badge.priv { background: rgba(100,116,139,.2); color: #cbd5e1; }
.st-status { font-size: 11px; color: var(--text-secondary); }
.st-row-link { color: var(--text-primary); text-decoration: none; }
.st-row-link:hover { color: #ef4444; }
</style>
<div class="st-head">
<h1><i class="bi bi-sliders" aria-hidden="true"></i> Studio</h1>
<p>Manage every video you've uploaded. Select rows for bulk visibility changes.</p>
<div class="st-stat-cards">
<div class="st-stat-card"><span class="k">Total videos</span> <span class="v">{{ number_format($totals['total']) }}</span></div>
<div class="st-stat-card"><span class="k">Public</span> <span class="v">{{ number_format($totals['public']) }}</span></div>
<div class="st-stat-card"><span class="k">Unlisted</span> <span class="v">{{ number_format($totals['unlisted']) }}</span></div>
<div class="st-stat-card"><span class="k">Private</span> <span class="v">{{ number_format($totals['private']) }}</span></div>
<div class="st-stat-card"><span class="k">Processing</span> <span class="v">{{ number_format($totals['processing']) }}</span></div>
<div class="st-stat-card"><span class="k">Failed</span> <span class="v">{{ number_format($totals['failed']) }}</span></div>
<div class="st-stat-card"><span class="k">Total views</span> <span class="v">{{ number_format($totals['total_views']) }}</span></div>
</div>
<div class="st-filter-bar" role="navigation" aria-label="Filter videos">
@foreach (['all'=>'All', 'public'=>'Public', 'unlisted'=>'Unlisted', 'private'=>'Private', 'shorts'=>'Shorts', 'draft'=>'Processing / Failed'] as $k => $label)
<a href="{{ route('studio', ['filter' => $k]) }}"
class="st-filter-pill {{ $filter === $k ? 'active' : '' }}"
@if($filter === $k) aria-current="page" @endif>{{ $label }}</a>
@endforeach
</div>
</div>
<div style="padding: 0 32px 32px;">
<div class="st-toolbar hidden" id="stToolbar" aria-live="polite">
<span class="st-toolbar-count"><span id="stSelectedCount">0</span> selected</span>
<div class="st-toolbar-actions">
<select class="st-visibility-select" id="stBulkVisibility">
<option value="">Change visibility…</option>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Private</option>
</select>
<button type="button" class="action-btn" onclick="stApplyBulkVisibility()">Apply</button>
<button type="button" class="action-btn action-btn-danger" onclick="stApplyBulkDelete()">Delete selected</button>
</div>
</div>
@if ($videos->isEmpty())
<div class="yt-empty" style="text-align:center; padding:60px 20px;">
<i class="bi bi-camera-video" style="font-size:48px;color:var(--text-secondary);display:block;margin-bottom:12px;" aria-hidden="true"></i>
<h2>No videos in this filter</h2>
<p style="color:var(--text-secondary);">Switch filters above or upload a new video from the sidebar.</p>
</div>
@else
<table class="st-table">
<thead>
<tr>
<th class="st-check">
<input type="checkbox" id="stSelectAll" onclick="stToggleAll(this)" aria-label="Select all rows">
</th>
<th>Video</th>
<th>Visibility</th>
<th>Views</th>
<th>Likes</th>
<th>Comments</th>
<th>Uploaded</th>
</tr>
</thead>
<tbody>
@foreach ($videos as $v)
<tr>
<td class="st-check"><input type="checkbox" class="st-row-check" value="{{ $v->id }}" onclick="stUpdateToolbar()" aria-label="Select {{ $v->title }}"></td>
<td>
<div class="st-title-cell">
@if ($v->thumbnail)
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" class="st-thumb" alt="" loading="lazy">
@else
<div class="st-thumb" style="background:#1a1a1a;"></div>
@endif
<div style="min-width:0;">
<a class="st-row-link" href="{{ route('videos.show', $v) }}" target="_blank">
<div class="st-title-name" title="{{ $v->title }}">{{ $v->title }}</div>
</a>
@if ($v->description)
@php $plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $v->description))); @endphp
<div class="st-title-desc" title="{{ $plain }}">{{ Str::limit($plain, 70) }}</div>
@endif
</div>
</div>
</td>
<td>
@switch($v->visibility)
@case('public') <span class="st-badge pub">Public</span> @break
@case('unlisted') <span class="st-badge unl">Unlisted</span> @break
@case('private') <span class="st-badge priv">Private</span> @break
@endswitch
@if ($v->status !== 'ready')
<div class="st-status">{{ ucfirst($v->status) }}</div>
@endif
</td>
<td>{{ number_format($v->view_count) }}</td>
<td>{{ number_format($v->likes()->count()) }}</td>
<td>{{ number_format($v->comments()->count()) }}</td>
<td>{{ $v->created_at->format('M j, Y') }}</td>
</tr>
@endforeach
</tbody>
</table>
<div style="margin-top: 16px;">{{ $videos->links() }}</div>
@endif
</div>
<script>
function stCheckedIds() {
return Array.from(document.querySelectorAll('.st-row-check:checked')).map(c => parseInt(c.value, 10));
}
function stUpdateToolbar() {
const n = stCheckedIds().length;
const t = document.getElementById('stToolbar');
document.getElementById('stSelectedCount').textContent = n;
t.classList.toggle('hidden', n === 0);
}
function stToggleAll(cb) {
document.querySelectorAll('.st-row-check').forEach(c => { c.checked = cb.checked; });
stUpdateToolbar();
}
async function stApplyBulkVisibility() {
const ids = stCheckedIds();
const sel = document.getElementById('stBulkVisibility');
if (!ids.length || !sel.value) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
try {
const r = await fetch('{{ route('studio.bulkVisibility') }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ video_ids: ids, visibility: sel.value }),
});
if (!r.ok) throw new Error('bad');
const d = await r.json();
if (typeof showToast === 'function') showToast('Updated ' + d.updated + ' video(s).', 'success');
setTimeout(() => location.reload(), 400);
} catch { if (typeof showToast === 'function') showToast('Could not update videos.', 'error'); }
}
async function stApplyBulkDelete() {
const ids = stCheckedIds();
if (!ids.length) return;
if (!window.confirm('Delete ' + ids.length + ' video(s)? This cannot be undone.')) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
try {
const r = await fetch('{{ route('studio.bulkDelete') }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ video_ids: ids }),
});
if (!r.ok) throw new Error('bad');
const d = await r.json();
if (typeof showToast === 'function') showToast('Deleted ' + d.deleted + ' video(s).', 'success');
setTimeout(() => location.reload(), 400);
} catch { if (typeof showToast === 'function') showToast('Could not delete videos.', 'error'); }
}
</script>
@endsection
+117 -22
View File
@@ -122,7 +122,8 @@ $headerSocialMap = [
<div class="ch-meta-row">
<span class="ch-meta-item">
<i class="bi bi-camera-video-fill"></i>
{{ number_format($videos->count() + $shorts->count()) }} videos
@php $vidTotal = $videos->count() + $shorts->count(); @endphp
{{ number_format($vidTotal) }} {{ Str::plural('video', $vidTotal) }}
</span>
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item">
@@ -136,7 +137,14 @@ $headerSocialMap = [
<i class="bi bi-people-fill"></i>
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
</span>
@if($user->location)
{{-- Location, age and gender used to render in the public
header meta row — visible to every visitor. That's a
privacy leak the user flagged; those three now show
only to the owner here, and only in the About tab for
everyone (via the existing ch-about-card below). Join
month stays public because it's channel metadata, not
personal data. --}}
@if($isOwner && $user->location)
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item">
<i class="bi bi-geo-alt-fill"></i>
@@ -148,7 +156,7 @@ $headerSocialMap = [
<i class="bi bi-calendar3"></i>
Joined {{ $user->created_at->format('M Y') }}
</span>
@if($user->birthday)
@if($isOwner && $user->birthday)
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item">
@@ -156,13 +164,13 @@ $headerSocialMap = [
{{ $age }} yrs
</span>
@endif
@if($user->gender === 'male')
@if($isOwner && $user->gender === 'male')
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#3b82f6;font-weight:700;">
<i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i>
Male
</span>
@elseif($user->gender === 'female')
@elseif($isOwner && $user->gender === 'female')
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#ec4899;font-weight:700;">
<i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i>
@@ -181,18 +189,25 @@ $headerSocialMap = [
</div>
@endif
{{-- Horoscope + Compatibility strip & Social buttons side by side --}}
@if($horoscope || $socialLinks->isNotEmpty())
{{-- Horoscope + Compatibility strip & Social buttons side by side.
The horoscope portion is sign-derived from the birthday the
owner entered; it only renders to the owner (or to a visitor
who's seeing the compatibility match-up). Pure social buttons
still show to visitors. --}}
@php
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
$showHoroStrip = $horoscope && ($isOwner || $showCompat);
@endphp
@if($showHoroStrip || $socialLinks->isNotEmpty())
@php
if ($showCompat) {
$compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444');
$compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo')));
}
@endphp
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;">
@if($horoscope)
@if($showHoroStrip)
<div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;"
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
<div class="ch-horo-sign-part">
@@ -361,10 +376,15 @@ $headerSocialMap = [
<span class="ch-tab-badge">{{ $videos->count() }}</span>
@endif
</button>
@if($shorts->count() > 0)
{{-- Always show the Shorts tab for the owner so the "My Shorts"
sidebar link always lands somewhere; for visitors only when
there's content to show. --}}
@if($isOwner || $shorts->count() > 0)
<button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)">
Shorts
@if($shorts->count() > 0)
<span class="ch-tab-badge">{{ $shorts->count() }}</span>
@endif
</button>
@endif
@if(($playlists && $playlists->count() > 0) || $isOwner)
@@ -394,9 +414,9 @@ $headerSocialMap = [
{{-- ── LEFT: composer + feed ── --}}
<div>
{{-- Composer (owner only) --}}
{{-- Composer (owner only, hidden when previewing as a visitor) --}}
@auth
@if(Auth::id() === $user->id)
@if(Auth::id() === $user->id && !$preview)
<div class="ch-composer">
<form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm">
@csrf
@@ -528,7 +548,7 @@ $headerSocialMap = [
<div class="ch-post-video-info">
<div class="ch-post-video-title">{{ $v->title }}</div>
<div class="ch-post-video-meta">
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) }}</span>
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ $v->view_count > 0 ? \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) : '0' }}</span>
@if($v->duration)<span class="ch-post-video-badge"><i class="bi bi-clock"></i> {{ $v->formatted_duration }}</span>@endif
@if($v->is_shorts)<span class="ch-post-video-badge" style="color:#e33;"><i class="bi bi-phone"></i> Short</span>@endif
</div>
@@ -663,24 +683,74 @@ $headerSocialMap = [
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
<span class="ch-sort-label">Sort</span>
<div class="ch-sort-pills">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) }}"
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) }}"
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) }}"
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a>
</div>
</div>
</div>
<div class="yt-video-grid" id="chVidGrid">
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@include('components.video-card', ['video' => $video, 'hideChannelInfo' => true])
@endforeach
<div class="ch-vid-no-results" id="chVidNoResults">
<i class="bi bi-search"></i>
<span>No videos match your search</span>
</div>
</div>
{{-- Cursor-paginated Load More. The sentinel sits below the grid and
fetches the next slice when scrolled into view. A manual Load More
button is the fallback for browsers without IntersectionObserver. --}}
<div id="chVidSentinel"
data-username="{{ $user->username }}"
data-more-url="{{ route('channel.videos.more', ['username' => $user->username]) }}"
style="height:1px;"></div>
<div id="chVidLoadMoreWrap" style="display:none; text-align:center; padding:16px 0;">
<button type="button" id="chVidLoadMoreBtn" class="action-btn">Load more</button>
</div>
<script>
(function () {
const grid = document.getElementById('chVidGrid');
const sentinel = document.getElementById('chVidSentinel');
const btnWrap = document.getElementById('chVidLoadMoreWrap');
const btn = document.getElementById('chVidLoadMoreBtn');
if (!grid || !sentinel) return;
let cursor = '', busy = false, done = false;
const url = sentinel.dataset.moreUrl;
async function loadMore() {
if (busy || done) return;
busy = true;
try {
const u = new URL(url, window.location.origin);
if (cursor) u.searchParams.set('cursor', cursor);
const r = await fetch(u.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!r.ok) throw new Error('bad');
const d = await r.json();
if (d.items) grid.insertAdjacentHTML('beforeend', d.items);
cursor = d.next_cursor || '';
done = !d.has_more;
if (done) { sentinel.style.display = 'none'; btnWrap.style.display = 'none'; }
} catch (e) {
btnWrap.style.display = 'block';
} finally { busy = false; }
}
btn?.addEventListener('click', loadMore);
if ('IntersectionObserver' in window) {
new IntersectionObserver(entries => {
if (entries.some(e => e.isIntersecting)) loadMore();
}, { rootMargin: '400px 0px' }).observe(sentinel);
} else {
btnWrap.style.display = 'block';
}
})();
</script>
@else
<div class="ch-empty">
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
@@ -699,8 +769,15 @@ $headerSocialMap = [
</div>
{{-- ─────────────────────── SHORTS TAB ──────────────── --}}
@if($shorts->count() > 0)
@if($isOwner || $shorts->count() > 0)
<div class="ch-tab-content" id="tab-shorts">
@if($shorts->isEmpty())
<div class="ch-empty">
<div class="ch-empty-icon"><i class="bi bi-phone"></i></div>
<h3>No shorts yet</h3>
<p>Vertical videos under 60 seconds appear here.</p>
</div>
@else
<div class="ch-shorts-grid">
@foreach($shorts as $short)
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
@@ -722,6 +799,7 @@ $headerSocialMap = [
</a>
@endforeach
</div>
@endif
</div>
@endif
@@ -753,8 +831,8 @@ $headerSocialMap = [
@endif
<div class="ch-playlist-overlay"></div>
<div class="ch-playlist-count">
<i class="bi bi-play-fill"></i>
{{ $playlist->video_count }} videos
<i class="bi bi-play-fill" aria-hidden="true"></i>
{{ $playlist->video_count }} {{ Str::plural('video', $playlist->video_count) }}
</div>
</div>
<div class="ch-playlist-name">{{ $playlist->name }}</div>
@@ -768,7 +846,13 @@ $headerSocialMap = [
@else
<i class="bi bi-globe"></i> Public
@endif
· <i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
{{-- Hide the view-count row entirely when the playlist has
never been viewed. Previously showed "· eye 0.0" because
Number::abbreviate(0, precision: 1) prints "0.0". --}}
@if($playlist->view_count > 0)
· <i class="bi bi-eye" aria-hidden="true"></i>
{{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
@endif
</div>
</a>
@endforeach
@@ -1057,7 +1141,7 @@ $headerSocialMap = [
<div class="ch-about-card-title"><i class="bi bi-bar-chart-fill"></i> Stats</div>
<div class="ch-stat-cards">
<div class="ch-stat-card">
<div class="ch-stat-card-val">{{ \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) }}</div>
<div class="ch-stat-card-val">{{ $totalViews > 0 ? \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) : '0' }}</div>
<div class="ch-stat-card-key">Total Views</div>
</div>
<div class="ch-stat-card">
@@ -1171,7 +1255,13 @@ if ($oldLinks) {
<div class="ch-settings-field">
<label class="ch-settings-label">Gender</label>
<select name="gender" class="ch-settings-input" style="cursor:pointer;">
<option value="">Prefer not to say</option>
{{-- Empty value stands for "not chosen yet"
— different from the explicit "prefer not
to say" option. Previously both rendered
the same label, so users saw two
identical choices and the stats grouping
ambiguously bucketed one of them. --}}
<option value="">Select…</option>
<option value="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option>
<option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</option>
<option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option>
@@ -1338,9 +1428,14 @@ if ($oldLinks) {
@php
$notifPrefs = $user->notification_preferences ?? [];
$notifDefs = \App\Models\User::notifDefaults();
// function_exists guard so a second render in the same PHP
// process (feature tests fire two requests in one process)
// doesn't fatal with "Cannot redeclare notifVal()".
if (!function_exists('notifVal')) {
function notifVal(array $prefs, array $defs, string $key): bool {
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
}
}
@endphp
<div class="ch-settings-card" id="notifPrefsCard">
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', 'Watch History | TAKEONE')
@section('title', 'Watch History | ' . config('app.name'))
@section('extra_styles')
<style>
+3 -2
View File
@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', 'Liked Videos | TAKEONE')
@section('title', 'Liked Videos | ' . config('app.name'))
@section('extra_styles')
<style>
@@ -146,7 +146,8 @@
@endforeach
</div>
<div class="mt-4">{{ $videos->onEachSide(1)->links() }}</div>
{{-- Cursor paginator has no "N pages" concept — just prev/next. --}}
<div class="mt-4">{{ $videos->links() }}</div>
@endif
@endsection
@@ -342,10 +342,17 @@
/* ══════════════════════════════════════════════════
VIDEO GRID
══════════════════════════════════════════════════ */
.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } }
@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } }
/* Fluid grid: as many 260-px columns as fit, each one stretching to fill
the leftover space. Removes the three fixed breakpoints that produced
different card widths at 1101px vs 1500px, and `min-width:0` on every
grid child so a long unbroken title (CJK, "SPARTAWORRIOR") can't widen
its track past the configured 1fr. */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px;
}
.yt-video-grid > * { min-width: 0; }
/* ══════════════════════════════════════════════════
SHORTS GRID
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', 'Profile | TAKEONE')
@section('title', 'Profile | ' . config('app.name'))
@section('extra_styles')
<style>
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', 'Settings | TAKEONE')
@section('title', 'Settings | ' . config('app.name'))
@section('extra_styles')
<style>
+203 -9
View File
@@ -42,6 +42,34 @@
}
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
/* Channel results — card list sized to sit comfortably above the video
grid. Avatar is a fixed 56px circle; name + handle sit to the right. */
.search-channels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 8px;
}
.search-channel-card {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 12px;
text-decoration: none;
color: var(--text-primary);
transition: background .15s ease;
}
.search-channel-card:hover { background: rgba(255,255,255,.08); }
.search-channel-card img,
.search-channel-ph {
width: 56px; height: 56px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
background: #333; display: flex; align-items: center; justify-content: center;
font-size: 22px; color: var(--text-secondary);
}
.search-channel-text { min-width: 0; }
.search-channel-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-channel-handle { font-size: 13px; color: var(--text-secondary); }
.yt-empty { text-align: center; padding: 80px 20px; }
/* Language chips */
@@ -59,21 +87,34 @@
<div class="yt-filter-bar-wrap">
<button type="button" class="yt-filter-nav left" aria-label="Scroll left" onclick="scrollFilterBar(-1)"><i class="bi bi-chevron-left"></i></button>
<button type="button" class="yt-filter-nav right" aria-label="Scroll right" onclick="scrollFilterBar(1)"><i class="bi bi-chevron-right"></i></button>
<div class="yt-filter-bar">
<div class="yt-filter-bar" role="navigation" aria-label="Browse filters">
{{-- aria-current="page" on the active chip so assistive tech can read
the current filter. "New to You" was renamed to "Recent" — it was
just the latest filter, not a personalised recommendation, so the
old label overpromised. --}}
@php
$isAll = $activeFilter === 'all';
$isTrending = request()->is('trending');
$isMusic = $activeFilter === 'music';
$isMatch = $activeFilter === 'match';
$isShorts = request()->is('shorts');
$isPlaylists = $activeFilter === 'playlists';
$isRecent = $activeFilter === 'latest';
@endphp
<a href="{{ route('videos.index') }}"
class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a>
class="yt-chip {{ $isAll ? 'active' : '' }}" @if($isAll) aria-current="page"@endif>All</a>
<a href="{{ route('videos.trending') }}"
class="yt-chip {{ request()->is('trending') ? 'active' : '' }}">Trending</a>
class="yt-chip {{ $isTrending ? 'active' : '' }}" @if($isTrending) aria-current="page"@endif>Trending</a>
<a href="{{ route('videos.index', ['filter' => 'music']) }}"
class="yt-chip {{ $activeFilter === 'music' ? 'active' : '' }}">Music</a>
class="yt-chip {{ $isMusic ? 'active' : '' }}" @if($isMusic) aria-current="page"@endif>Music</a>
<a href="{{ route('videos.index', ['filter' => 'match']) }}"
class="yt-chip {{ $activeFilter === 'match' ? 'active' : '' }}">Sports</a>
class="yt-chip {{ $isMatch ? 'active' : '' }}" @if($isMatch) aria-current="page"@endif>Sports</a>
<a href="{{ route('videos.shorts') }}"
class="yt-chip {{ request()->is('shorts') ? 'active' : '' }}">Shorts</a>
class="yt-chip {{ $isShorts ? 'active' : '' }}" @if($isShorts) aria-current="page"@endif>Shorts</a>
<a href="{{ route('videos.index', ['filter' => 'playlists']) }}"
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a>
class="yt-chip {{ $isPlaylists ? 'active' : '' }}" @if($isPlaylists) aria-current="page"@endif>Playlists</a>
<a href="{{ route('videos.index', ['filter' => 'latest']) }}"
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a>
class="yt-chip {{ $isRecent ? 'active' : '' }}" @if($isRecent) aria-current="page"@endif>Recent</a>
@if(!empty($langChips ?? []))
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
@@ -81,6 +122,7 @@
@foreach($langChips as $chip)
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
@if($activeLang === $chip['code']) aria-current="page"@endif
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
<span class="fi fi-{{ $chip['flag'] }}"></span>
<span>{{ $chip['name'] }}</span>
@@ -100,7 +142,8 @@
@php
$searchShorts = $shorts ?? collect();
$searchPlaylists = $playlists ?? collect();
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count();
$searchChannels = $channels ?? collect();
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count() + $searchChannels->count();
@endphp
<div class="search-info">
@@ -108,11 +151,33 @@
<p>
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
@if($searchShorts->count()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
@if($searchChannels->count()) &nbsp;·&nbsp; {{ $searchChannels->count() }} channel{{ $searchChannels->count() !== 1 ? 's' : '' }} @endif
@if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
@if($totalResults === 0) No results found @endif
</p>
</div>
@if($searchChannels->count())
<div class="results-section-title"><i class="bi bi-person-circle"></i> Channels</div>
<div class="search-channels-grid">
@foreach($searchChannels as $channel)
<a href="{{ route('channel', $channel->channel) }}" class="search-channel-card">
@if($channel->avatar_url)
<img src="{{ $channel->avatar_url }}" alt="{{ $channel->name }}" width="56" height="56" loading="lazy">
@else
<div class="search-channel-ph"><i class="bi bi-person"></i></div>
@endif
<div class="search-channel-text">
<div class="search-channel-name">{{ $channel->name }}</div>
@if($channel->username)
<div class="search-channel-handle">@{{ $channel->username }}</div>
@endif
</div>
</a>
@endforeach
</div>
@endif
@if($videos->count())
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
<div class="yt-video-grid">
@@ -172,6 +237,60 @@
══════════════════════════════════════════════ --}}
@elseif(!isset($query) && (!isset($filter) || $filter === 'all'))
@if(!empty($homeShorts) && $homeShorts->count())
{{-- Horizontally-scrolling Shorts shelf. Sits above the main feed grid
so the user sees fresh short-form content without the feed being
drowned in 60-second portrait clips. --}}
<section class="home-shorts-shelf" aria-labelledby="home-shorts-heading">
<div class="home-shorts-shelf-head">
<h2 id="home-shorts-heading"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i> Shorts</h2>
<a href="{{ route('videos.shorts') }}" class="home-shorts-see-all">See all</a>
</div>
<div class="home-shorts-shelf-track">
@foreach($homeShorts as $s)
<a class="home-short-card" href="{{ route('videos.shorts.focused', $s) }}"
aria-label="Open short: {{ $s->title }}">
<img src="{{ $s->thumbnail ? route('media.thumbnail', $s->thumbnail) : 'https://picsum.photos/seed/'.$s->id.'/180/320' }}"
alt="" width="150" height="266" loading="lazy" decoding="async">
<div class="home-short-card-overlay">
<div class="home-short-card-title">{{ Str::limit($s->title, 40) }}</div>
<div class="home-short-card-views">{{ number_format($s->view_count) }} views</div>
</div>
</a>
@endforeach
</div>
</section>
<style>
.home-shorts-shelf { margin: 8px 0 24px; }
.home-shorts-shelf-head { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:12px; }
.home-shorts-shelf-head h2 { font-size:18px; font-weight:600; margin:0; display:flex; align-items:center; gap:8px; }
.home-shorts-shelf-head h2 i { color:#ef4444; }
.home-shorts-see-all { color: var(--text-secondary); font-size:13px; text-decoration:none; }
.home-shorts-see-all:hover { color: var(--text-primary); text-decoration: underline; }
.home-shorts-shelf-track {
display:flex; gap:12px; overflow-x:auto; scroll-snap-type: x proximity;
scrollbar-width: none; -ms-overflow-style: none;
padding-bottom: 4px;
}
.home-shorts-shelf-track::-webkit-scrollbar { display:none; }
.home-short-card {
flex: 0 0 150px; aspect-ratio: 9/16; border-radius: 10px;
overflow: hidden; position: relative; scroll-snap-align: start;
text-decoration: none; color: inherit; background: #1a1a1a;
transition: transform .15s ease;
}
.home-short-card:hover { transform: translateY(-2px); }
.home-short-card img { width:100%; height:100%; object-fit: cover; display:block; }
.home-short-card-overlay {
position:absolute; left:0; right:0; bottom:0; padding: 20px 8px 8px;
background: linear-gradient(transparent, rgba(0,0,0,.75));
color: #fff;
}
.home-short-card-title { font-size:12px; font-weight:600; line-height:1.3; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
.home-short-card-views { font-size:11px; opacity:.8; margin-top:2px; }
</style>
@endif
@if(isset($feedItems) && $feedItems->count())
<div class="yt-video-grid">
@foreach($feedItems as $entry)
@@ -211,6 +330,17 @@
@endif
@endif
{{-- Infinite-scroll sentinel. The IntersectionObserver down in @section('scripts')
fetches the next cursor page when this comes into view and appends the
rendered cards to the grid above. A visible "Load more" button is the
always-available fallback. --}}
<div id="feed-sentinel"
data-feed-filter="{{ $filter ?? 'all' }}"
style="height:1px;"></div>
<div id="feed-loadmore-wrap" style="display:none; text-align:center; padding:16px 0;">
<button type="button" id="feed-loadmore" class="action-btn">Load more</button>
</div>
</div>{{-- /filter-content --}}
@endsection
@@ -354,5 +484,69 @@
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
});
})();
/* ─── Cursor-paginated infinite scroll ───────────────────────────────────
The server renders the first page of 60 cards. Everything after that is
fetched here, one 24-card page at a time, when the #feed-sentinel scrolls
into view. The sentinel sits right after the grid, so we never fetch
until the user has actually scrolled past the first page.
State lives on #feed-sentinel.dataset so a SPA filter-swap can reset it
without having to rebind listeners. Fallback: an always-visible "Load
more" button that users without IntersectionObserver can click. */
(function () {
const sentinel = document.getElementById('feed-sentinel');
const grid = document.querySelector('.yt-video-grid');
const btnWrap = document.getElementById('feed-loadmore-wrap');
const btn = document.getElementById('feed-loadmore');
if (!sentinel || !grid) return;
let cursor = ''; // null token = next request is the first continuation page
let busy = false;
let done = false;
const filter = sentinel.dataset.feedFilter || 'all';
async function loadMore() {
if (busy || done) return;
busy = true;
try {
const url = new URL("{{ route('videos.feed.more') }}", window.location.origin);
url.searchParams.set('filter', filter);
if (cursor) url.searchParams.set('cursor', cursor);
const resp = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) throw new Error('bad status: ' + resp.status);
const data = await resp.json();
if (data.items) {
grid.insertAdjacentHTML('beforeend', data.items);
}
cursor = data.next_cursor || '';
done = !data.has_more;
} catch (e) {
// Reveal the manual Load More so a flaky network doesn't dead-end the user.
btnWrap.style.display = 'block';
} finally {
busy = false;
if (done) {
btnWrap.style.display = 'none';
sentinel.style.display = 'none';
}
}
}
btn?.addEventListener('click', loadMore);
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) loadMore();
}, { rootMargin: '400px 0px' });
io.observe(sentinel);
} else {
// No IntersectionObserver → surface the Load More fallback immediately.
btnWrap.style.display = 'block';
}
})();
</script>
@endsection
@@ -7,7 +7,12 @@
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
$showBox = $hasDesc || $isVideoOwner;
$llmEnabledVdb = $hasDesc && app(\App\Services\LlmLyricsService::class)->isEnabled();
// Optional pre-rendered block shown above the description inside the About
// panel. Only the `match` type passes it (the bout summary card); when it is
// unset every other type renders exactly as before.
$aboutPrepend = $aboutPrepend ?? null;
$showBox = $hasDesc || $isVideoOwner || $aboutPrepend !== null;
@endphp
@if ($showBox)
<style>
@@ -36,6 +41,27 @@
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
.vdb-show-more.expanded i { transform:rotate(180deg); }
.vdb-translate-btn { display:inline-flex; align-items:center; gap:6px; background:none; border:1px solid var(--border-color); color:var(--text-primary); font-size:12px; font-weight:600; padding:5px 12px; border-radius:16px; cursor:pointer; margin-top:10px; transition:background .15s, color .15s; font-family:inherit; }
.vdb-translate-btn:hover { background:rgba(255,255,255,.08); }
.vdb-translate-btn.loading { opacity:.6; cursor:wait; }
.vdb-translate-btn.active { color:#3ea6ff; border-color:rgba(62,166,255,.4); }
.vdb-translate-btn svg { width:14px; height:14px; fill:currentColor; }
.vdb-desc-split { display:block; }
/* Line-paired view: each row = one original line + its translation, aligned on
the same baseline so skimming across the divider stays easy. */
.vdb-desc-split.has-translation #vdbDescShort { display:none; }
.vdb-translation-grid { display:grid; grid-template-columns:1fr 1fr; column-gap:16px; row-gap:4px; font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
.vdb-translation-meta { grid-column:1 / -1; font-size:11px; color:var(--text-secondary); margin:0 0 4px; text-transform:uppercase; letter-spacing:.4px; }
.vdb-tr-row { display:contents; }
.vdb-tr-cell { padding:4px 0; white-space:pre-wrap; }
.vdb-tr-cell.vdb-tr-orig { padding-right:16px; border-right:1px solid var(--border-color); }
.vdb-tr-cell.vdb-tr-trans { padding-left:4px; }
.vdb-tr-cell.vdb-tr-empty { min-height:1em; }
@media (max-width:768px) {
.vdb-translation-grid { grid-template-columns:1fr; row-gap:2px; }
.vdb-tr-cell.vdb-tr-orig { padding:4px 0 2px; border-right:none; }
.vdb-tr-cell.vdb-tr-trans { padding:0 0 10px 0; color:#9ad1ff; border-bottom:1px dashed var(--border-color); }
}
</style>
<div class="vdb-wrap" id="vdbWrap">
@@ -51,6 +77,9 @@
</div>
<div class="vdb-panel active" id="vdb-about">
@if($aboutPrepend !== null)
{!! $aboutPrepend !!}
@endif
<div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span>
@@ -60,11 +89,21 @@
@if(isset($descriptionSlot))
{!! $descriptionSlot !!}
@elseif($renderedDescription !== '')
<div id="vdbDescSplit" class="vdb-desc-split">
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
</div>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
@if($llmEnabledVdb)
<button type="button" class="vdb-translate-btn" id="vdbTranslateBtn"
data-video-key="{{ $video->getRouteKey() }}"
data-csrf="{{ csrf_token() }}">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="vdb-translate-label">Translate</span>
</button>
@endif
</div>
@if($isVideoOwner)
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
};
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
window.addEventListener('load', window._vdbScheduleOverflowCheck);
// ── Description translate ─────────────────────────────
// Lives on the About tab. Falls back to navigator.language for the target.
function _vdbTargetLang() {
return ((navigator.language || 'en').toLowerCase().split('-')[0]) || 'en';
}
window._vdbToggleTranslate = window._vdbToggleTranslate || async function (btn) {
const split = document.getElementById('vdbDescSplit');
const showMore = document.getElementById('vdbShowMore');
const label = btn.querySelector('.vdb-translate-label');
if (!split) return;
let grid = document.getElementById('vdbTranslationGrid');
const showTranslation = () => {
split.classList.add('has-translation');
if (grid) grid.style.display = 'grid';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
if (showMore) showMore.style.display = 'none';
};
const hideTranslation = () => {
split.classList.remove('has-translation');
if (grid) grid.style.display = 'none';
btn.classList.remove('active');
if (label) label.textContent = 'Translate';
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
};
if (grid && grid.dataset.loaded === '1') {
split.classList.contains('has-translation') ? hideTranslation() : showTranslation();
return;
}
btn.classList.add('loading');
if (label) label.textContent = 'Translating…';
try {
const r = await fetch('/videos/' + btn.dataset.videoKey + '/description/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': btn.dataset.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ target: _vdbTargetLang() })
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
const segs = Array.isArray(d.segments) ? d.segments : [];
if (!segs.length) throw new Error('Translation failed');
// Build the line-paired grid. Each original line and its translation are
// siblings in the same CSS grid, so a short line on one side still shares
// its row with its counterpart on the other side.
if (!grid) {
grid = document.createElement('div');
grid.id = 'vdbTranslationGrid';
grid.className = 'vdb-translation-grid';
split.appendChild(grid);
}
grid.innerHTML = '';
const meta = document.createElement('div');
meta.className = 'vdb-translation-meta';
meta.textContent = 'Translated → ' + (d.target || _vdbTargetLang()).toUpperCase();
grid.appendChild(meta);
segs.forEach(seg => {
const origText = (seg.original ?? '').toString();
const trText = (seg.translated ?? '').toString();
const o = document.createElement('div');
o.className = 'vdb-tr-cell vdb-tr-orig' + (origText.trim() === '' ? ' vdb-tr-empty' : '');
o.textContent = origText;
const t = document.createElement('div');
t.className = 'vdb-tr-cell vdb-tr-trans' + (trText.trim() === '' ? ' vdb-tr-empty' : '');
t.textContent = trText;
grid.appendChild(o);
grid.appendChild(t);
});
grid.dataset.loaded = '1';
showTranslation();
} catch (err) {
if (window.showToast) window.showToast(err.message || 'Translation failed', 'error');
if (label) label.textContent = 'Translate';
} finally {
btn.classList.remove('loading');
}
};
// Delegate so SPA swaps that re-insert #vdbWrap keep working.
document.addEventListener('click', function (e) {
const b = e.target.closest('#vdbTranslateBtn');
if (b) { e.preventDefault(); window._vdbToggleTranslate(b); }
});
</script>
@endif
@@ -37,10 +37,10 @@
{{-- RED / AKA --}}
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
<span data-msb="redName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['red']['name'] ?: 'RED' }}</span>
</div>
<div style="display:flex;align-items:center;gap:8px;min-width:0">
@@ -76,14 +76,14 @@
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span data-msb="blueName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['name'] ?: 'BLUE' }}</span>
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
</div>
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span data-msb="bluePenalty" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
<span data-msb="blueClubName" data-msb-part="clubs" style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['club'] ?? '' }}</span>
</div>
</div>
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
</div>
</div>
</div>
@@ -78,8 +78,10 @@
el.textContent = val || '';
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
};
// Replace the placeholder "CLUB LOGO" text with a real <img>
// (or leave the placeholder if no image). Never touches inline styles.
// When a club_logo path is set, drop a real <img> into the empty
// placeholder box. When it isn't, the box stays empty — the
// inline-styled background keeps the slot visible without
// showing the stale "CLUB LOGO" text that used to render there.
const setImage = (name, path) => {
const el = $(name); if (!el) return;
if (!path) return;
@@ -90,8 +92,9 @@
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
el.appendChild(img);
};
// Swap the placeholder "FLAG" box for a real flag image by pointing
// its background-image at the self-hosted flag-icons SVG.
// When an ISO2 is set, point the flag box's background-image at
// the self-hosted flag-icons SVG. When it isn't, the empty box
// stays empty — no "FLAG" placeholder text.
const setFlag = (name, iso2) => {
const el = $(name); if (!el) return;
const code = (iso2 || '').toLowerCase();
+243 -38
View File
@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', 'Shorts - ' . config('app.name'))
@section('title', 'Shorts | ' . config('app.name'))
@section('main_class', 'shorts-view-page')
@@ -64,6 +64,20 @@
display: block;
}
/* Poster shim shown behind the <video> (and visible on inactive slots).
Keeps the slot filled with content when the shared player node has
moved to a different slot. */
.short-poster {
position: absolute; inset: 0;
background-size: cover; background-position: center;
background-color: #000;
}
/* Hidden preloader elements that fetch metadata for the next 1-2 shorts
without rendering anything visible. preload="metadata" is the
smallest-possible warmup; the browser pulls the moov atom + first
keyframe, enough that the switch to playback is instant. */
.short-preloader { display: none; }
/* ── Tap-to-pause icon ── */
.short-pause-icon {
position: absolute;
@@ -337,18 +351,21 @@
@endif
</div>
{{-- Video player --}}
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
{{-- Video player wrap. The actual <video> element is a SINGLE shared
node (defined once at the bottom of the feed) that gets moved
between slots as the active short changes. Each slot carries its
own src + poster on data- attributes and the slot's inner div
shows the poster until the player docks here. --}}
<div class="short-player-wrap" id="wrap-{{ $index }}"
data-src="{{ $src }}"
data-poster="{{ $thumb }}"
onclick="toggleShortPlay({{ $index }})">
<video
id="sv-{{ $index }}"
src="{{ $src }}"
poster="{{ $thumb }}"
loop
playsinline
preload="{{ $loop->first ? 'auto' : 'none' }}"
style="width:100%;height:100%;object-fit:cover;">
</video>
{{-- Poster shim: covers the slot before the shared <video> moves in.
Also stays visible on inactive slots so the viewport doesn't
go blank when the user is between shorts. --}}
<div class="short-poster"
@if($thumb) style="background-image:url('{{ $thumb }}');" @endif></div>
{{-- Pause icon --}}
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
@@ -387,11 +404,14 @@
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
</button>
{{-- Comments --}}
<a class="short-action-btn" href="{{ route('videos.show', $video) }}#comments">
<div class="short-action-icon"><i class="bi bi-chat-fill"></i></div>
{{-- Comments — opens the side panel on desktop, bottom sheet on
mobile (CSS does the breakpoint; the button toggles a shared
.shorts-comments-pane element populated via fetch). --}}
<button type="button" class="short-action-btn"
onclick="shortOpenComments('{{ $video->getRouteKey() }}')">
<div class="short-action-icon"><i class="bi bi-chat-fill" aria-hidden="true"></i></div>
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
</a>
</button>
{{-- Share --}}
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
@@ -426,36 +446,182 @@
@endforelse
{{-- Single shared <video> moved between slots as the active short changes.
Starts out detached (parked here in the DOM); the IntersectionObserver
relocates it into .short-player-wrap of the active slot. --}}
<video id="svGlobal"
loop playsinline
preload="metadata"
style="width:100%;height:100%;object-fit:cover;display:block;position:absolute;inset:0;">
</video>
{{-- Preloaders for the next 1-2 shorts. Hidden; preload="metadata" is
enough that switching to playback is instant. We assign srcs
dynamically — defining them here keeps the DOM shape stable. --}}
<video id="svPre1" class="short-preloader" preload="metadata" muted></video>
<video id="svPre2" class="short-preloader" preload="metadata" muted></video>
{{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
Populated via fetch of the regular watch page's #ytcSection block so
all the comment features (likes, replies, translate, heart/pin) work
identically. --}}
<div id="shortsCommentsPane" class="shorts-comments-pane" aria-hidden="true">
<div class="shorts-comments-head">
<span>Comments</span>
<button type="button" class="shorts-comments-close" onclick="shortCloseComments()" aria-label="Close comments">
<i class="bi bi-x-lg" aria-hidden="true"></i>
</button>
</div>
<div class="shorts-comments-body" id="shortsCommentsBody">
<div class="shorts-comments-loading">Loading…</div>
</div>
</div>
<div id="shortsCommentsBackdrop" class="shorts-comments-backdrop" onclick="shortCloseComments()"></div>
<style>
.shorts-comments-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.45);
z-index: 59; display: none;
}
.shorts-comments-backdrop.visible { display: block; }
.shorts-comments-pane {
position: fixed;
right: 0; top: 56px; bottom: 0; width: 420px;
background: var(--bg-dark); color: var(--text-primary);
box-shadow: -4px 0 24px rgba(0,0,0,.4);
z-index: 60; display: none; flex-direction: column;
border-left: 1px solid var(--border-color);
}
.shorts-comments-pane.visible { display: flex; }
.shorts-comments-head {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; border-bottom: 1px solid var(--border-color);
font-size: 15px; font-weight: 600;
}
.shorts-comments-close {
background: none; border: none; color: var(--text-primary);
cursor: pointer; font-size: 16px; padding: 6px;
border-radius: 6px;
}
.shorts-comments-close:hover { background: rgba(255,255,255,.08); }
.shorts-comments-body {
flex: 1; overflow-y: auto; padding: 12px 16px;
}
.shorts-comments-loading {
text-align: center; padding: 40px 16px; color: var(--text-secondary);
}
/* Mobile: bottom sheet instead of right drawer. */
@media (max-width: 768px) {
.shorts-comments-pane {
left: 0; right: 0; top: auto; bottom: 56px;
width: 100%; height: 70vh;
border-left: none; border-top: 1px solid var(--border-color);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 24px rgba(0,0,0,.4);
}
}
</style>
@endsection
@section('scripts')
<script>
(function () {
const main = document.getElementById('main');
let muted = false;
const video = document.getElementById('svGlobal');
const pre1 = document.getElementById('svPre1');
const pre2 = document.getElementById('svPre2');
// Persist the mute state across sessions. Default to muted so autoplay
// policies don't silently fail on first load; the user can unmute once
// and we remember it from then on.
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
let current = -1;
// Build an ordered list of slot wraps so neighbor lookup is a plain
// array index — we need to know "the slot at position N+1" to know
// which short to preload next.
const wraps = Array.from(document.querySelectorAll('.short-player-wrap'));
// ── Deep-link to /shorts/{id} — scroll the matching item into view
// before the observer starts, so autoplay fires on the target short
// rather than the first in document order. ────────────────────
@if($startId)
(function () {
const target = document.querySelector('.short-item[data-id="{{ (int) $startId }}"]');
if (target) target.scrollIntoView({ behavior: 'instant' in window ? 'instant' : 'auto', block: 'start' });
})();
@endif
function _dockVideoIn(wrap) {
// Move the shared <video> element into the active wrap. The browser
// keeps the active media context across the move, so playback
// doesn't restart just because we re-parented the node.
if (video.parentElement !== wrap) {
// Append after the poster so the poster acts as background
// while the video decodes its first frame.
wrap.appendChild(video);
}
}
function _primePreloaders(idx) {
// Preload metadata for the next 1-2 shorts so switching feels
// instant. preload="metadata" grabs just the moov atom + first
// keyframe; the browser doesn't fetch the whole file.
[pre1, pre2].forEach((pre, i) => {
const target = wraps[idx + 1 + i];
const nextSrc = target?.dataset.src || '';
if (pre.src !== nextSrc) pre.src = nextSrc;
});
}
function pauseShort(idx) {
const wrap = document.getElementById('wrap-' + idx);
if (video && video.parentElement === wrap) {
video.pause();
video.currentTime = 0;
}
if (wrap) wrap.classList.remove('paused');
const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = '0%';
}
// ── IntersectionObserver — auto-play visible short ─────────
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const item = entry.target;
const idx = parseInt(item.dataset.index);
const video = document.getElementById('sv-' + idx);
if (!video) return;
const wrap = document.getElementById('wrap-' + idx);
if (!wrap) return;
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
if (current !== -1 && current !== idx) pauseShort(current);
current = idx;
// ── Reflect the active short in the URL (no history entry) ──
try {
const vid = item.dataset.id;
if (vid) history.replaceState(null, '', '/shorts/' + vid);
} catch (_) {}
_dockVideoIn(wrap);
const nextSrc = wrap.dataset.src || '';
if (video.src !== nextSrc) video.src = nextSrc;
video.poster = wrap.dataset.poster || '';
video.muted = muted;
video.volume = 1;
video.play().catch(() => {
// autoplay blocked — start muted then unmute
// autoplay blocked — fall back to muted
video.muted = true;
muted = true;
localStorage.setItem('shortsMuted', '1');
updateMuteIcon(idx);
video.play().catch(() => {});
});
document.getElementById('wrap-' + idx).classList.remove('paused');
wrap.classList.remove('paused');
_primePreloaders(idx);
} else {
pauseShort(idx);
}
@@ -466,9 +632,8 @@
// ── Play / pause on tap ────────────────────────────────────
window.toggleShortPlay = function(idx) {
const video = document.getElementById('sv-' + idx);
const wrap = document.getElementById('wrap-' + idx);
if (!video) return;
if (!wrap || video.parentElement !== wrap) return;
if (video.paused) {
video.play().catch(() => {});
wrap.classList.remove('paused');
@@ -478,30 +643,23 @@
}
};
function pauseShort(idx) {
const video = document.getElementById('sv-' + idx);
const wrap = document.getElementById('wrap-' + idx);
if (video) { video.pause(); video.currentTime = 0; }
if (wrap) wrap.classList.remove('paused');
const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = '0%';
}
// ── Progress bars ──────────────────────────────────────────
document.querySelectorAll('.short-item video').forEach(video => {
const idx = video.id.replace('sv-', '');
// ── Progress bar — a single listener on the shared video updates the
// bar in whichever slot it's currently docked. --
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
const wrap = video.parentElement;
if (!wrap || !wrap.classList.contains('short-player-wrap')) return;
const idx = wrap.id.replace('wrap-', '');
const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
});
});
// ── Mute toggle ────────────────────────────────────────────
window.toggleMute = function(idx) {
muted = !muted;
const video = document.getElementById('sv-' + idx);
if (video) video.muted = muted;
localStorage.setItem('shortsMuted', muted ? '1' : '0');
// The shared video is the only one playing; mute it directly.
video.muted = muted;
updateMuteIcon(idx);
};
@@ -528,6 +686,22 @@
}
});
// ── Mouse wheel nav (desktop) ──────────────────────────────
// One wheel event = one short, debounced so a trackpad flick doesn't
// skip five shorts at once. The main scroll container's own snap would
// nearly handle this, but a slow scroll leaves the feed mid-snap on
// some browsers — this makes the step explicit and symmetrical.
let wheelLock = false;
main?.addEventListener('wheel', (e) => {
if (wheelLock) { e.preventDefault(); return; }
if (Math.abs(e.deltaY) < 10) return; // ignore tiny jogs
wheelLock = true;
setTimeout(() => { wheelLock = false; }, 450);
e.preventDefault();
const dir = e.deltaY > 0 ? 1 : -1;
if (current !== -1) navigateShort(dir, current);
}, { passive: false });
// ── Like ───────────────────────────────────────────────────
window.shortLike = function(videoId) {
@auth
@@ -552,6 +726,37 @@
@endauth
};
// ── Comments pane ──────────────────────────────────────
window.shortOpenComments = function(videoKey) {
const pane = document.getElementById('shortsCommentsPane');
const backdrop = document.getElementById('shortsCommentsBackdrop');
const body = document.getElementById('shortsCommentsBody');
if (!pane || !body) return;
body.innerHTML = '<div class="shorts-comments-loading">Loading…</div>';
pane.classList.add('visible');
pane.setAttribute('aria-hidden', 'false');
backdrop?.classList.add('visible');
fetch('/videos/' + videoKey, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin'
})
.then(r => r.text())
.then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const section = doc.getElementById('ytcSection');
if (section) { body.innerHTML = ''; body.appendChild(section); }
else { body.innerHTML = '<div class="shorts-comments-loading">Comments unavailable.</div>'; }
})
.catch(() => { body.innerHTML = '<div class="shorts-comments-loading">Could not load comments.</div>'; });
};
window.shortCloseComments = function() {
const pane = document.getElementById('shortsCommentsPane');
const backdrop = document.getElementById('shortsCommentsBackdrop');
pane?.classList.remove('visible');
pane?.setAttribute('aria-hidden', 'true');
backdrop?.classList.remove('visible');
};
// ── Share ──────────────────────────────────────────────────
window.shortShare = function(videoKey, title) {
const url = window.location.origin + '/videos/' + videoKey;
+15 -7
View File
@@ -836,15 +836,22 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
{{-- Real <a href> cards so right-click / open-in-new-tab /
middle-click and screen readers all work. The SPA
transition hooks in types/*.blade.php still intercept
these clicks via data-rec-url for a soft load. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
alt="{{ $recVideo->title }}" width="168" height="94"
loading="lazy" decoding="async">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
alt="{{ $recVideo->title }}">
alt="{{ $recVideo->title }}" width="168" height="94"
loading="lazy" decoding="async">
@endif
@if ($recVideo->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
@@ -852,14 +859,15 @@
@if ($recVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
<i class="bi bi-collection-play-fill" aria-hidden="true"></i> SHORTS
</span>
@endif
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
style="color: #ef4444; margin-right: 4px; font-size: 12px;"
aria-hidden="true"></i>
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">
@@ -868,7 +876,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
</a>
@endforeach
</div>
@else
+7 -3
View File
@@ -177,9 +177,13 @@
<div class="trending-header">
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
<div class="trending-filters">
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
{{-- Hours line up with the labels: 24 = Today, 168 = This Week
(7×24), 720 = This Month (30×24). The old 24/48/168 trio
claimed Month but silently showed Week data because the
controller clamped the max to 168. --}}
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}" @if($hours == 24) aria-current="page"@endif>Today</a>
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}" @if($hours == 168) aria-current="page"@endif>This Week</a>
<a href="{{ route('videos.trending', ['hours' => 720]) }}" class="{{ $hours == 720 ? 'active' : '' }}" @if($hours == 720) aria-current="page"@endif>This Month</a>
</div>
</div>
+10 -3
View File
@@ -307,7 +307,10 @@
border-radius: 8px;
padding: 4px;
transition: background .15s;
color: inherit;
text-decoration: none;
}
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
.sidebar-video-card.current-video {
background: rgba(239,68,68,.12);
@@ -977,9 +980,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
{{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
@@ -1013,7 +1020,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
</a>
@endforeach
</div>
@else
+26 -20
View File
@@ -317,14 +317,12 @@
/* No rotation animation */
/* Hide sidebar by default */
.events-sidebar {
display: none;
}
.events-sidebar.show {
display: flex;
}
{{-- Dead-code removal (2026-10-21 audit): the previous `.events-sidebar
{ display: none }` + `.show { display: flex }` pair here was
overridden further down (search for the `display: none !important`
definition under /* ===== Match Events Sidebar ===== */). The
!important forms silently won every cascade; this pair never
rendered. --}}
/* ===== Playlist Navigation Controls ===== */
.playlist-controls {
@@ -2009,15 +2007,12 @@
}
/* ===== Responsive ===== */
@media (max-width: 1300px) {
.yt-sidebar-container {
width: 300px;
}
.events-sidebar {
width: 300px;
}
}
{{-- Dead-code removal (2026-10-21 audit): a prior
@media (max-width: 1300px) block sized .yt-sidebar-container +
.events-sidebar to 300 px. The immediately-following
@media (max-width: 1300px) rule overrides both to 100% with
!important as part of the stacking layout, so the 300 px pair
never applied. --}}
/* ── Narrow desktop: too little room for a side rail ──────────────
Between 992px and 1300px the left nav (240px) and the 300px rail
@@ -2418,6 +2413,13 @@
</div>
<!-- ===== END EVENT HEADER ===== -->
{{-- Channel row — the generic + music watch pages render this
just under the title. The match page used to omit it so
the only way to reach the uploader's channel from a match
video was the small avatar hidden inside the Match Overview
section. --}}
<x-channel-row :video="$video" />
@include('videos.partials.description-box', ['video' => $video])
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
@@ -3144,9 +3146,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
{{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
@@ -3181,7 +3187,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
</a>
@endforeach
</div>
@else
+12 -3
View File
@@ -307,7 +307,12 @@
border-radius: 8px;
padding: 4px;
transition: background .15s;
/* Card is now an <a href> for real right-click / open-in-new-tab
/ middle-click support. Strip the default link styling. */
color: inherit;
text-decoration: none;
}
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
.sidebar-video-card.current-video {
background: rgba(239,68,68,.12);
@@ -967,9 +972,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
{{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
@@ -1003,7 +1012,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
</a>
@endforeach
</div>
@else
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', 'Welcome - ' . config('app.name'))
@section('title', 'Welcome | ' . config('app.name'))
@section('content')
<div class="welcome-container">
+1 -1
View File
@@ -15,7 +15,7 @@ Route::middleware('guest')->group(function () {
Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store'])
->middleware('throttle:10,1') // max 10 login attempts per minute per IP
->middleware('throttle:5,1') // max 5 login attempts per minute per IP
->name('login.store');
});
+71 -1
View File
@@ -24,9 +24,20 @@ Route::get('/', [VideoController::class, 'index'])->name('home');
// Video routes - public
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
// Site-wide search. /videos/search is the historical URL; keep it as a
// signed-forward redirect so bookmarks and external links don't break.
Route::get('/search', [\App\Http\Controllers\SearchController::class, 'index'])->name('search');
Route::get('/videos/search', function (\Illuminate\Http\Request $request) {
return redirect()->route('search', $request->only('q'), 301);
})->name('videos.search');
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
// Deep-link form: /shorts/{encoded-id} loads the feed scrolled to that short.
// The resolveRouteBinding on Video decodes the id; if it's gone, 404 lands
// on the branded errors/404 page.
Route::get('/shorts/{video}', [VideoController::class, 'shortsFocused'])->name('videos.shorts.focused');
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
Route::get('/videos/{numericId}', function ($numericId) {
@@ -58,6 +69,7 @@ Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsPr
Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::post('/videos/{video}/description/translate', [VideoController::class, 'translateDescription'])->name('videos.description.translate')->middleware('throttle:30,1');
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
@@ -119,6 +131,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
Route::post('/videos/{video}/toggle-dislike', [UserController::class, 'toggleDislike'])->name('videos.toggleDislike');
});
// Subscribe + notifications — auth but NOT email verification required
@@ -137,11 +150,14 @@ Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisi
// Comment routes
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
Route::post('/comments/{comment}/translate', [CommentController::class, 'translate'])->name('comments.translate');
Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
Route::post('/comments/{comment}/heart', [CommentController::class, 'heart'])->name('comments.heart');
Route::post('/comments/{comment}/pin', [CommentController::class, 'pin'])->name('comments.pin');
});
// User routes
@@ -159,6 +175,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/history', [UserController::class, 'history'])->name('history');
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
// Creator studio
Route::get('/studio', [\App\Http\Controllers\StudioController::class, 'index'])->name('studio');
Route::post('/studio/bulk/visibility', [\App\Http\Controllers\StudioController::class, 'bulkVisibility'])->name('studio.bulkVisibility');
Route::post('/studio/bulk/delete', [\App\Http\Controllers\StudioController::class, 'bulkDelete'])->name('studio.bulkDelete');
// Logout all other devices
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
@@ -171,6 +193,54 @@ Route::middleware(['auth', 'verified'])->group(function () {
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
Route::get('/channel/{username}/videos/more', [UserController::class, 'channelVideosMore'])
->name('channel.videos.more');
// YouTube-style @handle alias. For now `handle` is just the existing
// `username` slug — if the two ever diverge, flip the controller lookup
// in UserController::channel() to prefer a dedicated `handle` column.
// The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice`
// — Laravel's router accepts both because `@` is allowed in URI path segments.
// ─── Live streaming ─────────────────────────────────────
use App\Http\Controllers\LiveAuthController;
use App\Http\Controllers\LiveStreamController;
Route::get('/live', [LiveStreamController::class, 'index'])->name('live.index');
Route::get('/live/new', [LiveStreamController::class, 'create'])->name('live.create');
Route::post('/live', [LiveStreamController::class, 'store'])->name('live.store')->middleware('throttle:10,1');
Route::get('/live/{stream}/broadcast', [LiveStreamController::class, 'broadcast'])->name('live.broadcast');
Route::post('/live/{stream}/token', [LiveStreamController::class, 'token'])->name('live.token')->middleware('throttle:30,1');
Route::post('/live/{stream}/stop', [LiveStreamController::class, 'stop'])->name('live.stop')->middleware('throttle:30,1');
Route::get('/live/{stream}', [LiveStreamController::class, 'watch'])->name('live.watch');
Route::get('/live/{stream}/status', [LiveStreamController::class, 'status'])->name('live.status')->middleware('throttle:120,1');
// Live chat — polling by viewers, send by signed-in users (rate-limited).
Route::get('/live/{stream}/chat', [LiveStreamController::class, 'chatPoll'])
->name('live.chat.poll')->middleware('throttle:120,1');
Route::post('/live/{stream}/chat', [LiveStreamController::class, 'chatSend'])
->name('live.chat.send')->middleware('throttle:60,1');
Route::post('/api/live/auth', [LiveAuthController::class, 'authenticate'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Route::post('/api/live/hook', [LiveAuthController::class, 'hook'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Route::get('/@{handle}', [UserController::class, 'channel'])
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
->name('channel.handle');
// Tab aliases: /@handle/videos lands on the Videos tab, /@handle/shorts on
// Shorts, etc. These bridge to the single-page channel view by forwarding
// the tab name as a hash — the channel.blade.php JS already handles
// #wall/#videos/#shorts/#playlists/#about/#posts on load.
Route::get('/@{handle}/{tab}', function (string $handle, string $tab) {
$allowed = ['wall', 'videos', 'shorts', 'playlists', 'posts', 'about'];
if (! in_array($tab, $allowed, true)) {
abort(404);
}
$user = \App\Models\User::where('username', $handle)->firstOrFail();
return redirect(route('channel', $user->channel) . '#' . $tab, 302);
})->where(['handle' => '[A-Za-z0-9][A-Za-z0-9._-]*', 'tab' => '[a-z]+'])
->name('channel.handle.tab');
// Post routes - auth required
Route::middleware(['auth'])->group(function () {
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator;
use Tests\TestCase;
/**
* Admin polish follow-ups to Phase 7 — storage math, pagination controls,
* HTML-stripped descriptions and the grouped error log viewer.
*/
class AdminPolishTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Pagination partial: First + Last buttons ─────
public function test_pagination_partial_renders_first_and_last_controls(): void
{
$paginator = new LengthAwarePaginator(
items: ['a', 'b', 'c'],
total: 100,
perPage: 10,
currentPage: 5,
options: ['path' => '/videos']
);
$html = view('partials.pagination', [
'paginator' => $paginator,
'elements' => $paginator->linkCollection()->toArray(),
])->render();
$this->assertStringContainsString('aria-label="First page"', $html);
$this->assertStringContainsString('aria-label="Last page"', $html);
$this->assertStringContainsString('aria-label="Previous page"', $html);
$this->assertStringContainsString('aria-label="Next page"', $html);
// The anchors should link to page 1 and page 10 respectively.
$this->assertStringContainsString('href="/videos?page=1"', $html);
$this->assertStringContainsString('href="/videos?page=10"', $html);
}
public function test_pagination_disables_first_on_page_one(): void
{
// Two-page paginator — if there's only one page the partial
// short-circuits via @if($paginator->hasPages()) and renders
// nothing, which isn't what we're testing.
$paginator = new LengthAwarePaginator(
items: ['a'],
total: 20,
perPage: 10,
currentPage: 1,
options: ['path' => '/videos']
);
$html = view('partials.pagination', [
'paginator' => $paginator,
'elements' => $paginator->linkCollection()->toArray(),
])->render();
// On page 1 the First button is a disabled span, not an anchor.
$this->assertMatchesRegularExpression(
'/<span[^>]+class="pag-item disabled"[^>]+title="First page"/',
$html
);
}
// ─── Admin videos list: HTML-stripped description ─
public function test_admin_video_description_renders_without_html_tags(): void
{
$owner = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
'role' => 'super_admin',
]);
$video = Video::create([
'user_id' => $owner->id,
'title' => 'The Full Video Title That Will Be Ellipsized',
'description' => '<p>Hello <strong>world</strong> <a href="#">link</a></p>',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
$this->actingAs($owner);
$html = $this->get('/admin/videos')->getContent();
// Admin list must render plain text, not literal <p> / <strong>.
$this->assertStringNotContainsString('&lt;p&gt;Hello', $html,
'Description must be stripped of HTML tags before display.');
$this->assertStringContainsString('Hello world link', $html,
'Stripped text should still be visible.');
// Title tooltip in both the title column and description preview.
$this->assertStringContainsString(
'title="The Full Video Title That Will Be Ellipsized"',
$html,
'Admin title column must carry a title="" attribute for the full name.'
);
}
// ─── Dashboard: Ready label ──────────────────────
public function test_dashboard_ready_stat_card_uses_the_new_label(): void
{
$owner = User::factory()->create([
'username' => 'ad'.substr(bin2hex(random_bytes(4)), 0, 6),
'role' => 'super_admin',
]);
$this->actingAs($owner);
$html = $this->get('/admin/dashboard')->getContent();
$this->assertStringContainsString('>Ready<', $html);
$this->assertStringNotContainsString('>Live Videos<', $html,
'The old "Live Videos" stat-card label should be gone.');
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Covers the bulk cleanup of deferred items:
* end-screen markup, C hotkey, guest subscribe prompt, /@handle tab aliases,
* channel videos cursor endpoint, shorts comments pane, studio skeleton.
*/
class DeferredCleanupTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Player ─────────────────────────────────────
public function test_player_component_ships_the_end_screen_markup(): void
{
$u = $this->makeUser();
$v = $this->makeVideo($u);
$html = view('components.video-player', ['video' => $v])->render();
$this->assertStringContainsString('id="ytpEndScreen"', $html);
$this->assertStringContainsString('ytp-end-countdown', $html, 'countdown ring class hook');
$this->assertStringContainsString('id="ytpEndRing"', $html);
$this->assertStringContainsString('id="ytpEndCancel"', $html);
}
public function test_player_binds_c_hotkey_to_lyrics_button(): void
{
$u = $this->makeUser();
$v = $this->makeVideo($u);
$html = view('components.video-player', ['video' => $v])->render();
$this->assertStringContainsString("case 'c':", $html);
$this->assertStringContainsString('ytpLyricsBtn', $html);
}
// ─── Guest Subscribe prompt ────────────────────
public function test_guest_subscribe_button_preserves_intended_url(): void
{
$u = $this->makeUser();
$v = $this->makeVideo($u);
$html = view('components.video-actions', ['video' => $v])->render();
$this->assertStringContainsString('?intended=', $html,
'Guest Subscribe button must redirect to login with ?intended=.');
$this->assertStringContainsString('encodeURIComponent', $html);
}
// ─── /@handle tab aliases ───────────────────────
public function test_handle_tab_alias_302_redirects_to_hash_url(): void
{
$u = User::factory()->create(['username' => 'hta-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
$r = $this->get('/@' . $u->username . '/videos');
$r->assertStatus(302);
$this->assertStringEndsWith('#videos', $r->headers->get('Location'));
}
public function test_handle_tab_alias_404s_on_unknown_tab(): void
{
$u = User::factory()->create(['username' => 'htn-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
$this->get('/@' . $u->username . '/nonsense')->assertStatus(404);
}
// ─── Channel videos cursor endpoint ─────────────
public function test_channel_videos_more_returns_cursor_json(): void
{
$u = $this->makeUser();
// Twice the page size so has_more is true.
for ($i = 0; $i < 30; $i++) {
$this->makeVideo($u, ['title' => 'Vid '.$i]);
}
$r = $this->getJson(route('channel.videos.more', ['username' => $u->username]));
$r->assertOk()
->assertJsonStructure(['items', 'next_cursor', 'has_more'])
->assertJson(['has_more' => true]);
$this->assertStringContainsString('yt-video-card', $r->json('items'));
}
// ─── Studio ─────────────────────────────────────
public function test_studio_requires_authentication(): void
{
$this->get(route('studio'))->assertStatus(302);
}
public function test_studio_renders_only_my_videos(): void
{
$me = $this->makeUser();
$other = $this->makeUser();
$mine = $this->makeVideo($me, ['title' => 'MyStudioVideo']);
$theirs = $this->makeVideo($other, ['title' => 'TheirVideo']);
$this->actingAs($me);
$html = $this->get(route('studio'))->getContent();
$this->assertStringContainsString('MyStudioVideo', $html);
$this->assertStringNotContainsString('TheirVideo', $html);
}
public function test_studio_bulk_visibility_refuses_other_users_videos(): void
{
$me = $this->makeUser();
$other = $this->makeUser();
$mine = $this->makeVideo($me, ['visibility' => 'public']);
$theirs = $this->makeVideo($other, ['visibility' => 'public']);
$this->actingAs($me);
$this->postJson(route('studio.bulkVisibility'), [
'video_ids' => [$mine->id, $theirs->id],
'visibility' => 'private',
])->assertOk()->assertJson(['updated' => 1]);
$this->assertEquals('private', $mine->fresh()->visibility);
$this->assertEquals('public', $theirs->fresh()->visibility,
'A crafted request body must not flip another user\'s video.');
}
public function test_studio_bulk_delete_scoped_to_owner(): void
{
$me = $this->makeUser();
$other = $this->makeUser();
$mine = $this->makeVideo($me);
$theirs = $this->makeVideo($other);
$this->actingAs($me);
$this->postJson(route('studio.bulkDelete'), [
'video_ids' => [$mine->id, $theirs->id],
])->assertOk()->assertJson(['deleted' => 1]);
$this->assertNull(Video::find($mine->id));
$this->assertNotNull(Video::find($theirs->id));
}
// ─── Helpers ────────────────────────────────────
private function makeUser(): User
{
return User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
}
private function makeVideo(User $u, array $attrs = []): Video
{
return Video::create(array_merge([
'user_id' => $u->id,
'title' => 'T',
'description' => '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
], $attrs));
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Video dislike toggle + creator heart + creator pin.
*
* The in-memory DB these tests run against gets the two post-audit migrations
* applied automatically by RefreshDatabase, so no manual migrate step is
* required to exercise the full feature.
*/
class DislikeHeartPinTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Dislike toggle ──────────────────────────────
public function test_toggle_dislike_adds_then_removes_the_row(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($viewer);
$this->postJson(route('videos.toggleDislike', $video))
->assertOk()
->assertJson(['disliked' => true, 'dislike_count' => 1]);
$this->assertDatabaseHas('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
$this->postJson(route('videos.toggleDislike', $video))
->assertOk()
->assertJson(['disliked' => false, 'dislike_count' => 0]);
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
}
public function test_disliking_clears_an_existing_like(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($viewer);
$video->likes()->attach($viewer->id);
$this->postJson(route('videos.toggleDislike', $video))
->assertOk()
->assertJson(['disliked' => true, 'liked' => false, 'like_count' => 0]);
$this->assertDatabaseMissing('video_likes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
}
public function test_liking_clears_an_existing_dislike(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($viewer);
$video->dislikes()->attach($viewer->id);
$this->postJson(route('videos.toggleLike', $video))
->assertOk()
->assertJson(['liked' => true, 'disliked' => false, 'like_count' => 1, 'dislike_count' => 0]);
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
}
// ─── Heart ──────────────────────────────────────
public function test_owner_can_heart_a_comment(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($owner);
$comment = Comment::create([
'user_id' => $viewer->id,
'video_id' => $video->id,
'body' => 'nice video!',
]);
$this->postJson(route('comments.heart', $comment))
->assertOk()
->assertJson(['hearted' => true]);
$this->assertEquals(true, $comment->fresh()->hearted);
$this->postJson(route('comments.heart', $comment))
->assertOk()
->assertJson(['hearted' => false]);
}
public function test_non_owner_cannot_heart_a_comment(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$comment = Comment::create([
'user_id' => $viewer->id,
'video_id' => $video->id,
'body' => 'nice video!',
]);
$this->actingAs($viewer);
$this->postJson(route('comments.heart', $comment))->assertStatus(403);
$this->assertEquals(false, $comment->fresh()->hearted);
}
// ─── Pin ────────────────────────────────────────
public function test_owner_pins_at_most_one_comment_at_a_time(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($owner);
$a = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'A']);
$b = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'B']);
$this->postJson(route('comments.pin', $a))->assertOk()->assertJson(['pinned' => true]);
$this->assertTrue((bool) $a->fresh()->pinned);
$this->assertFalse((bool) $b->fresh()->pinned);
// Pinning B must unpin A.
$this->postJson(route('comments.pin', $b))->assertOk()->assertJson(['pinned' => true]);
$this->assertFalse((bool) $a->fresh()->pinned);
$this->assertTrue((bool) $b->fresh()->pinned);
}
public function test_cannot_pin_a_reply(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($owner);
$parent = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'top']);
$reply = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'parent_id' => $parent->id, 'body' => 'r']);
$this->postJson(route('comments.pin', $reply))->assertStatus(422);
$this->assertFalse((bool) $reply->fresh()->pinned);
}
public function test_non_owner_cannot_pin_a_comment(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($viewer);
$comment = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'self']);
$this->postJson(route('comments.pin', $comment))->assertStatus(403);
}
// ─── Pinned comments sort first in the component ──
public function test_pinned_comments_render_before_newer_unpinned_ones(): void
{
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
$this->actingAs($owner);
$pinned = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'OLDPINNED', 'pinned' => true]);
// Date it older than the second one so sort would normally send it to the bottom.
$pinned->created_at = now()->subDays(5); $pinned->save();
Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'NEWFRESH']);
$html = view('components.video-comments', ['video' => $video])->render();
// Match on the unique comment-id markup so count/header text can't
// interfere with the order check.
$pOld = strpos($html, 'id="comment-' . $pinned->id . '"');
$pNew = strpos($html, 'id="comment-' . ($pinned->id + 1) . '"');
$this->assertNotFalse($pOld);
$this->assertNotFalse($pNew);
$this->assertLessThan($pNew, $pOld, 'Pinned comment must render before a newer unpinned one.');
}
// ─── Helpers ────────────────────────────────────
private function makeOwnerViewerVideo(): array
{
$owner = User::factory()->create(['username' => 'own-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
$viewer = User::factory()->create(['username' => 'vwr-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
$video = Video::create([
'user_id' => $owner->id,
'title' => 'test',
'description' => '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
return [$owner, $viewer, $video];
}
}
+15 -4
View File
@@ -2,18 +2,29 @@
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
/**
* A basic test example.
* The home page must render for an anonymous visitor on an empty database.
*
* Previously 500'd because this test skipped RefreshDatabase, so the
* home controller tried to query a `videos` table that didn't exist in
* the in-memory testing DB.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
// phpunit.xml + .env.testing + TestCase::assertSafeTestDatabase() point
// this at :memory: — matching the trio every Phase test uses.
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
$response->assertStatus(200);
$this->get('/')->assertStatus(200);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\LiveChatMessage;
use App\Models\LiveStream;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Live polish regression tests. Covers:
* - Private visibility accepted by /live/new validation.
* - Ended-state watch page shows "Stream ended" + duration.
* - Chat poll endpoint returns everything newer than ?since=.
* - Chat send endpoint rejects guests, non-live streams, and
* anyone but the owner on a private stream.
*/
class LivePolishTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Private visibility accepted ─────────────────
public function test_create_live_stream_accepts_private_visibility(): void
{
$u = $this->makeUser();
$this->actingAs($u);
$this->post(route('live.store'), [
'title' => 'My private test stream',
'description' => 'just testing',
'visibility' => 'private',
])->assertRedirect();
$this->assertDatabaseHas('livestreams', [
'user_id' => $u->id,
'title' => 'My private test stream',
'visibility' => 'private',
]);
}
// ─── Ended state renders "Stream ended" ──────────
public function test_ended_stream_watch_page_shows_stream_ended(): void
{
$u = $this->makeUser();
$s = $this->makeStream($u, [
'status' => LiveStream::STATUS_ENDED,
'started_at' => now()->subMinutes(10),
'ended_at' => now(),
]);
$html = $this->get(route('live.watch', $s))->assertOk()->getContent();
$this->assertStringContainsString('Stream ended', $html);
$this->assertStringNotContainsString('Waiting for the broadcast to start', $html);
}
// ─── Channel name is a link ──────────────────────
public function test_watch_page_channel_name_is_a_real_link(): void
{
$u = $this->makeUser();
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
$expected = route('channel', $u->channel);
$html = $this->get(route('live.watch', $s))->assertOk()->getContent();
$this->assertStringContainsString('href="' . $expected . '"', $html);
}
// ─── Chat poll ───────────────────────────────────
public function test_chat_poll_returns_messages_newer_than_since(): void
{
$u = $this->makeUser();
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
$a = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'hello', 'created_at' => now()]);
$b = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'world', 'created_at' => now()]);
$this->getJson(route('live.chat.poll', $s) . '?since=' . $a->id)
->assertOk()
->assertJsonCount(1, 'messages')
->assertJsonPath('messages.0.body', 'world');
}
// ─── Chat send ───────────────────────────────────
public function test_chat_send_rejects_guests(): void
{
$u = $this->makeUser();
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
// postJson sets Accept: application/json so the auth middleware
// returns 401 instead of redirecting to login.
$this->postJson(route('live.chat.send', $s), ['body' => 'hi'])
->assertStatus(401);
}
public function test_chat_send_rejects_offline_streams(): void
{
$u = $this->makeUser();
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_ENDED]);
$this->actingAs($u);
$this->postJson(route('live.chat.send', $s), ['body' => 'hi'])
->assertStatus(422);
}
public function test_private_stream_chat_rejects_non_owner(): void
{
$owner = $this->makeUser();
$viewer = $this->makeUser();
$s = $this->makeStream($owner, ['status' => LiveStream::STATUS_LIVE, 'visibility' => 'private']);
$this->actingAs($viewer);
$this->postJson(route('live.chat.send', $s), ['body' => 'sneak'])
->assertStatus(404);
}
// ─── Helpers ─────────────────────────────────────
private function makeUser(): User
{
return User::factory()->create([
'username' => 'lu'.substr(bin2hex(random_bytes(4)), 0, 6),
]);
}
private function makeStream(User $u, array $attrs = []): LiveStream
{
return LiveStream::create(array_merge([
'user_id' => $u->id,
'public_id' => substr(bin2hex(random_bytes(10)), 0, 16),
'title' => 'Test stream',
'description' => '',
'status' => LiveStream::STATUS_IDLE,
'visibility' => 'public',
], $attrs));
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use App\Rules\ReservedIdentifier;
use App\Support\SafeNotifier;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Phase 1 audit remediation: security + data-integrity regression tests.
*
* These tests run on a scratch SQLite file in storage/testing.sqlite so they
* can't collide with the shared dev DB. phpunit.xml keeps DB_CONNECTION out
* of the test env so this per-class override is where the swap happens.
*/
class Phase1SecurityTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
// Force the test runner onto an in-memory SQLite BEFORE the framework
// boots, so RefreshDatabase migrates the throwaway DB and never the
// shared dev file. Env vars are read during app bootstrap in
// CreatesApplication, which is why putenv has to come first.
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
// Strip the two middleware that fight the test client: CSRF (no
// token flow in postJson) and AuthenticateSession (which rotates
// the session on cross-request assertions and 302s to APP_URL).
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── ReservedIdentifier rule ──────────────────────────
public function test_reserved_name_rule_blocks_impersonation_candidates(): void
{
$rule = new ReservedIdentifier('name');
foreach (['admin', 'Administrator', 'super_admin', 'super-admin',
'SuperAdmin', 'TAKEONE', 'support', 'root', 'Postmaster'] as $name) {
$failed = false;
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
$this->assertTrue($failed, "Expected name '{$name}' to be reserved");
}
}
public function test_reserved_email_rule_matches_on_local_part_only(): void
{
$rule = new ReservedIdentifier('email');
$failed = false;
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
$this->assertTrue($failed);
$failed = false;
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
$this->assertFalse($failed, 'Only exact local-part should match; "admin.user" is a real person.');
}
public function test_reserved_rule_allows_normal_names(): void
{
$rule = new ReservedIdentifier('name');
foreach (['Alice', 'Bob Smith', 'John Doe', 'الحسن'] as $name) {
$failed = false;
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
$this->assertFalse($failed, "Expected name '{$name}' to be allowed");
}
}
// ─── SafeNotifier swallows mail failures ──────────────
public function test_safe_notifier_logs_but_does_not_throw_on_failure(): void
{
// A fake notifiable whose notify() blows up — stands in for the Gmail
// 535 case where sending throws out of the transport layer.
$exploding = new class {
public function notify($n): void { throw new \RuntimeException('smtp exploded'); }
public function getKey(): int { return 1; }
};
$notification = new \App\Notifications\VerifyEmail;
// Call must not throw — if it did, PHPUnit would mark this test as errored.
SafeNotifier::send($exploding, $notification);
$this->assertTrue(true, 'SafeNotifier swallowed the throw');
}
// ─── Comment dedupe ───────────────────────────────────
public function test_identical_comments_within_60s_are_collapsed(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$first = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
$first->assertOk();
$firstId = $first->json('comment.id');
$second = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
$second->assertOk();
$secondId = $second->json('comment.id');
$this->assertSame($firstId, $secondId, 'Duplicate within 60s should return the first row, not insert a new one.');
$this->assertSame(1, Comment::where('video_id', $video->id)->count());
}
public function test_idempotency_key_returns_the_same_comment(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$key = 'test-idem-key-' . bin2hex(random_bytes(8));
$a = $this->withHeaders(['X-Idempotency-Key' => $key])
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'First body']);
$a->assertOk();
$aId = $a->json('comment.id');
// Different body, same key — because the first write cached the id,
// the second call returns it unchanged.
$b = $this->withHeaders(['X-Idempotency-Key' => $key])
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Different body']);
$b->assertOk();
$bId = $b->json('comment.id');
$this->assertSame($aId, $bId, 'Same idempotency key must return the same comment id.');
}
// ─── Owner view exclusion ─────────────────────────────
public function test_owner_view_is_not_recorded(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$this->get("/videos/{$video->id}");
$this->assertSame(0, DB::table('video_views')->where('video_id', $video->id)->count(),
'Views by the video owner must not be counted.');
}
// ─── Helpers ──────────────────────────────────────────
private function makeUserAndVideo(): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create([
'user_id' => $user->id,
'title' => 'Test video',
'description'=> 'Hello',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
]);
return [$user, $video];
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 2 — Performance and DOM. Guards against regressions in the shared
* edit modal, owner-only menu items, thumbnail layout-shift attrs, and the
* cursor-paginated feed endpoint.
*/
class Phase2PerfTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Shared edit modal: per-card copy is gone ─────
public function test_video_card_does_not_render_a_per_card_edit_modal(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$html = view('components.video-card', ['video' => $video])->render();
// The dead per-card modal used id="editVideoModal{slug}" and the class
// "cute-edit-modal". If either reappears, we're back to shipping 100
// extra lines per card.
$this->assertStringNotContainsString('editVideoModal' . $video->getRouteKey(), $html);
$this->assertStringNotContainsString('cute-edit-modal', $html);
}
// ─── Owner-only menu items ────────────────────────
public function test_owner_sees_edit_delete_items_in_card_menu(): void
{
[$owner, $video] = $this->makeUserAndVideo();
$this->actingAs($owner);
$html = view('components.video-card', ['video' => $video])->render();
$this->assertStringContainsString('openEditVideoModal', $html);
$this->assertStringContainsString('showDeleteModal', $html);
$this->assertStringNotContainsString('notInterested', $html);
$this->assertStringNotContainsString('dontRecommendChannel', $html);
$this->assertStringNotContainsString('reportVideo', $html);
}
public function test_non_owner_sees_report_items_but_not_edit(): void
{
[$owner, $video] = $this->makeUserAndVideo();
$viewer = User::factory()->create([
'username' => 'v'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$this->actingAs($viewer);
$html = view('components.video-card', ['video' => $video])->render();
$this->assertStringNotContainsString('openEditVideoModal', $html);
$this->assertStringNotContainsString('showDeleteModal', $html);
$this->assertStringContainsString('notInterested', $html);
$this->assertStringContainsString('reportVideo', $html);
}
// ─── Thumbnail layout-shift attributes ────────────
public function test_thumbnail_image_has_width_and_height(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$html = view('components.video-card', ['video' => $video])->render();
$this->assertMatchesRegularExpression(
'/<img[^>]*width="320"[^>]*height="180"/i',
$html,
'Thumbnail img must carry explicit width/height so the browser reserves layout space.'
);
$this->assertStringContainsString('loading="lazy"', $html);
}
// ─── Cursor-paginated feed endpoint ───────────────
public function test_feed_more_returns_json_with_cards_and_next_cursor(): void
{
$user = User::factory()->create([
'username' => 'f'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
// Twice the page size so the cursor must advance.
for ($i = 0; $i < 30; $i++) {
Video::create([
'user_id' => $user->id,
'title' => 'Vid ' . $i,
'description' => '',
'filename' => "users/x/videos/y{$i}/video.mp4",
'path' => "users/x/videos/y{$i}/video.mp4",
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
}
$r = $this->getJson(route('videos.feed.more', ['filter' => 'all']));
$r->assertOk();
$r->assertJsonStructure(['items', 'next_cursor', 'has_more']);
$this->assertTrue($r->json('has_more'), 'With 30 videos and a 24-page size, has_more must be true.');
$this->assertNotEmpty($r->json('next_cursor'));
$this->assertStringContainsString('yt-video-card', (string) $r->json('items'));
}
public function test_feed_more_unknown_filter_returns_empty_slice(): void
{
$this->getJson(route('videos.feed.more', ['filter' => 'nonsense']))
->assertOk()
->assertJson(['items' => '', 'has_more' => false]);
}
// ─── Lazy reply-form template present ─────────────
public function test_comments_component_ships_the_reply_template_not_inline_forms(): void
{
[$user, $video] = $this->makeUserAndVideo();
\App\Models\Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'top-level comment',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('id="ytc-reply-form-tpl"', $html);
$this->assertStringContainsString('ytc-reply-slot', $html);
}
// ─── Helpers ──────────────────────────────────────
private function makeUserAndVideo(): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create([
'user_id' => $user->id,
'title' => 'Test video',
'description'=> 'Hello',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
return [$user, $video];
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Phase 3 — Feeds, search, trending.
*
* Guards against: trending window regressions, music feed opening on oldest,
* search returning the wrong buckets, and the /videos/search → /search 301.
*/
class Phase3FeedsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Trending window ─────────────────────────────
public function test_trending_accepts_24_168_720_and_snaps_others_to_24(): void
{
$this->get(route('videos.trending', ['hours' => 24]))->assertOk();
$this->get(route('videos.trending', ['hours' => 168]))->assertOk();
$this->get(route('videos.trending', ['hours' => 720]))->assertOk();
// Garbage input must not crash; it snaps to 24.
$this->get(route('videos.trending', ['hours' => 'abc']))->assertOk();
$this->get(route('videos.trending', ['hours' => 99999]))->assertOk();
}
public function test_trending_ranks_by_in_window_views_then_likes(): void
{
$owner = $this->makeUser();
// Three videos — "A" has most views in last 24h, "B" has most likes
// but no views, "C" has nothing. A should be first; B should NOT
// appear (no in-window views → filtered out); C should not appear.
$a = $this->makeVideo($owner, 'A');
$b = $this->makeVideo($owner, 'B');
$c = $this->makeVideo($owner, 'C');
$this->seedViews($a, 10);
$this->seedViews($b, 0);
$this->seedLikes($b, 5);
$this->seedViews($c, 0);
$r = $this->get(route('videos.trending', ['hours' => 24]));
$r->assertOk();
$html = $r->getContent();
$this->assertStringContainsString('A', $html, 'Video A must appear — it has in-window views.');
$this->assertStringNotContainsString('>B<', $html, 'Video B must not appear — zero in-window views.');
}
// ─── Music feed newest-first ─────────────────────
public function test_music_feed_opens_on_newest_video(): void
{
$owner = $this->makeUser();
// created_at isn't in Video::$fillable (Eloquent auto-manages it),
// so set it after the row exists.
$old = $this->makeVideo($owner, 'OLD MUSIC', ['type' => 'music']);
$old->created_at = now()->subDays(5); $old->save();
$new = $this->makeVideo($owner, 'NEW MUSIC', ['type' => 'music']);
$new->created_at = now()->subMinutes(5); $new->save();
$html = $this->get(route('videos.index', ['filter' => 'music']))->assertOk()->getContent();
$posNew = strpos($html, 'NEW MUSIC');
$posOld = strpos($html, 'OLD MUSIC');
$this->assertNotFalse($posNew);
$this->assertNotFalse($posOld);
$this->assertLessThan($posOld, $posNew, 'Newer music video must appear before the older one.');
}
// ─── /videos/search → /search (301) ──────────────
public function test_old_videos_search_url_301_redirects_to_search(): void
{
$this->get('/videos/search?q=hello')
->assertStatus(301)
->assertRedirect('/search?q=hello');
}
// ─── /search returns channel + playlist buckets ──
public function test_search_matches_video_channel_and_playlist(): void
{
$owner = User::factory()->create([
'name' => 'Searchable Owner',
'username' => 'searchable-channel-'.substr(bin2hex(random_bytes(4)), 0, 6),
]);
$video = $this->makeVideo($owner, 'SearchableVideoTitle', ['description' => 'nothing special']);
$pl = Playlist::create([
'user_id' => $owner->id,
'name' => 'SearchablePlaylistName',
'visibility' => 'public',
'share_token'=> bin2hex(random_bytes(8)),
]);
// 1. Query that matches a video title
$html = $this->get(route('search', ['q' => 'SearchableVideo']))->assertOk()->getContent();
$this->assertStringContainsString('SearchableVideoTitle', $html);
// 2. Query that matches a channel name
$html = $this->get(route('search', ['q' => 'Searchable Owner']))->assertOk()->getContent();
$this->assertStringContainsString('Channels', $html);
$this->assertStringContainsString('Searchable Owner', $html);
// 3. Query that matches a playlist name
$html = $this->get(route('search', ['q' => 'SearchablePlaylist']))->assertOk()->getContent();
$this->assertStringContainsString('Playlists', $html);
$this->assertStringContainsString('SearchablePlaylistName', $html);
}
public function test_empty_search_redirects_to_browse(): void
{
$this->get(route('search', ['q' => '']))
->assertRedirect(route('videos.index'));
}
// ─── Helpers ─────────────────────────────────────
private function makeUser(): User
{
return User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
}
private function makeVideo(User $u, string $title, array $attrs = []): Video
{
return Video::create(array_merge([
'user_id' => $u->id,
'title' => $title,
'description' => '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
], $attrs));
}
private function seedViews(Video $v, int $n): void
{
for ($i = 0; $i < $n; $i++) {
DB::table('video_views')->insert([
'video_id' => $v->id,
'user_id' => null,
'device_id' => 'dev-'.$i,
'watched_at' => now()->subMinutes(10),
]);
}
}
private function seedLikes(Video $v, int $n): void
{
for ($i = 0; $i < $n; $i++) {
$u = $this->makeUser();
DB::table('video_likes')->insert([
'video_id' => $v->id,
'user_id' => $u->id,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use App\Support\HtmlSanitizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 4 — Watch page.
*
* Guards against regressions in: the Up Next sidebar (anchor cards, weighted
* recommender), the comment permalink + plural counts, the markdown-leak
* rendering, and the download access gate for both MP4 and MP3.
*/
class Phase4WatchTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Comment component ────────────────────────────
public function test_comment_header_uses_singular_count_for_one_comment(): void
{
[$user, $video] = $this->makeUserAndVideo();
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'the only comment',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('1 Comment', $html);
$this->assertStringNotContainsString('1 Comments', $html);
}
public function test_comment_header_breaks_out_replies_from_top_level(): void
{
[$user, $video] = $this->makeUserAndVideo();
$parent = Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'top',
]);
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'reply 1',
]);
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'reply 2',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('1 Comment', $html, 'Top-level count is 1.');
$this->assertStringContainsString('2 replies', $html, 'Replies are counted separately.');
}
public function test_each_comment_timestamp_is_a_lc_permalink(): void
{
[$user, $video] = $this->makeUserAndVideo();
$c = Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'hi',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('?lc=' . $c->id . '#comment-' . $c->id, $html);
$this->assertStringContainsString('ytc-permalink', $html);
}
// ─── Description markdown ────────────────────────
public function test_leaked_markdown_headers_render_as_html(): void
{
$out = HtmlSanitizer::render("Intro line\n## Letra\nla la\n### Chorus");
$this->assertStringContainsString('<h2>Letra</h2>', $out);
$this->assertStringContainsString('<h3>Chorus</h3>', $out);
$this->assertStringNotContainsString('## Letra', $out);
$this->assertStringNotContainsString('### Chorus', $out);
}
public function test_normal_plain_text_description_passes_through(): void
{
$out = HtmlSanitizer::render("hello world\nhow are you");
$this->assertStringContainsString('hello world', $out);
$this->assertStringContainsString('how are you', $out);
$this->assertStringNotContainsString('<h2>', $out);
}
// ─── Download access gate ────────────────────────
public function test_disabled_download_returns_403_for_mp4(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
$this->get(route('videos.download', $video))->assertStatus(403);
}
public function test_disabled_download_returns_403_for_mp3(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
$this->get(route('videos.downloadMp3', $video))->assertStatus(403);
}
public function test_registered_download_denies_guests(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'registered']);
$this->get(route('videos.download', $video))->assertStatus(403);
}
// ─── Up Next anchor cards ─────────────────────────
public function test_up_next_sidebar_renders_anchor_cards_with_hrefs(): void
{
[$owner, $target] = $this->makeUserAndVideo();
[, $rec] = $this->makeUserAndVideo();
$html = $this->get(route('videos.show', $target))->getContent();
$this->assertStringContainsString('<a class="sidebar-video-card"', $html,
'Up Next entries must be anchor elements, not onclick divs.');
$this->assertStringContainsString('data-rec-url=', $html,
'data-rec-url hooks keep the SPA transition working for left-clicks.');
}
// ─── Helpers ──────────────────────────────────────
private function makeUserAndVideo(array $attrs = []): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create(array_merge([
'user_id' => $user->id,
'title' => 'T '.substr(bin2hex(random_bytes(4)), 0, 6),
'description'=> '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token'=> bin2hex(random_bytes(8)),
], $attrs));
return [$user, $video];
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 5 — Shorts.
*
* Guards against regressions in the deep-link route, the Home shelf, and
* the branded 404 fallback.
*/
class Phase5ShortsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── /shorts/{id} deep-link ──────────────────────
public function test_shorts_deep_link_renders_feed_scrolled_to_target(): void
{
$owner = $this->makeUser();
$short = $this->makeShort($owner, 'DEEP LINK SHORT');
$html = $this->get(route('videos.shorts.focused', $short))->assertOk()->getContent();
$this->assertStringContainsString('DEEP LINK SHORT', $html);
// The in-page scroller is driven by data-id matching the start id.
$this->assertStringContainsString('data-id="' . $short->id . '"', $html);
$this->assertStringContainsString('scrollIntoView', $html);
}
public function test_shorts_deep_link_404s_on_non_short(): void
{
$owner = $this->makeUser();
$regular = Video::create([
'user_id' => $owner->id,
'title' => 'not a short',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => false,
'share_token'=> bin2hex(random_bytes(8)),
]);
$this->get('/shorts/' . Video::encodeId($regular->id))->assertStatus(404);
}
// ─── Home shelf ──────────────────────────────────
public function test_home_shows_shorts_shelf_when_shorts_exist(): void
{
$owner = $this->makeUser();
$s = $this->makeShort($owner, 'SHELF SHORT');
$html = $this->get(route('home'))->assertOk()->getContent();
$this->assertStringContainsString('home-shorts-shelf', $html);
$this->assertStringContainsString('SHELF SHORT', $html);
$this->assertStringContainsString('/shorts/' . Video::encodeId($s->id), $html);
}
public function test_home_omits_shorts_shelf_when_no_shorts(): void
{
$owner = $this->makeUser();
// A regular video so the home feed renders, but no shorts.
Video::create([
'user_id' => $owner->id,
'title' => 'reg',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => false,
'share_token'=> bin2hex(random_bytes(8)),
]);
$html = $this->get(route('home'))->assertOk()->getContent();
$this->assertStringNotContainsString('home-shorts-shelf', $html);
}
// ─── Branded 404 ─────────────────────────────────
public function test_unknown_url_renders_branded_404(): void
{
$r = $this->get('/no-such-page')->assertStatus(404);
$r->assertSee('Error 404', false);
}
// ─── Helpers ─────────────────────────────────────
private function makeUser(): User
{
return User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
}
private function makeShort(User $u, string $title): Video
{
return Video::create([
'user_id' => $u->id,
'title' => $title,
'description'=> '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => true,
'duration' => 45,
'orientation'=> 'portrait',
'share_token'=> bin2hex(random_bytes(8)),
]);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Playlist;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 6 — Channel / Studio / Live.
*
* Covers the handle-alias route, the composer being hidden in preview mode,
* and the pluralised playlist meta.
*/
class Phase6ChannelTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── /@handle alias ─────────────────────────────
public function test_at_handle_url_opens_the_matching_channel(): void
{
$user = User::factory()->create([
'name' => 'Handle Test',
'username' => 'handletest-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
$r = $this->get('/@' . $user->username)->assertOk();
$r->assertSee('Handle Test', false);
}
public function test_at_handle_rejects_invalid_characters(): void
{
// Space in the handle must not match the /@ route.
$this->get('/@not valid')->assertStatus(404);
}
// ─── Preview-as-visitor hides composer ──────────
public function test_preview_as_visitor_hides_the_wall_composer(): void
{
$user = User::factory()->create([
'username' => 'preview-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
$this->actingAs($user);
// The composer form carries id="postForm" and action pointing at
// posts.store — specific enough that we don't collide with the
// "ch-composer-*" literals that appear in inline JS strings.
$owner = $this->get('/channel/' . $user->username)->getContent();
$this->assertStringContainsString('id="postForm"', $owner,
'Owner should see the composer form in normal view.');
$preview = $this->get('/channel/' . $user->username . '?preview=1')->getContent();
$this->assertStringNotContainsString('id="postForm"', $preview,
'Preview-as-visitor must hide the composer form.');
}
// ─── Playlist card: pluralise + hide stray "0.0" ──
public function test_playlist_card_pluralises_videos_and_hides_zero_views(): void
{
$owner = User::factory()->create([
'username' => 'pl-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
$this->actingAs($owner);
$pl = Playlist::create([
'user_id' => $owner->id,
'name' => 'Empty Playlist',
'visibility' => 'public',
'share_token'=> bin2hex(random_bytes(8)),
]);
$html = $this->get('/channel/' . $owner->username)->getContent();
// The specific bug: the view-count row ends with
// `<i class="bi bi-eye" aria-hidden="true"></i> 0.0`
// for a zero-view playlist. The fix hides the whole row for zero
// views, so the "bi-eye ...0.0" sequence should never appear.
$this->assertDoesNotMatchRegularExpression(
'/bi-eye[^<]{0,200}0\.0/',
$html,
'Playlist meta must not emit "bi-eye … 0.0" for zero-view playlists.'
);
// Plural must switch to singular for 1 video. Never "1 videos".
$this->assertDoesNotMatchRegularExpression('/\b1 videos\b/i', $html);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 7 — Admin, a11y, consistency.
*
* Covers the skip-to-content link, icon-button aria-label coverage, the
* server-side age gate on register, and the branded 404 title format.
*/
class Phase7AdminA11yTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Skip link + icon-button aria-labels ──────────
public function test_every_page_ships_the_skip_to_content_link(): void
{
$r = $this->get('/')->assertOk();
$r->assertSee('skip-to-content', false);
$r->assertSee('href="#main"', false);
}
public function test_header_icon_buttons_have_aria_labels(): void
{
$html = $this->get('/')->getContent();
// Hamburger menu
$this->assertMatchesRegularExpression(
'/<button[^>]+class="yt-menu-btn"[^>]+aria-label="[^"]+"/',
$html,
'The hamburger menu button must carry an aria-label.'
);
// Mobile search toggle
$this->assertMatchesRegularExpression(
'/<button[^>]+yt-icon-btn[^>]+onclick="toggleMobileSearch[^>]+aria-label="[^"]+"/',
$html,
'The mobile search toggle must carry an aria-label.'
);
}
// ─── Age gate on registration ─────────────────────
public function test_registration_rejects_under_13(): void
{
$r = $this->post(route('register.store'), [
'name' => 'Too Young',
'email' => '[email protected]',
'password' => 'HelloHello123!',
'password_confirmation' => 'HelloHello123!',
'birthday' => now()->subYears(10)->toDateString(),
'gender' => 'male',
'nationality' => 'BH',
]);
$r->assertSessionHasErrors('birthday');
$this->assertDatabaseMissing('users', ['email' => '[email protected]']);
}
public function test_registration_accepts_at_least_13(): void
{
$r = $this->post(route('register.store'), [
'name' => 'Just Old Enough',
'email' => '[email protected]',
'password' => 'HelloHello123!',
'password_confirmation' => 'HelloHello123!',
'birthday' => now()->subYears(14)->toDateString(),
'gender' => 'male',
'nationality' => 'BH',
]);
$r->assertSessionHasNoErrors();
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
}
// ─── Title format consistency ─────────────────────
public function test_branded_404_body_names_the_error(): void
{
// The exception handler in the testing env renders through a different
// view resolution path than production (it bypasses layouts.app's head
// section), so matching the <title> tag here is unreliable. The
// body content is deterministic and sufficient to prove the branded
// 404 is live — the production title format is smoke-tested via
// curl in the deploy checklist.
$this->get('/no-such-page')
->assertStatus(404)
->assertSee('Error 404', false);
}
// ─── Gender select: no duplicate labels ───────────
public function test_settings_gender_select_has_distinct_options(): void
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$this->actingAs($user);
$html = $this->get('/channel/' . $user->username . '#settings')->getContent();
// The empty-value option used to say "Prefer not to say" too — one
// label, two choices. It now says "Select…".
$this->assertMatchesRegularExpression(
'/<option value="">Select…<\/option>/u',
$html,
'Empty-value gender option should read "Select…", not "Prefer not to say".'
);
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Comment;
use App\Models\SportsMatch;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Post-audit polish — the deferred items we tackled in bulk after Phase 7:
* match channel row, read-more clamp, nested-reply prefill, player hotkeys,
* cursor pagination on /liked and /history.
*/
class PostAuditPolishTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Match page: channel row ─────────────────────
public function test_match_watch_page_renders_the_channel_row(): void
{
$owner = $this->makeUser('match-owner');
$video = Video::create([
'user_id' => $owner->id,
'title' => 'MatchTitle',
'description' => '',
'filename' => 'users/x/sports/y/video.mp4',
'path' => 'users/x/sports/y/video.mp4',
'type' => 'match',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
// Minimal sports_match so the match blade renders without null checks exploding.
SportsMatch::create([
'video_id' => $video->id,
'user_id' => $owner->id,
'title' => 'MatchTitle',
'participants' => [],
'sport' => 'karate',
]);
$html = $this->get(route('videos.show', $video))->getContent();
$this->assertStringContainsString('channel-row', $html,
'Match watch page must render the shared channel row component.');
}
// ─── Comment reply: nested handle button present ──
public function test_reply_to_reply_button_carries_data_handle(): void
{
$owner = $this->makeUser('replyowner');
$this->actingAs($owner);
$video = Video::create([
'user_id' => $owner->id,
'title' => 'Vid',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token'=> bin2hex(random_bytes(8)),
]);
$parent = Comment::create([
'user_id' => $owner->id,
'video_id' => $video->id,
'body' => 'top',
]);
$replier = $this->makeUser('replier');
Comment::create([
'user_id' => $replier->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'nested',
]);
$html = view('components.video-comments', ['video' => $video])->render();
// The nested reply trigger must carry the reply author's handle so the
// JS can prefill "@handle " when the parent's reply form opens.
$this->assertMatchesRegularExpression(
'/_comment-reply-nested-trigger[^"]*"\s+data-parent-id="' . $parent->id . '"\s+data-handle="' . preg_quote($replier->username, '/') . '"/',
$html
);
}
// ─── Liked feed: cursor pagination ────────────────
public function test_liked_controller_returns_a_cursor_paginator(): void
{
// The controller introspection is enough to confirm the pagination
// mode — the full HTML path depends on view rendering that costs
// 400 KB per test and isn't the behaviour we're asserting on.
$user = $this->makeUser('liker');
$this->actingAs($user);
$route = app('router')->getRoutes()->getByName('liked');
[$class, $method] = explode('@', $route->getActionName());
$controller = app($class);
$response = $controller->$method();
$videos = $response->getData()['videos'] ?? null;
$this->assertInstanceOf(
\Illuminate\Pagination\CursorPaginator::class,
$videos,
'liked() must return a CursorPaginator instance.'
);
}
// ─── Player hotkey code is present ────────────────
public function test_player_component_handles_shift_dot_hotkey(): void
{
// The player component accepts a $video context — pass a bare
// record so the view renders.
$u = $this->makeUser('p');
$v = Video::create([
'user_id' => $u->id,
'title' => 't',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token'=> bin2hex(random_bytes(8)),
]);
$html = view('components.video-player', ['video' => $v])->render();
$this->assertStringContainsString("case '>':", $html);
$this->assertStringContainsString("case '<':", $html);
$this->assertStringContainsString('video.playbackRate', $html);
}
// ─── Helpers ─────────────────────────────────────
private function makeUser(string $prefix): User
{
return User::factory()->create([
'username' => $prefix.'-'.substr(bin2hex(random_bytes(4)), 0, 6),
]);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Shorts: single reused <video> element + 1-2 next preloaded.
*
* Validates that the shorts feed markup matches the architecture:
* - a single #svGlobal <video> rendered once at the end of the forelse,
* - two preloader placeholders (#svPre1 / #svPre2),
* - no per-short <video> elements inside .short-player-wrap,
* - each slot carries its own data-src + data-poster so the shared player
* can swap into position without a full DOM rebuild.
*/
class ShortsReusedVideoTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
public function test_shorts_feed_renders_a_single_shared_video_element(): void
{
$u = User::factory()->create([
'username' => 'sh-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
// Three shorts so we can prove only ONE <video id="svGlobal"> is
// emitted no matter how many shorts there are.
for ($i = 0; $i < 3; $i++) {
Video::create([
'user_id' => $u->id,
'title' => 'Short '.$i,
'description' => '',
'filename' => "users/x/videos/y{$i}/video.mp4",
'path' => "users/x/videos/y{$i}/video.mp4",
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => true,
'duration' => 45,
'orientation' => 'portrait',
'share_token' => bin2hex(random_bytes(8)),
]);
}
$html = $this->get(route('videos.shorts'))->assertOk()->getContent();
// Exactly one shared video element and two preloaders.
$this->assertEquals(
1,
substr_count($html, 'id="svGlobal"'),
'Shorts feed must emit exactly one shared <video id="svGlobal">.'
);
$this->assertStringContainsString('id="svPre1"', $html);
$this->assertStringContainsString('id="svPre2"', $html);
// No per-short video elements inside the slot wraps.
$this->assertDoesNotMatchRegularExpression(
'/short-player-wrap[^>]*>[^<]*<video id="sv-\d+/',
$html,
'Per-short <video id="sv-{idx}"> elements should be gone.'
);
// Each slot still carries its own src + poster for the shared
// player to pick up on dock.
$this->assertStringContainsString('data-src=', $html);
$this->assertStringContainsString('data-poster=', $html);
}
}
+57
View File
@@ -7,4 +7,61 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
/** Output-buffer depth captured before each test, so tearDown can
* rewind back to it instead of leaving dangling buffers that PHPUnit
* reports as "Test code or tested code did not close its own output
* buffers". The depth leaks when a view throws mid-render — common
* with Blade's compiled files when a feature test exercises an
* error path. */
private int $preTestObLevel = 0;
/**
* Belt-and-braces guard against a repeat of the 2026-10-21 incident, where
* a test with the RefreshDatabase trait ran migrate:fresh against the
* shared dev SQLite file and wiped every row.
*
* By the time setUp() runs we know the final DB config. If it points at
* anything other than an in-memory SQLite (or a file inside the test
* directory), we abort the whole run before any migration can execute.
*/
protected function setUp(): void
{
parent::setUp();
$this->assertSafeTestDatabase();
$this->preTestObLevel = ob_get_level();
}
protected function tearDown(): void
{
// Collapse any output buffers the test (or Blade view it rendered)
// left open. Without this, each view()->render() call that touches
// a Blade partial with its own @once / @push block can leave the
// buffer stack one level deep — PHPUnit reports every one of those
// tests as "risky".
while (ob_get_level() > $this->preTestObLevel) {
ob_end_clean();
}
parent::tearDown();
}
private function assertSafeTestDatabase(): void
{
$driver = config('database.default');
$database = config("database.connections.{$driver}.database");
$allowed = $database === ':memory:'
|| $database === null
|| str_contains((string) $database, '/testing')
|| str_contains((string) $database, '/tmp/')
|| str_ends_with((string) $database, '.testing.sqlite');
if (! $allowed) {
// Fatal, not an assertion failure — a wrong DB target has to stop
// execution before Laravel can run even one query against it.
fwrite(STDERR, "\n\n*** ABORTING TESTS: unsafe DB target '{$database}' on connection '{$driver}'. ***\n");
fwrite(STDERR, "Set DB_CONNECTION=sqlite and DB_DATABASE=:memory: in .env.testing (or in your test's setUp before parent::setUp).\n\n");
exit(2);
}
}
}