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]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-02 12:18:20 +03:00
1 parent 20c8fdcdf3
commit 2d33e39b9b
8 files changed
+749 -22

No files matched your search

+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;
}
}
+56 -1
View File
@@ -8,6 +8,7 @@ use App\Models\Video;
use App\Notifications\NewCommentLikeNotification; use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification; use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification; use App\Notifications\NewReplyNotification;
use App\Services\LlmLyricsService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -15,7 +16,7 @@ class CommentController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index']); $this->middleware('auth')->except(['index', 'translate']);
} }
public function index(Video $video) public function index(Video $video)
@@ -34,6 +35,29 @@ class CommentController extends Controller
$commenter = Auth::user(); $commenter = Auth::user();
// Idempotency guard: a client that retries (duplicate listeners after an
// SPA swap, fast double-click, a flaky connection) can POST the identical
// body twice. Treat an identical comment from the same user to the same
// video within 5 seconds as the same submission and return the first one.
$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(5))
->latest('id')
->first();
if ($existing) {
return response()->json([
'success' => true,
'comment' => $existing->load('user:id,name,avatar'),
]);
}
$comment = $video->comments()->create([ $comment = $video->comments()->create([
'user_id' => $commenter->id, 'user_id' => $commenter->id,
'body' => $request->body, 'body' => $request->body,
@@ -89,6 +113,37 @@ class CommentController extends Controller
return response()->json(['success' => true]); 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) public function like(Comment $comment)
{ {
$userId = Auth::id(); $userId = Auth::id();
+48 -1
View File
@@ -26,7 +26,7 @@ class VideoController extends Controller
{ {
public function __construct() 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', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription']);
} }
public function index() public function index()
@@ -913,6 +913,53 @@ class VideoController extends Controller
* mirror + the NAS copy and removes any in-flight progress / temp files, * mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result. * 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) public function deleteLyrics(Request $request, Video $video)
{ {
if (Auth::id() !== $video->user_id) { if (Auth::id() !== $video->user_id) {
+100
View File
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
'password' => 'hashed', 'password' => 'hashed',
'two_factor_enabled' => 'boolean', 'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array', '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) // Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [ private const NOTIF_DEFAULTS = [
'notif_new_comment' => true, '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 public static function generateUniqueUsername(string $name): string
{ {
$base = substr(Str::slug(trim($name)), 0, 18); $base = substr(Str::slug(trim($name)), 0, 18);
+190 -16
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. */ /** 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(); $p = $this->activeProvider();
if (! $p) return ''; if (! $p) return '';
try { try {
return match ($p['kind']) { return match ($p['kind']) {
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens), 'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens), 'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
default => $this->callOllama($p, $prompt, $maxTokens), default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
}; };
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']); 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'), '/'); $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', [ $resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
'model' => $p['model'], 'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]], 'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
'stream' => false, '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()) { if (! $resp->successful()) {
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return ''; return '';
} }
$j = $resp->json(); $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'), '/'); $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
$resp = Http::timeout(120)->withHeaders([ $resp = Http::timeout(120)->withHeaders([
@@ -233,9 +406,10 @@ class LlmLyricsService
'anthropic-version' => '2023-06-01', 'anthropic-version' => '2023-06-01',
'content-type' => 'application/json', 'content-type' => 'application/json',
])->post($endpoint . '/v1/messages', [ ])->post($endpoint . '/v1/messages', [
'model' => $p['model'], 'model' => $p['model'],
'max_tokens' => $maxTokens, 'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]], 'temperature' => $temperature,
'messages' => [['role' => 'user', 'content' => $prompt]],
]); ]);
if (! $resp->successful()) { if (! $resp->successful()) {
Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
@@ -245,7 +419,7 @@ class LlmLyricsService
return (string) ($j['content'][0]['text'] ?? ''); 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'), '/'); $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
$resp = Http::timeout(120)->withToken((string) $p['api_key']) $resp = Http::timeout(120)->withToken((string) $p['api_key'])
@@ -254,7 +428,7 @@ class LlmLyricsService
'model' => $p['model'], 'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]], 'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens, 'max_tokens' => $maxTokens,
'temperature' => 0.2, 'temperature' => $temperature,
]); ]);
if (! $resp->successful()) { if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
@@ -4,6 +4,12 @@
use App\Models\CommentLike; use App\Models\CommentLike;
$currentUserId = Auth::id() ?? 0; $currentUserId = Auth::id() ?? 0;
$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);
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get(); $topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count()); $commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
@@ -89,11 +95,16 @@
</a> </a>
<div class="ytc-body-wrap"> <div class="ytc-body-wrap">
<div class="ytc-meta"> <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> <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> <span class="ytc-time">{{ ytcTime($comment->created_at) }}</span>
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</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 --}} {{-- Edit form --}}
@if($isOwn) @if($isOwn)
@@ -120,6 +131,12 @@
@auth @auth
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button> <button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
@endauth @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) @if($isOwn)
<div class="ytc-more-wrap"> <div class="ytc-more-wrap">
<button class="ytc-more-btn" title="More actions"> <button class="ytc-more-btn" title="More actions">
@@ -172,10 +189,15 @@
</a> </a>
<div class="ytc-body-wrap"> <div class="ytc-body-wrap">
<div class="ytc-meta"> <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> <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> <span class="ytc-time">{{ ytcTime($reply->created_at) }}</span>
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</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) @if($replyIsOwn)
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none"> <div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
@@ -197,6 +219,12 @@
<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>
@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) @if($replyIsOwn)
<div class="ytc-more-wrap"> <div class="ytc-more-wrap">
<button class="ytc-more-btn"> <button class="ytc-more-btn">
@@ -498,6 +526,57 @@
} }
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); } .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 ── */ /* ── Three-dot menu ── */
.ytc-more-wrap { .ytc-more-wrap {
position: relative; position: relative;
@@ -695,9 +774,16 @@ const YTC = {
videoId: '{{ $video->getRouteKey() }}', videoId: '{{ $video->getRouteKey() }}',
csrf: '{{ csrf_token() }}', csrf: '{{ csrf_token() }}',
userId: {{ $currentUserId }}, userId: {{ $currentUserId }},
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
deleteId: null, deleteId: null,
toastTimer: 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 ────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────
function esc(s) { function esc(s) {
@@ -904,8 +990,78 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
// Dislike btn // Dislike btn
const db = e.target.closest('.ytc-dislike-btn'); const db = e.target.closest('.ytc-dislike-btn');
if (db) { e.stopPropagation(); toggleDislike(db); return; } if (db) { e.stopPropagation(); toggleDislike(db); return; }
}); });
// 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 ──────────────────────────────────────── // ── Reply form ────────────────────────────────────────
function toggleReplyForm(commentId) { function toggleReplyForm(commentId) {
const form = document.getElementById('replyForm' + commentId); const form = document.getElementById('replyForm' + commentId);
@@ -970,10 +1126,12 @@ async function saveEdit(commentId) {
// ── Post comment ────────────────────────────────────── // ── Post comment ──────────────────────────────────────
async function postComment() { 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'); const ta = document.getElementById('ytcTextarea');
if (!ta) return; if (!ta) return;
const text = ta.value.trim(); const text = ta.value.trim();
if (!text) { toast('Write something first', 'error'); return; } if (!text) { toast('Write something first', 'error'); return; }
postComment._busy = true;
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Posting…'; submitBtn.textContent = 'Posting…';
try { try {
@@ -1000,6 +1158,7 @@ async function postComment() {
} catch (err) { } catch (err) {
toast(err.message, 'error'); toast(err.message, 'error');
} finally { } finally {
postComment._busy = false;
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = 'Comment'; submitBtn.textContent = 'Comment';
} }
@@ -1012,7 +1171,12 @@ async function postReply(vid, parentId) {
const text = input.value.trim(); const text = input.value.trim();
if (!text) { toast('Write something first', 'error'); return; } if (!text) { toast('Write something first', 'error'); return; }
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger'); 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 { try {
const r = await fetch('/videos/' + vid + '/comments', { const r = await fetch('/videos/' + vid + '/comments', {
method: 'POST', method: 'POST',
@@ -1035,7 +1199,7 @@ async function postReply(vid, parentId) {
} catch (err) { } catch (err) {
toast(err.message, 'error'); toast(err.message, 'error');
} finally { } finally {
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; } if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
} }
} }
@@ -1133,6 +1297,12 @@ function buildCommentEl(c, isReply) {
</div>` : ''; </div>` : '';
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : ''; 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'); const div = document.createElement('div');
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : ''); div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
@@ -1143,10 +1313,12 @@ function buildCommentEl(c, isReply) {
</a> </a>
<div class="ytc-body-wrap"> <div class="ytc-body-wrap">
<div class="ytc-meta"> <div class="ytc-meta">
${flagEl}
<span class="ytc-author">${name}</span> <span class="ytc-author">${name}</span>
<span class="ytc-time">${time}</span> <span class="ytc-time">${time}</span>
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div> <div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
${translationPanel}
${editForm} ${editForm}
<div class="ytc-actions"> <div class="ytc-actions">
<button class="ytc-like-btn" data-id="${id}" title="Like"> <button class="ytc-like-btn" data-id="${id}" title="Like">
@@ -1157,6 +1329,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> <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>
${replyBtn} ${replyBtn}
${translateBtn}
${moreMenu} ${moreMenu}
</div> </div>
${replyForm} ${replyForm}
@@ -7,7 +7,12 @@
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id; $isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? ''); $renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot); $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 @endphp
@if ($showBox) @if ($showBox)
<style> <style>
@@ -36,6 +41,27 @@
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); } .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 i { font-size:14px; transition:transform .2s ease; }
.vdb-show-more.expanded i { transform:rotate(180deg); } .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> </style>
<div class="vdb-wrap" id="vdbWrap"> <div class="vdb-wrap" id="vdbWrap">
@@ -51,6 +77,9 @@
</div> </div>
<div class="vdb-panel active" id="vdb-about"> <div class="vdb-panel active" id="vdb-about">
@if($aboutPrepend !== null)
{!! $aboutPrepend !!}
@endif
<div class="vdb-meta"> <div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span> <span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span> <span>•</span>
@@ -60,11 +89,21 @@
@if(isset($descriptionSlot)) @if(isset($descriptionSlot))
{!! $descriptionSlot !!} {!! $descriptionSlot !!}
@elseif($renderedDescription !== '') @elseif($renderedDescription !== '')
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div> <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> <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 @else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p> <p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif @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> </div>
@if($isVideoOwner) @if($isVideoOwner)
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
}; };
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck); document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
window.addEventListener('load', 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> </script>
@endif @endif
+2
View File
@@ -58,6 +58,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::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::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}/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/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::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'); Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
@@ -137,6 +138,7 @@ Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisi
// Comment routes // Comment routes
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index'); 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::middleware(['auth', 'verified'])->group(function () {
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store'); Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update'); Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');