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:
1 parent
6aae6f86b6
commit
73527f3781
61 files changed
+4014
-636
No files matched your search
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user