Compare commits
4
Commits
5aef41201a
...
73de5bc97e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73de5bc97e | ||
|
|
341e94a4f8 | ||
|
|
574c9581bb | ||
|
|
22d6b1507d |
No files matched your search
@@ -325,29 +325,41 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watch history
|
// Watch history
|
||||||
public function history()
|
public function history(Request $request)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Get videos the user has watched, ordered by most recently watched
|
// Latest-first by watched_at. Paginate at the join so a user who
|
||||||
// Include private videos since they are the user's own
|
// has watched tens of thousands of videos doesn't pull every row
|
||||||
$videoIds = \DB::table('video_views')
|
// into memory on each page load. Fetch the videos keyed by latest
|
||||||
->where('user_id', $user->id)
|
// view so sort order is deterministic even when the same video has
|
||||||
->orderBy('watched_at', 'desc')
|
// been watched more than once.
|
||||||
->pluck('video_id')
|
//
|
||||||
->unique();
|
// Each history row is one (video_views + videos) pair, with the
|
||||||
|
// uniqueness constraint deferred to presentation: duplicates per
|
||||||
$videos = Video::whereIn('id', $videoIds)
|
// 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) {
|
->where(function ($q) use ($user) {
|
||||||
$q->where('visibility', '!=', 'private')
|
$q->where('videos.visibility', '!=', 'private')
|
||||||
->orWhere('user_id', $user->id);
|
->orWhere('videos.user_id', $user->id);
|
||||||
})
|
})
|
||||||
->get()
|
->orderBy('video_views.watched_at', 'desc')
|
||||||
->sortBy(function ($video) use ($videoIds) {
|
->select('videos.id as id', 'video_views.watched_at')
|
||||||
return $videoIds->search($video->id);
|
->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
|
// Clear watch history
|
||||||
@@ -392,14 +404,18 @@ class UserController extends Controller
|
|||||||
public function liked()
|
public function liked()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$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()
|
$videos = $user->likes()
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
$q->where('visibility', '!=', 'private')
|
$q->where('visibility', '!=', 'private')
|
||||||
->orWhere('videos.user_id', $user->id);
|
->orWhere('videos.user_id', $user->id);
|
||||||
})
|
})
|
||||||
->latest()
|
->latest('videos.id')
|
||||||
->paginate(12);
|
->cursorPaginate(24);
|
||||||
|
|
||||||
return view('user.liked', compact('videos'));
|
return view('user.liked', compact('videos'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,15 @@
|
|||||||
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
|
<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>
|
<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>
|
</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)
|
@if($llmEnabled)
|
||||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
|
<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>
|
<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>
|
||||||
@@ -515,6 +524,31 @@
|
|||||||
white-space: pre-wrap;
|
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 ── */
|
/* ── Actions ── */
|
||||||
.ytc-actions {
|
.ytc-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -999,6 +1033,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
|||||||
const rt = e.target.closest('._comment-reply-trigger');
|
const rt = e.target.closest('._comment-reply-trigger');
|
||||||
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
|
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
|
// Edit trigger
|
||||||
const et = e.target.closest('._comment-edit-trigger');
|
const et = e.target.closest('._comment-edit-trigger');
|
||||||
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
||||||
@@ -1467,6 +1521,32 @@ function toggleDislike(btn) {
|
|||||||
function initLikeStates() {} // server-rendered state is already in the HTML
|
function initLikeStates() {} // server-rendered state is already in the HTML
|
||||||
|
|
||||||
// ── Timestamp & @mention parsing ─────────────────────
|
// ── 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) {
|
function enhanceBody(el) {
|
||||||
if (!el || el.dataset._commentEnhanced === '1') return;
|
if (!el || el.dataset._commentEnhanced === '1') return;
|
||||||
let text = el.textContent || '';
|
let text = el.textContent || '';
|
||||||
@@ -1496,6 +1576,9 @@ function enhanceBody(el) {
|
|||||||
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
|
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() {
|
function enhanceAll() {
|
||||||
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
||||||
|
|||||||
@@ -1343,6 +1343,25 @@ document.addEventListener('keydown', e => {
|
|||||||
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
||||||
case 'l': case 'L':
|
case 'l': case 'L':
|
||||||
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
|
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':
|
case 'ArrowLeft':
|
||||||
e.preventDefault(); seekRelative(-5); showControls(); break;
|
e.preventDefault(); seekRelative(-5); showControls(); break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
|
|||||||
@@ -146,7 +146,8 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</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
|
@endif
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@@ -2418,6 +2418,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- ===== END EVENT HEADER ===== -->
|
<!-- ===== 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])
|
@include('videos.partials.description-box', ['video' => $video])
|
||||||
|
|
||||||
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
|
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user