diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php
index 6e6ff39..161135c 100644
--- a/app/Http/Controllers/VideoController.php
+++ b/app/Http/Controllers/VideoController.php
@@ -2176,6 +2176,47 @@ class VideoController extends Controller
return response()->json(['url' => route('share.access', $token)]);
}
+ /**
+ * Email a friend a properly-formatted share email, version-aware: the link and the
+ * email's title reflect the language track the sender chose. The URL is built
+ * server-side so outgoing mail can never carry an attacker-supplied link.
+ */
+ public function shareByEmail(Request $request, Video $video)
+ {
+ if (! $video->isShareable() || ! $video->canView(Auth::user())) {
+ return response()->json(['error' => 'This video cannot be shared.'], 403);
+ }
+
+ $data = $request->validate([
+ 'email' => 'required|email|max:255',
+ 'message' => 'nullable|string|max:500',
+ 'track' => 'nullable|integer',
+ ]);
+
+ $trackId = (int) ($data['track'] ?? 0);
+ $shareTitle = $video->title;
+ $shareUrl = $video->share_url;
+ if ($trackId && ($track = $video->audioTracks->firstWhere('id', $trackId))) {
+ if (! empty($track->title)) $shareTitle = $track->title;
+ $shareUrl .= (str_contains($shareUrl, '?') ? '&' : '?') . 'track=' . $trackId;
+ }
+
+ try {
+ \Mail::to($data['email'])->send(new \App\Mail\VideoShared(
+ $video,
+ $shareUrl,
+ Auth::user(),
+ $data['message'] ?? null,
+ $shareTitle,
+ ));
+ } catch (\Throwable $e) {
+ \Log::error('Share-by-email failed: ' . $e->getMessage(), ['video_id' => $video->id]);
+ return response()->json(['error' => 'Could not send the email right now. Please try again.'], 500);
+ }
+
+ return response()->json(['success' => true]);
+ }
+
public function accessShare(Request $request, string $token)
{
$share = \DB::table('video_shares')->where('token', $token)->first();
diff --git a/app/Mail/VideoShared.php b/app/Mail/VideoShared.php
new file mode 100644
index 0000000..d290028
--- /dev/null
+++ b/app/Mail/VideoShared.php
@@ -0,0 +1,46 @@
+shareTitle = $shareTitle ?: $video->title;
+ }
+
+ public function envelope(): Envelope
+ {
+ return new Envelope(
+ subject: $this->sender->name . ' shared "' . $this->shareTitle . '" with you on ' . config('app.name'),
+ // Let the recipient reply straight to the friend who sent it.
+ replyTo: $this->sender->email
+ ? [new Address($this->sender->email, $this->sender->name)]
+ : [],
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(view: 'emails.video-shared');
+ }
+}
diff --git a/resources/views/components/video-actions.blade.php b/resources/views/components/video-actions.blade.php
index b57cbe2..e90b5d0 100644
--- a/resources/views/components/video-actions.blade.php
+++ b/resources/views/components/video-actions.blade.php
@@ -168,7 +168,7 @@
@if ($video->isShareable())
@endif
@@ -271,7 +271,7 @@
@if ($video->isShareable())
@endif
@@ -516,10 +516,10 @@ if (!window._slideshowDlInit) {
// Share the version the viewer is on: append ?track={id} so the recipient's player
// opens directly on that language (window._ytpTrackId; 0 = primary β plain link).
- window.shareCurrent = function (baseUrl, title, recordUrl) {
+ window.shareCurrent = function (baseUrl, title, recordUrl, emailUrl) {
var t = window._ytpTrackId || 0;
var url = t ? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + t : baseUrl;
- if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl);
+ if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl, emailUrl);
};
window.startSlideshowDownload = function (routeKey) {
diff --git a/resources/views/emails/video-shared.blade.php b/resources/views/emails/video-shared.blade.php
new file mode 100644
index 0000000..394decc
--- /dev/null
+++ b/resources/views/emails/video-shared.blade.php
@@ -0,0 +1,69 @@
+@php
+ // Type-aware wording β a sport/match or regular video is NOT a song.
+ $isSong = $video->isAudioOnly() || $video->type === 'music';
+ $noun = $isSong ? 'song' : ($video->type === 'match' ? 'match' : 'video');
+ $cta = $isSong ? 'Listen now' : 'Watch now';
+ $icon = $isSong ? '🎵' : '▶'; // π΅ vs βΆ
+ $byLabel = $isSong ? 'Artist' : ($video->type === 'match' ? 'Posted by' : 'Channel');
+@endphp
+
+
+ {{-- Icon --}}
+ {!! $icon !!}
+
+ {{ $sender->name }} shared a {{ $noun }} with you
+ They thought you'd enjoy this on {{ config('app.name') }}.
+
+ @if($personalMessage)
+ {{-- Personal note --}}
+
+
Message from {{ $sender->name }}
+
{{ $personalMessage }}
+
+ @endif
+
+ {{-- Cover --}}
+
+ @if($video->thumbnail)
+
+
+
+ @else
+
+ {!! $icon !!}
+ {{ Str::limit($shareTitle, 40) }}
+
+ @endif
+
+
+ {{-- Details --}}
+
+
Details
+
+ Title
+ {{ $shareTitle }}
+
+ @if($video->formatted_duration)
+
+ Duration
+ {{ $video->formatted_duration }}
+
+ @endif
+
+ {{ $byLabel }}
+ {{ $video->user->name ?? config('app.name') }}
+
+
+
+ {{-- CTA --}}
+
+
+
+
+
+ {{ $sender->name }} sent you this link from {{ config('app.name') }}. If you weren't expecting it, you can safely ignore this email.
+
+
+
diff --git a/resources/views/layouts/partials/share-modal.blade.php b/resources/views/layouts/partials/share-modal.blade.php
index 6666649..b761d8d 100644
--- a/resources/views/layouts/partials/share-modal.blade.php
+++ b/resources/views/layouts/partials/share-modal.blade.php
@@ -42,8 +42,27 @@
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0088cc; color: white; text-decoration: none;">
+
+
+
+
+ {{-- Send by email --}}
+
+
+ Send to a friend by email:
+
+
+
+
+
+
@@ -70,7 +89,11 @@ function _getLatestCsrf() {
return (typeof csrf !== 'undefined') ? csrf : '';
}
-async function openShareModal(videoUrl, videoTitle, recordUrl) {
+// Set per-open so the "Send by email" form knows the endpoint + which version to send.
+var _shareEmailUrl = '';
+var _shareTrack = '';
+
+async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) {
var csrfToken = _getLatestCsrf();
var shareUrl = videoUrl;
@@ -78,6 +101,8 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) {
// share link replaces shareUrl below, so we re-attach it afterwards.
var trackParam = '';
try { trackParam = (new URL(videoUrl, window.location.origin)).searchParams.get('track') || ''; } catch (e) {}
+ _shareEmailUrl = emailUrl || '';
+ _shareTrack = trackParam;
// Obtain a unique tracked share link from the server
if (recordUrl) {
@@ -130,6 +155,17 @@ function _populateShareModal(shareUrl, videoTitle) {
copyBtn.innerHTML = ' Copy';
copyBtn.classList.remove('copied');
document.getElementById('copySuccess').style.display = 'none';
+
+ // Reset the email form; the envelope button only works when an endpoint was provided.
+ var emailBtn = document.getElementById('shareEmailBtn');
+ var emailSec = document.getElementById('shareEmailSection');
+ if (emailBtn) emailBtn.style.display = _shareEmailUrl ? 'flex' : 'none';
+ if (emailSec) {
+ emailSec.style.display = 'none';
+ var to = document.getElementById('shareEmailTo'); if (to) to.value = '';
+ var msg = document.getElementById('shareEmailMsg'); if (msg) msg.value = '';
+ var st = document.getElementById('shareEmailStatus'); if (st) st.style.display = 'none';
+ }
}
function _copyToClipboard(text) {
@@ -174,6 +210,82 @@ document.addEventListener('DOMContentLoaded', function() {
showToast('Could not copy β please copy the link manually.', 'error');
});
});
+
+ // ββ Send by email ββββββββββββββββββββββββββββββββββββββββββββββ
+ var emailBtn = document.getElementById('shareEmailBtn');
+ var emailSec = document.getElementById('shareEmailSection');
+ var emailTo = document.getElementById('shareEmailTo');
+ var emailMsg = document.getElementById('shareEmailMsg');
+ var emailSend = document.getElementById('shareEmailSend');
+ var emailStat = document.getElementById('shareEmailStatus');
+
+ if (emailBtn && emailSec) {
+ emailBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ emailSec.style.display = (emailSec.style.display === 'none' || !emailSec.style.display) ? 'block' : 'none';
+ if (emailSec.style.display === 'block' && emailTo) emailTo.focus();
+ });
+ }
+
+ function _showEmailStatus(msg, color) {
+ if (!emailStat) return;
+ emailStat.textContent = msg;
+ emailStat.style.color = color;
+ emailStat.style.display = 'block';
+ }
+
+ if (emailSend) {
+ emailSend.addEventListener('click', function() {
+ var to = (emailTo && emailTo.value || '').trim();
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
+ _showEmailStatus('Please enter a valid email address.', '#ef4444');
+ if (emailTo) emailTo.focus();
+ return;
+ }
+ if (!_shareEmailUrl) { _showEmailStatus('Email sharing is unavailable here.', '#ef4444'); return; }
+
+ emailSend.disabled = true;
+ var _orig = emailSend.innerHTML;
+ emailSend.innerHTML = ' Sendingβ¦';
+ _showEmailStatus('Sendingβ¦', 'var(--text-secondary)');
+
+ var body = new URLSearchParams({
+ _token: '{{ csrf_token() }}',
+ email: to,
+ message: (emailMsg && emailMsg.value || ''),
+ track: _shareTrack || '0',
+ });
+
+ fetch(_shareEmailUrl, {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-TOKEN': _getLatestCsrf(),
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ body: body.toString(),
+ })
+ .then(function(r) { return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
+ .then(function(res) {
+ emailSend.disabled = false;
+ emailSend.innerHTML = _orig;
+ if (res.ok && res.d.success) {
+ _showEmailStatus('Sent! Your friend will get the email shortly.', '#4caf50');
+ if (emailTo) emailTo.value = '';
+ if (emailMsg) emailMsg.value = '';
+ setTimeout(function(){ if (emailSec) emailSec.style.display = 'none'; }, 1800);
+ } else {
+ _showEmailStatus((res.d && res.d.error) || 'Could not send the email. Please try again.', '#ef4444');
+ }
+ })
+ .catch(function() {
+ emailSend.disabled = false;
+ emailSend.innerHTML = _orig;
+ _showEmailStatus('Network error β please try again.', '#ef4444');
+ });
+ });
+ }
});
diff --git a/routes/web.php b/routes/web.php
index a495360..1672a9b 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -48,6 +48,7 @@ Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slide
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData');
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
+Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access');
Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');