Add sports-match type, device tracking, profile visits, and share refactor

- New SportsMatch model/controller and sports UI components/modal
- Move share-modal to a reusable x-share-modal/x-share-button component
- Add VideoSharedWithUser notification and share-to-members flow
- Device/user-agent tracking on views, downloads, share accesses
- ProfileVisit model + migration; subscription source tracking
- Email thumbnail support; remove stale TODO files
This commit is contained in:
ghassan committed 2026-05-29 01:50:28 +03:00
1 parent 6aae6f86b6
commit 73527f3781
61 files changed
+4014 -636

No files matched your search

+124 -1
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\Video;
use App\Services\GeoIpService;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -14,7 +15,7 @@ class PlaylistController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare']);
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare', 'ogImage']);
}
// List user's playlists
@@ -119,6 +120,23 @@ class PlaylistController extends Controller
]);
}
// Serve the playlist's own OG metadata to social-media crawlers so previews
// show the playlist's picture and name — not the first video's. Humans still
// get redirected to the first track for one-tap playback.
$ua = (string) $request->userAgent();
$isCrawler = (bool) preg_match(
'/facebookexternalhit|facebookcatalog|Facebot|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|TelegramBot|Pinterest|redditbot|Googlebot|bingbot|DuckDuckBot|YandexBot|Applebot|Embedly|vkShare|W3C_Validator|SkypeUriPreview/i',
$ua
);
if ($isCrawler) {
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return response()
->view('playlists.show', compact('playlist', 'videos'))
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
$firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
@@ -495,4 +513,109 @@ class PlaylistController extends Controller
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
} catch (\Throwable) {}
}
// Social-preview image: resize the playlist's own thumbnail to a 1200x630 JPEG
// (small enough for WhatsApp/Telegram/Discord previews). Falls back to a branded card.
public function ogImage(Playlist $playlist, NasSyncService $nas)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404);
}
if ($playlist->thumbnail) {
$local = storage_path('app/' . $playlist->thumbnail);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$nas->ensureLocalAsset($local, $playlist->thumbnail);
}
if (file_exists($local)) {
$ext = strtolower(pathinfo($local, PATHINFO_EXTENSION));
$src = match ($ext) {
'png' => @imagecreatefrompng($local),
'webp' => @imagecreatefromwebp($local),
'gif' => @imagecreatefromgif($local),
default => @imagecreatefromjpeg($local),
};
if ($src) {
$ow = imagesx($src); $oh = imagesy($src);
// Always output an exact 1200x630 canvas (cover-crop, no letterbox)
// so the served image matches the og:image:width/height we declare —
// a mismatch makes WhatsApp/Telegram fall back to the tiny thumbnail.
$cw = 1200; $ch = 630;
$dst = imagecreatetruecolor($cw, $ch);
// Cover: scale so the image fills the whole canvas, center-crop overflow
$scale = max($cw / $ow, $ch / $oh);
$sw = (int) round($cw / $scale);
$sh = (int) round($ch / $scale);
$sx = (int) round(($ow - $sw) / 2);
$sy = (int) round(($oh - $sh) / 2);
imagecopyresampled($dst, $src, 0, 0, $sx, $sy, $cw, $ch, $sw, $sh);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 82);
imagedestroy($dst);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}
}
// Branded fallback card
$w = 1200; $h = 630;
$img = imagecreatetruecolor($w, $h);
$cBg = imagecolorallocate($img, 10, 10, 10);
$cRed = imagecolorallocate($img, 230, 30, 30);
$cWhite = imagecolorallocate($img, 240, 240, 240);
$cGray = imagecolorallocate($img, 120, 120, 120);
imagefill($img, 0, 0, $cBg);
imagefilledrectangle($img, 0, 0, $w, 8, $cRed);
imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);
$cx = (int)($w / 2); $cy = (int)($h / 2) - 30; $r = 72;
imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
$tri = [$cx - 22, $cy - 30, $cx - 22, $cy + 30, $cx + 34, $cy];
imagefilledpolygon($img, $tri, $cWhite);
$fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
$fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));
$title = $playlist->name ?: 'Playlist';
$maxChars = 42;
$lines = [];
if (mb_strlen($title) > $maxChars) {
$words = explode(' ', $title); $line = '';
foreach ($words as $word) {
if (mb_strlen($line . ' ' . $word) > $maxChars) { $lines[] = trim($line); $line = $word; }
else { $line .= ($line ? ' ' : '') . $word; }
}
if ($line) $lines[] = trim($line);
} else { $lines = [$title]; }
$lines = array_slice($lines, 0, 2);
$titleY = $cy + $r + 60;
foreach ($lines as $i => $line) {
$bbox = imagettfbbox(28, 0, $fontBold, $line);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
}
$meta = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : '');
$bbox = imagettfbbox(16, 0, $fontNormal, $meta);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $meta);
ob_start();
imagejpeg($img, null, 85);
imagedestroy($img);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}