Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c37df755 | |||
| 6a9d3b25bf | |||
| 75b98de746 | |||
| eaf48276f0 | |||
| 2e3918def4 | |||
| 7553b9f895 | |||
| 5af1c3c447 | |||
| 2b1a967065 | |||
| ee1c17b903 | |||
| a5bf2ac245 | |||
| 4f81164b44 | |||
| 859a35847a | |||
| f35d1a119e | |||
| a04d58c60f | |||
| bcd15d8569 | |||
| 133a069890 | |||
| b32465a7d0 | |||
| f8022b3f18 | |||
| 57d1ec34c3 | |||
| 81639aaabf | |||
| 5dc1bff60c | |||
| 2f3f0edc30 | |||
| a71b2976af | |||
| 2c016274ab | |||
| 5ba0a55316 | |||
| a6fe34a0d1 | |||
| bb53e06c69 | |||
| 5cae5dc658 | |||
| 356d07365f | |||
| 3574d20fae | |||
| 9fc9e8ed10 | |||
| f5c706b587 | |||
| cbea71d9ae | |||
| 64a621173c | |||
| 839779b82e | |||
| 112cf9433e | |||
| 26a6500fca | |||
| d8cf70e747 | |||
| 0d4545c2ab | |||
| 900103e1c2 | |||
| d4c90976f8 | |||
| 72263127df | |||
| 6d3de59929 | |||
| ddb1bc2d14 | |||
| 5f3874a233 | |||
| ba3650899e | |||
| 904604fcfb | |||
| b7b34b503c | |||
| 4928733383 | |||
| 6340302ac6 | |||
| c1829ba7bd | |||
| 0b155bbb80 | |||
| 9f959efa14 | |||
| 38e3346dc3 | |||
| 09c08f3fea | |||
| 75f631c3e6 | |||
| fdf26604f3 | |||
| 59cb39ca77 | |||
| 62647be75c | |||
| de6efb877c | |||
| 2151d69791 | |||
| 05d4ef1bdb | |||
| 8ae9eaaadb | |||
| 361b511c3e | |||
| 1bc505057f | |||
| a78b1c41ac |
@@ -16,7 +16,7 @@ class GenerateSitemap extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'sitemap:generate';
|
protected $signature = 'app:generate-sitemap';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Helpers;
|
namespace App\Helpers;
|
||||||
|
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
|
use App\Models\Downloads;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
use App\Models\Hentai;
|
use App\Models\Hentai;
|
||||||
use App\Models\PopularDaily;
|
use App\Models\PopularDaily;
|
||||||
use App\Models\PopularMonthly;
|
use App\Models\PopularMonthly;
|
||||||
use App\Models\PopularWeekly;
|
use App\Models\PopularWeekly;
|
||||||
|
use App\Models\User;
|
||||||
use Conner\Tagging\Model\Tag;
|
use Conner\Tagging\Model\Tag;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -61,6 +63,100 @@ class CacheHelper
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getTotalUserCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_user_count', now()->addMinutes(60), function () {
|
||||||
|
return User::count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTotalCommentCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_comment_count', now()->addMinutes(60), function () {
|
||||||
|
return Comment::count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTotalLikeCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_like_count', now()->addMinutes(60), function () {
|
||||||
|
return DB::table('markable_likes')->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTotalDownloadCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_download_count', now()->addMinutes(60), function () {
|
||||||
|
return Downloads::sum('count');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get4kEpisodeCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('episodes_4k_count', now()->addMinutes(60), function () {
|
||||||
|
return Episode::where('interpolated', true)->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getUHD48FpsEpisodeCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('episodes_uhd48_count', now()->addMinutes(60), function () {
|
||||||
|
return Episode::where('interpolated_uhd', true)->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTodayViewCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('today_view_count', now()->addMinutes(30), function () {
|
||||||
|
return PopularDaily::whereDate('created_at', today())->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getWeeklyViewCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('weekly_view_count', now()->addMinutes(60), function () {
|
||||||
|
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(7))->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPreviousWeeklyViewCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('prev_weekly_view_count', now()->addMinutes(60), function () {
|
||||||
|
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(14))
|
||||||
|
->whereDate('created_at', '<', today()->subDays(7))
|
||||||
|
->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getAverageViewsPerEpisode()
|
||||||
|
{
|
||||||
|
return Cache::remember('avg_views_per_episode', now()->addMinutes(60), function () {
|
||||||
|
$totalEpisodes = Episode::count();
|
||||||
|
if ($totalEpisodes === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(Episode::sum('view_count') / $totalEpisodes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNewEpisodesThisWeek()
|
||||||
|
{
|
||||||
|
return Cache::remember('new_episodes_this_week', now()->addMinutes(60), function () {
|
||||||
|
return Episode::whereDate('created_at', '>=', today()->subDays(7))->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTopTags()
|
||||||
|
{
|
||||||
|
return Cache::remember('top_tags_stats', now()->addMinutes(120), function () {
|
||||||
|
return Tag::where('count', '>', 0)
|
||||||
|
->orderBy('count', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPopularAllTime(bool $guest)
|
public static function getPopularAllTime(bool $guest)
|
||||||
{
|
{
|
||||||
$guestString = $guest ? 'guest' : 'authed';
|
$guestString = $guest ? 'guest' : 'authed';
|
||||||
@@ -128,7 +224,7 @@ class CacheHelper
|
|||||||
public static function getLatestComments()
|
public static function getLatestComments()
|
||||||
{
|
{
|
||||||
return Cache::remember('latest_comments', now()->addMinutes(60), function () {
|
return Cache::remember('latest_comments', now()->addMinutes(60), function () {
|
||||||
return Comment::latest()->take(10)->get();
|
return Comment::with('user')->latest()->take(10)->get();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Conner\Tagging\Model\Tag;
|
||||||
|
|
||||||
|
class FilterCategories
|
||||||
|
{
|
||||||
|
public static function getFilterCategories()
|
||||||
|
{
|
||||||
|
$taglist = Cache::remember(
|
||||||
|
'searchtags',
|
||||||
|
300,
|
||||||
|
fn () => Tag::where('count', '>', 0)
|
||||||
|
->orderBy('slug')
|
||||||
|
->get(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$appearances = [
|
||||||
|
'Loli',
|
||||||
|
'Shota',
|
||||||
|
'Milf',
|
||||||
|
'Futanari',
|
||||||
|
'Big Boobs',
|
||||||
|
'Small Boobs',
|
||||||
|
'Dark Skin',
|
||||||
|
'Cosplay',
|
||||||
|
'Elf',
|
||||||
|
'Maid',
|
||||||
|
'Nekomimi',
|
||||||
|
'Nurse',
|
||||||
|
'School Girl',
|
||||||
|
'Succubus',
|
||||||
|
'Teacher',
|
||||||
|
'Trap',
|
||||||
|
'Pregnant',
|
||||||
|
'Glasses',
|
||||||
|
'Swim Suit',
|
||||||
|
'Ugly Bastard',
|
||||||
|
'Monster',
|
||||||
|
];
|
||||||
|
|
||||||
|
$types = [
|
||||||
|
'3D',
|
||||||
|
'4K',
|
||||||
|
'48Fps',
|
||||||
|
'4K 48Fps',
|
||||||
|
'Censored',
|
||||||
|
'Uncensored',
|
||||||
|
'Comedy',
|
||||||
|
'Fantasy',
|
||||||
|
'Horror',
|
||||||
|
'Vanilla',
|
||||||
|
'Ntr',
|
||||||
|
'Pov',
|
||||||
|
'Filmed',
|
||||||
|
'X-Ray',
|
||||||
|
];
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
'Anal',
|
||||||
|
'Bdsm',
|
||||||
|
'Facial',
|
||||||
|
'Blow Job',
|
||||||
|
'Boob Job',
|
||||||
|
'Foot Job',
|
||||||
|
'Hand Job',
|
||||||
|
'Rimjob',
|
||||||
|
'Inflation',
|
||||||
|
'Masturbation',
|
||||||
|
'Public Sex',
|
||||||
|
'Rape',
|
||||||
|
'Reverse Rape',
|
||||||
|
'Threesome',
|
||||||
|
'Orgy',
|
||||||
|
'Gangbang',
|
||||||
|
];
|
||||||
|
|
||||||
|
$excluded = [...$appearances, ...$types, ...$actions];
|
||||||
|
|
||||||
|
$categories = [
|
||||||
|
'Genres' => $taglist
|
||||||
|
->reject(fn ($tag) => in_array($tag->name, $excluded))
|
||||||
|
->pluck('name')
|
||||||
|
->toArray(),
|
||||||
|
|
||||||
|
'Actions' => $actions,
|
||||||
|
|
||||||
|
'Appearance' => collect($appearances)
|
||||||
|
->reject(function ($tag) {
|
||||||
|
return Auth::guest() && in_array($tag, ['Loli', 'Shota']);
|
||||||
|
})
|
||||||
|
->toArray(),
|
||||||
|
|
||||||
|
'Types' => $types,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $categories;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,16 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Alert;
|
use App\Models\Alert;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class AlertController extends Controller
|
class AlertController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display alert index page
|
* Display alert index page
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('admin.alert.index');
|
return view('admin.alert.index');
|
||||||
}
|
}
|
||||||
@@ -19,7 +21,7 @@ class AlertController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create Alert.
|
* Create Alert.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'message' => 'required|string|max:255',
|
'message' => 'required|string|max:255',
|
||||||
@@ -39,7 +41,7 @@ class AlertController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Delete Alert.
|
* Delete Alert.
|
||||||
*/
|
*/
|
||||||
public function delete(int $alert_id): \Illuminate\Http\RedirectResponse
|
public function delete(int $alert_id): RedirectResponse
|
||||||
{
|
{
|
||||||
Alert::where('id', $alert_id)->delete();
|
Alert::where('id', $alert_id)->delete();
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class CommentsController extends Controller
|
class CommentsController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display Comments Page.
|
* Display Comments Page.
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('admin.comments.index');
|
return view('admin.comments.index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Contact;
|
use App\Models\Contact;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display Contact Page.
|
* Display Contact Page.
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
$contacts = Contact::orderBy('created_at', 'DESC')->get();
|
$contacts = Contact::orderBy('created_at', 'DESC')->get();
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ class ContactController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Delete Contact.
|
* Delete Contact.
|
||||||
*/
|
*/
|
||||||
public function delete(int $contact_id): \Illuminate\Http\RedirectResponse
|
public function delete(int $contact_id): RedirectResponse
|
||||||
{
|
{
|
||||||
Contact::where('id', $contact_id)->delete();
|
Contact::where('id', $contact_id)->delete();
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Jobs\DiscordReleaseNotification;
|
use App\Jobs\DiscordReleaseNotification;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
use App\Services\DownloadService;
|
use App\Services\DownloadService;
|
||||||
use App\Services\EpisodeService;
|
use App\Services\EpisodeService;
|
||||||
use App\Services\GalleryService;
|
use App\Services\GalleryService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class EpisodeController extends Controller
|
class EpisodeController extends Controller
|
||||||
@@ -31,7 +33,7 @@ class EpisodeController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Add Episode to existing series
|
* Add Episode to existing series
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
|
$referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
|
||||||
$episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1;
|
$episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1;
|
||||||
@@ -59,9 +61,20 @@ class EpisodeController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Edit Episode
|
* Edit Episode
|
||||||
*/
|
*/
|
||||||
public function update(Request $request): \Illuminate\Http\RedirectResponse
|
public function update(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$episode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
|
$episode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
|
||||||
|
|
||||||
|
if ($request->user()->hasRole(UserRole::MODERATOR)) {
|
||||||
|
$this->episodeService->updateEpisodeModerator($request, $episode->id);
|
||||||
|
|
||||||
|
cache()->flush();
|
||||||
|
|
||||||
|
return to_route('hentai.index', [
|
||||||
|
'title' => $episode->slug,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
|
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
|
||||||
|
|
||||||
$oldinterpolated = $episode->interpolated;
|
$oldinterpolated = $episode->interpolated;
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use App\Models\Hentai;
|
|||||||
use App\Services\DownloadService;
|
use App\Services\DownloadService;
|
||||||
use App\Services\EpisodeService;
|
use App\Services\EpisodeService;
|
||||||
use App\Services\GalleryService;
|
use App\Services\GalleryService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ReleaseController extends Controller
|
class ReleaseController extends Controller
|
||||||
{
|
{
|
||||||
@@ -31,7 +33,7 @@ class ReleaseController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display release page
|
* Display release page
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('admin.release.create');
|
return view('admin.release.create');
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,7 @@ class ReleaseController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Upload New Hentai with One or Multipe Episodes
|
* Upload New Hentai with One or Multipe Episodes
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
// Create new Hentai or find existing one
|
// Create new Hentai or find existing one
|
||||||
$slug = $this->episodeService->generateSlug($request->input('title'));
|
$slug = $this->episodeService->generateSlug($request->input('title'));
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\SiteBackground;
|
use App\Models\SiteBackground;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\View\View;
|
||||||
use Intervention\Image\Encoders\WebpEncoder;
|
use Intervention\Image\Encoders\WebpEncoder;
|
||||||
use Intervention\Image\Laravel\Facades\Image;
|
use Intervention\Image\Laravel\Facades\Image;
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ class SiteBackgroundController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display admin index page
|
* Display admin index page
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('admin.background.index', [
|
return view('admin.background.index', [
|
||||||
'images' => SiteBackground::all(),
|
'images' => SiteBackground::all(),
|
||||||
@@ -26,7 +28,7 @@ class SiteBackgroundController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create new site backgrounds
|
* Create new site backgrounds
|
||||||
*/
|
*/
|
||||||
public function create(Request $request): \Illuminate\Http\RedirectResponse
|
public function create(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'images' => 'required',
|
'images' => 'required',
|
||||||
@@ -73,7 +75,7 @@ class SiteBackgroundController extends Controller
|
|||||||
return redirect()->back();
|
return redirect()->back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request): \Illuminate\Http\RedirectResponse
|
public function update(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'id' => 'required|exists:site_backgrounds,id',
|
'id' => 'required|exists:site_backgrounds,id',
|
||||||
@@ -96,7 +98,7 @@ class SiteBackgroundController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Delete backround
|
* Delete backround
|
||||||
*/
|
*/
|
||||||
public function delete(Request $request): \Illuminate\Http\RedirectResponse
|
public function delete(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$id = $request->input('id');
|
$id = $request->input('id');
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
use App\Models\EpisodeSubtitle;
|
use App\Models\EpisodeSubtitle;
|
||||||
use App\Models\Subtitle;
|
use App\Models\Subtitle;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class SubtitleController extends Controller
|
class SubtitleController extends Controller
|
||||||
@@ -13,7 +14,7 @@ class SubtitleController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Add new Subtitle.
|
* Add new Subtitle.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$subtitle = Subtitle::create([
|
$subtitle = Subtitle::create([
|
||||||
'name' => $request->name,
|
'name' => $request->name,
|
||||||
@@ -32,7 +33,7 @@ class SubtitleController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Update Episode Subtitles.
|
* Update Episode Subtitles.
|
||||||
*/
|
*/
|
||||||
public function update(Request $request): \Illuminate\Http\RedirectResponse
|
public function update(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
|
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ use App\Enums\UserRole;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display Users Page.
|
* Display Users Page.
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('admin.users.index');
|
return view('admin.users.index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Downloads;
|
use App\Models\Downloads;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
use GrantHolle\Altcha\Rules\ValidAltcha;
|
use App\Rules\ValidCaptcha;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class DownloadApiController extends Controller
|
class DownloadApiController extends Controller
|
||||||
@@ -17,7 +17,7 @@ class DownloadApiController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'episode_id' => ['required'],
|
'episode_id' => ['required'],
|
||||||
'captcha' => ['required', new ValidAltcha],
|
'captcha' => ['required', new ValidCaptcha],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$episode = Episode::where('id', $request->input('episode_id'))
|
$episode = Episode::where('id', $request->input('episode_id'))
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
|
use App\Models\VideoEngagement;
|
||||||
|
use App\Models\Watched;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
||||||
class StreamApiController extends Controller
|
class StreamApiController extends Controller
|
||||||
{
|
{
|
||||||
@@ -34,4 +41,103 @@ class StreamApiController extends Controller
|
|||||||
'extra_subtitles' => $subtitles,
|
'extra_subtitles' => $subtitles,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track that the authenticated user has watched the episode.
|
||||||
|
* Called client-side after 10 seconds of playback.
|
||||||
|
*/
|
||||||
|
public function trackWatched(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'episode_id' => 'required|integer|exists:episodes,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json(['watched' => false], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$episodeId = $request->input('episode_id');
|
||||||
|
|
||||||
|
// 1-hour cooldown to prevent duplicate entries
|
||||||
|
$time = Carbon::now()->subHour(1);
|
||||||
|
$alreadyWatched = Watched::where('user_id', $user->id)
|
||||||
|
->where('episode_id', $episodeId)
|
||||||
|
->where('created_at', '>=', $time)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $alreadyWatched) {
|
||||||
|
Watched::create(['user_id' => $user->id, 'episode_id' => $episodeId]);
|
||||||
|
cache()->forget('user'.$user->id.'watched'.$episodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['watched' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track engagement segments that the authenticated user has watched.
|
||||||
|
* Called periodically client-side while the video is playing.
|
||||||
|
* Segments are 10-second chunks; segment 0 (0-10s) is excluded.
|
||||||
|
*/
|
||||||
|
public function trackEngagement(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'episode_id' => 'required|integer|exists:episodes,id',
|
||||||
|
'segments' => 'required|array',
|
||||||
|
'segments.*' => 'integer|min:1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json(['tracked' => 0], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateLimitKey = 'engagement:'.$user->id;
|
||||||
|
if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) {
|
||||||
|
$seconds = RateLimiter::availableIn($rateLimitKey);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'tracked' => 0,
|
||||||
|
'message' => 'Rate limit exceeded. Try again in '.$seconds.' seconds.',
|
||||||
|
], 429);
|
||||||
|
}
|
||||||
|
RateLimiter::hit($rateLimitKey, 60);
|
||||||
|
|
||||||
|
$episodeId = $request->input('episode_id');
|
||||||
|
$segments = $request->input('segments');
|
||||||
|
$tracked = 0;
|
||||||
|
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
// upsert: insert if not exists, otherwise ignore (unique constraint prevents dupes)
|
||||||
|
VideoEngagement::firstOrCreate([
|
||||||
|
'episode_id' => $episodeId,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'segment' => $segment,
|
||||||
|
]);
|
||||||
|
$tracked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['tracked' => $tracked], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get engagement heatmap data for an episode.
|
||||||
|
* Returns watch counts per segment, aggregated across all users.
|
||||||
|
*/
|
||||||
|
public function getEngagement(int $episodeId): JsonResponse
|
||||||
|
{
|
||||||
|
$episode = Episode::findOrFail($episodeId);
|
||||||
|
|
||||||
|
$data = cache()->remember(
|
||||||
|
"engagement:{$episodeId}",
|
||||||
|
600, // 10-minute cache
|
||||||
|
fn () => VideoEngagement::where('episode_id', $episodeId)
|
||||||
|
->select('segment', DB::raw('count(*) as watch_count'))
|
||||||
|
->groupBy('segment')
|
||||||
|
->pluck('watch_count', 'segment')
|
||||||
|
->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($data, 200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
|
||||||
|
use AltchaOrg\Altcha\Altcha;
|
||||||
|
use AltchaOrg\Altcha\CreateChallengeOptions;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class CaptchaController extends Controller
|
||||||
|
{
|
||||||
|
public function create(): array
|
||||||
|
{
|
||||||
|
$pbkdf2 = new Pbkdf2;
|
||||||
|
|
||||||
|
$altcha = new Altcha(
|
||||||
|
hmacSignatureSecret: config('captcha.hmac_key'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create challenge
|
||||||
|
$challenge = $altcha->createChallenge(new CreateChallengeOptions(
|
||||||
|
algorithm: $pbkdf2,
|
||||||
|
cost: 5000,
|
||||||
|
counter: random_int(5000, 10000),
|
||||||
|
expiresAt: time() + 600,
|
||||||
|
));
|
||||||
|
|
||||||
|
return get_object_vars($challenge);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Laravel\Socialite\Two\InvalidStateException;
|
||||||
|
|
||||||
class DiscordAuthController extends Controller
|
class DiscordAuthController extends Controller
|
||||||
{
|
{
|
||||||
@@ -26,7 +27,12 @@ class DiscordAuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function callback(): RedirectResponse
|
public function callback(): RedirectResponse
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
$discordUser = Socialite::driver('discord')->user();
|
$discordUser = Socialite::driver('discord')->user();
|
||||||
|
} catch (InvalidStateException $e) {
|
||||||
|
return redirect()->route('login')
|
||||||
|
->with('error', 'Your login session expired. Please try signing in again.');
|
||||||
|
}
|
||||||
|
|
||||||
$user = User::where('discord_id', $discordUser->id)->first();
|
$user = User::where('discord_id', $discordUser->id)->first();
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules;
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class NewPasswordController extends Controller
|
class NewPasswordController extends Controller
|
||||||
@@ -26,7 +27,7 @@ class NewPasswordController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming new password request.
|
* Handle an incoming new password request.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class PasswordResetLinkController extends Controller
|
class PasswordResetLinkController extends Controller
|
||||||
@@ -21,7 +22,7 @@ class PasswordResetLinkController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming password reset link request.
|
* Handle an incoming password reset link request.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ namespace App\Http\Controllers\Auth;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use GrantHolle\Altcha\Rules\ValidAltcha;
|
use App\Rules\ValidCaptcha;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rules;
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class RegisteredUserController extends Controller
|
class RegisteredUserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle an incoming registration request.
|
* Handle an incoming registration request.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
@@ -25,7 +26,7 @@ class RegisteredUserController extends Controller
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
'altcha' => ['required', new ValidAltcha],
|
'altcha' => ['required', new ValidCaptcha],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Contact;
|
use App\Models\Contact;
|
||||||
use GrantHolle\Altcha\Rules\ValidAltcha;
|
use App\Rules\ValidCaptcha;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display Contact Page.
|
* Display Contact Page.
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('contact.form');
|
return view('contact.form');
|
||||||
}
|
}
|
||||||
@@ -19,14 +21,14 @@ class ContactController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Store Contact Submission.
|
* Store Contact Submission.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|max:30',
|
'name' => 'required|max:30',
|
||||||
'email' => 'required|max:50',
|
'email' => 'required|max:50',
|
||||||
'message' => 'required|max:1000',
|
'message' => 'required|max:1000',
|
||||||
'subject' => 'required|max:50',
|
'subject' => 'required|max:50',
|
||||||
'altcha' => ['required', new ValidAltcha],
|
'altcha' => ['required', new ValidCaptcha],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$contact = new Contact;
|
$contact = new Contact;
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Helpers\CacheHelper;
|
use App\Helpers\CacheHelper;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class HomeController extends Controller
|
class HomeController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display Home Page.
|
* Display Home Page.
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
$guest = Auth::guest();
|
$guest = Auth::guest();
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ class HomeController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display Banned Page.
|
* Display Banned Page.
|
||||||
*/
|
*/
|
||||||
public function banned(): \Illuminate\View\View
|
public function banned(): View
|
||||||
{
|
{
|
||||||
return view('auth.banned');
|
return view('auth.banned');
|
||||||
}
|
}
|
||||||
@@ -54,7 +56,7 @@ class HomeController extends Controller
|
|||||||
* Redirects to a random Hentai episode
|
* Redirects to a random Hentai episode
|
||||||
* Done due to performance reasons
|
* Done due to performance reasons
|
||||||
*/
|
*/
|
||||||
public function random(): \Illuminate\Http\RedirectResponse
|
public function random(): RedirectResponse
|
||||||
{
|
{
|
||||||
$random = Episode::inRandomOrder()
|
$random = Episode::inRandomOrder()
|
||||||
->limit(1)
|
->limit(1)
|
||||||
@@ -69,7 +71,7 @@ class HomeController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display Search Page.
|
* Display Search Page.
|
||||||
*/
|
*/
|
||||||
public function search(): \Illuminate\View\View
|
public function search(): View
|
||||||
{
|
{
|
||||||
return view('search.index');
|
return view('search.index');
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,7 @@ class HomeController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display Download Search Page.
|
* Display Download Search Page.
|
||||||
*/
|
*/
|
||||||
public function downloadSearch(): \Illuminate\View\View
|
public function downloadSearch(): View
|
||||||
{
|
{
|
||||||
return view('search.download');
|
return view('search.download');
|
||||||
}
|
}
|
||||||
@@ -85,7 +87,7 @@ class HomeController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Redirect POST Data to GET with Query String.
|
* Redirect POST Data to GET with Query String.
|
||||||
*/
|
*/
|
||||||
public function searchRedirect(Request $request): \Illuminate\Http\RedirectResponse
|
public function searchRedirect(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
return redirect()->route('hentai.search', [
|
return redirect()->route('hentai.search', [
|
||||||
'search' => $request->input('live-search'),
|
'search' => $request->input('live-search'),
|
||||||
@@ -95,19 +97,31 @@ class HomeController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display Stats Page.
|
* Display Stats Page.
|
||||||
*/
|
*/
|
||||||
public function stats(): \Illuminate\View\View
|
public function stats(): View
|
||||||
{
|
{
|
||||||
return view('home.stats', [
|
return view('home.stats', [
|
||||||
'viewCount' => CacheHelper::getTotalViewCount(),
|
'viewCount' => CacheHelper::getTotalViewCount(),
|
||||||
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
|
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
|
||||||
'hentaiCount' => CacheHelper::getTotalHentaiCount(),
|
'hentaiCount' => CacheHelper::getTotalHentaiCount(),
|
||||||
|
'userCount' => CacheHelper::getTotalUserCount(),
|
||||||
|
'commentCount' => CacheHelper::getTotalCommentCount(),
|
||||||
|
'likeCount' => CacheHelper::getTotalLikeCount(),
|
||||||
|
'downloadCount' => CacheHelper::getTotalDownloadCount(),
|
||||||
|
'episodes4k' => CacheHelper::get4kEpisodeCount(),
|
||||||
|
'episodesUHD48' => CacheHelper::getUHD48FpsEpisodeCount(),
|
||||||
|
'todayViews' => CacheHelper::getTodayViewCount(),
|
||||||
|
'weeklyViews' => CacheHelper::getWeeklyViewCount(),
|
||||||
|
'prevWeeklyViews' => CacheHelper::getPreviousWeeklyViewCount(),
|
||||||
|
'avgViewsPerEpisode' => CacheHelper::getAverageViewsPerEpisode(),
|
||||||
|
'newEpisodesThisWeek' => CacheHelper::getNewEpisodesThisWeek(),
|
||||||
|
'topTags' => CacheHelper::getTopTags(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manually set website language
|
* Manually set website language
|
||||||
*/
|
*/
|
||||||
public function updateLanguage(Request $request): \Illuminate\Http\RedirectResponse
|
public function updateLanguage(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(in_array($request->language, config('app.supported_locales'), true), 404);
|
abort_unless(in_array($request->language, config('app.supported_locales'), true), 404);
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ namespace App\Http\Controllers;
|
|||||||
use App\Http\Requests\MatrixRegisterRequest;
|
use App\Http\Requests\MatrixRegisterRequest;
|
||||||
use App\Services\MatrixRegistrationService;
|
use App\Services\MatrixRegistrationService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class MatrixController extends Controller
|
class MatrixController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display the user page.
|
* Display the user page.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): \Illuminate\View\View
|
public function index(Request $request): View
|
||||||
{
|
{
|
||||||
$rooms = [
|
$rooms = [
|
||||||
['name' => '🏠 General', 'description' => 'Our main chat.', 'alias' => 'https://matrix.to/#/#general:hstream.moe'],
|
['name' => '🏠 General', 'description' => 'Our main chat.', 'alias' => 'https://matrix.to/#/#general:hstream.moe'],
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class NotificationController extends Controller
|
class NotificationController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display the user's notification page.
|
* Display the user's notification page.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): \Illuminate\View\View
|
public function index(Request $request): View
|
||||||
{
|
{
|
||||||
return view('profile.notifications', [
|
return view('profile.notifications', [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
@@ -18,10 +20,17 @@ class NotificationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete Notifcation
|
* Delete a notification or clear all.
|
||||||
*/
|
*/
|
||||||
public function delete(Request $request): \Illuminate\Http\RedirectResponse
|
public function delete(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
// Clear all notifications if no specific ID is provided
|
||||||
|
if (! $request->has('id')) {
|
||||||
|
$request->user()->notifications()->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('status', 'notifications-cleared');
|
||||||
|
}
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'id' => 'required|exists:notifications,id',
|
'id' => 'required|exists:notifications,id',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ use App\Models\Episode;
|
|||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\PlaylistEpisode;
|
use App\Models\PlaylistEpisode;
|
||||||
use App\Services\PlaylistService;
|
use App\Services\PlaylistService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class PlaylistController extends Controller
|
class PlaylistController extends Controller
|
||||||
{
|
{
|
||||||
@@ -20,7 +23,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the public playlists page.
|
* Display the public playlists page.
|
||||||
*/
|
*/
|
||||||
public function index(): \Illuminate\View\View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('playlist.index');
|
return view('playlist.index');
|
||||||
}
|
}
|
||||||
@@ -28,7 +31,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display public playlist.
|
* Display public playlist.
|
||||||
*/
|
*/
|
||||||
public function show($playlist_id): \Illuminate\View\View
|
public function show($playlist_id): View
|
||||||
{
|
{
|
||||||
if (! is_numeric($playlist_id)) {
|
if (! is_numeric($playlist_id)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -44,7 +47,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the user's playlists page.
|
* Display the user's playlists page.
|
||||||
*/
|
*/
|
||||||
public function playlists(Request $request): \Illuminate\View\View
|
public function playlists(Request $request): View
|
||||||
{
|
{
|
||||||
$title = 'Delete Playlist!';
|
$title = 'Delete Playlist!';
|
||||||
$text = 'Are you sure you want to delete?';
|
$text = 'Are you sure you want to delete?';
|
||||||
@@ -59,7 +62,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display user's playlist.
|
* Display user's playlist.
|
||||||
*/
|
*/
|
||||||
public function showPlaylist(Request $request, $playlist_id): \Illuminate\View\View
|
public function showPlaylist(Request $request, $playlist_id): View
|
||||||
{
|
{
|
||||||
if (! is_numeric($playlist_id)) {
|
if (! is_numeric($playlist_id)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -77,7 +80,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create user playlist (Form).
|
* Create user playlist (Form).
|
||||||
*/
|
*/
|
||||||
public function createPlaylist(Request $request): \Illuminate\Http\RedirectResponse
|
public function createPlaylist(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|max:30',
|
'name' => 'required|max:30',
|
||||||
@@ -92,10 +95,38 @@ class PlaylistController extends Controller
|
|||||||
return to_route('profile.playlists');
|
return to_route('profile.playlists');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user playlist.
|
||||||
|
*/
|
||||||
|
public function updatePlaylist(Request $request, $playlist_id): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! is_numeric($playlist_id)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|max:30',
|
||||||
|
'is_private' => 'required|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$playlist = Playlist::where('user_id', $user->id)
|
||||||
|
->where('id', $playlist_id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$playlist->update([
|
||||||
|
'name' => $request->input('name'),
|
||||||
|
'is_private' => $request->input('is_private'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'playlist-updated');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete user playlist.
|
* Delete user playlist.
|
||||||
*/
|
*/
|
||||||
public function deletePlaylist(Request $request, $playlist_id): \Illuminate\Http\RedirectResponse
|
public function deletePlaylist(Request $request, $playlist_id): RedirectResponse
|
||||||
{
|
{
|
||||||
if (! is_numeric($playlist_id)) {
|
if (! is_numeric($playlist_id)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -115,7 +146,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Delete episode from playlist.
|
* Delete episode from playlist.
|
||||||
*/
|
*/
|
||||||
public function deleteEpisodeFromPlaylist(Request $request): \Illuminate\Http\JsonResponse
|
public function deleteEpisodeFromPlaylist(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
if (! is_numeric($request->input('playlist')) || ! is_numeric($request->input('episode'))) {
|
if (! is_numeric($request->input('playlist')) || ! is_numeric($request->input('episode'))) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -143,7 +174,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Add to user playlist (API).
|
* Add to user playlist (API).
|
||||||
*/
|
*/
|
||||||
public function addPlaylistApi(Request $request): \Illuminate\Http\JsonResponse
|
public function addPlaylistApi(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -180,7 +211,7 @@ class PlaylistController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create user playlist (API).
|
* Create user playlist (API).
|
||||||
*/
|
*/
|
||||||
public function createPlaylistApi(Request $request): \Illuminate\Http\JsonResponse
|
public function createPlaylistApi(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|max:30',
|
'name' => 'required|max:30',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use Conner\Tagging\Model\Tag;
|
use Conner\Tagging\Model\Tag;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Redirect;
|
use Illuminate\Support\Facades\Redirect;
|
||||||
@@ -138,7 +139,7 @@ class ProfileController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Delete the user's account.
|
* Delete the user's account.
|
||||||
*/
|
*/
|
||||||
public function destroy(Request $request): \Illuminate\Http\RedirectResponse
|
public function destroy(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ class ProfileController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Store custom user avatar.
|
* Store custom user avatar.
|
||||||
*/
|
*/
|
||||||
protected function storeAvatar(\Illuminate\Http\UploadedFile $file, User $user): void
|
protected function storeAvatar(UploadedFile $file, User $user): void
|
||||||
{
|
{
|
||||||
// Create Folder for Image Upload
|
// Create Folder for Image Upload
|
||||||
if (! Storage::disk('public')->exists('/images/avatars')) {
|
if (! Storage::disk('public')->exists('/images/avatars')) {
|
||||||
|
|||||||
@@ -8,18 +8,17 @@ use App\Models\Gallery;
|
|||||||
use App\Models\Hentai;
|
use App\Models\Hentai;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\PlaylistEpisode;
|
use App\Models\PlaylistEpisode;
|
||||||
use App\Models\Watched;
|
|
||||||
use hisorange\BrowserDetect\Facade as Browser;
|
use hisorange\BrowserDetect\Facade as Browser;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class StreamController extends Controller
|
class StreamController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display Stream Page.
|
* Display Stream Page.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request, string $title): \Illuminate\View\View
|
public function index(Request $request, string $title): View
|
||||||
{
|
{
|
||||||
$titleParts = explode('-', $title);
|
$titleParts = explode('-', $title);
|
||||||
if (! is_numeric($titleParts[array_key_last($titleParts)])) {
|
if (! is_numeric($titleParts[array_key_last($titleParts)])) {
|
||||||
@@ -51,18 +50,6 @@ class StreamController extends Controller
|
|||||||
// Increment Popular Count
|
// Increment Popular Count
|
||||||
$episode->incrementPopularCount();
|
$episode->incrementPopularCount();
|
||||||
|
|
||||||
if (! Auth::guest()) {
|
|
||||||
$user = Auth::user();
|
|
||||||
|
|
||||||
// Add to user watched list
|
|
||||||
$time = Carbon::now()->subHour(1);
|
|
||||||
$alreadyWatched = Watched::where('user_id', $user->id)->where('episode_id', $episode->id)->where('created_at', '>=', $time)->exists();
|
|
||||||
if (! $alreadyWatched) {
|
|
||||||
Watched::create(['user_id' => $user->id, 'episode_id' => $episode->id]);
|
|
||||||
cache()->forget('user'.$user->id.'watched'.$episode->id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile Detection
|
// Mobile Detection
|
||||||
$isMobile = Browser::isMobile();
|
$isMobile = Browser::isMobile();
|
||||||
|
|
||||||
|
|||||||
+56
-29
@@ -2,7 +2,34 @@
|
|||||||
|
|
||||||
namespace App\Http;
|
namespace App\Http;
|
||||||
|
|
||||||
|
use App\Http\Middleware\Authenticate;
|
||||||
|
use App\Http\Middleware\EncryptCookies;
|
||||||
|
use App\Http\Middleware\IsAdmin;
|
||||||
|
use App\Http\Middleware\IsBanned;
|
||||||
|
use App\Http\Middleware\IsModerator;
|
||||||
|
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||||
|
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||||
|
use App\Http\Middleware\SetLocale;
|
||||||
|
use App\Http\Middleware\TrimStrings;
|
||||||
|
use App\Http\Middleware\TrustProxies;
|
||||||
|
use App\Http\Middleware\ValidateSignature;
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
|
||||||
|
use Illuminate\Auth\Middleware\Authorize;
|
||||||
|
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
|
||||||
|
use Illuminate\Auth\Middleware\RequirePassword;
|
||||||
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
|
||||||
|
use Illuminate\Http\Middleware\HandleCors;
|
||||||
|
use Illuminate\Http\Middleware\SetCacheHeaders;
|
||||||
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||||
|
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||||
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
|
||||||
class Kernel extends HttpKernel
|
class Kernel extends HttpKernel
|
||||||
{
|
{
|
||||||
@@ -15,12 +42,12 @@ class Kernel extends HttpKernel
|
|||||||
*/
|
*/
|
||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
// \App\Http\Middleware\TrustHosts::class,
|
// \App\Http\Middleware\TrustHosts::class,
|
||||||
\App\Http\Middleware\TrustProxies::class,
|
TrustProxies::class,
|
||||||
\Illuminate\Http\Middleware\HandleCors::class,
|
HandleCors::class,
|
||||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
PreventRequestsDuringMaintenance::class,
|
||||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
ValidatePostSize::class,
|
||||||
\App\Http\Middleware\TrimStrings::class,
|
TrimStrings::class,
|
||||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
ConvertEmptyStringsToNull::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,20 +57,20 @@ class Kernel extends HttpKernel
|
|||||||
*/
|
*/
|
||||||
protected $middlewareGroups = [
|
protected $middlewareGroups = [
|
||||||
'web' => [
|
'web' => [
|
||||||
\App\Http\Middleware\EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
\Illuminate\Session\Middleware\StartSession::class,
|
StartSession::class,
|
||||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
ShareErrorsFromSession::class,
|
||||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
\App\Http\Middleware\IsBanned::class,
|
IsBanned::class,
|
||||||
\App\Http\Middleware\SetLocale::class,
|
SetLocale::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'api' => [
|
'api' => [
|
||||||
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||||
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
ThrottleRequests::class.':api',
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -55,18 +82,18 @@ class Kernel extends HttpKernel
|
|||||||
* @var array<string, class-string|string>
|
* @var array<string, class-string|string>
|
||||||
*/
|
*/
|
||||||
protected $middlewareAliases = [
|
protected $middlewareAliases = [
|
||||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
'auth' => Authenticate::class,
|
||||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
'auth.basic' => AuthenticateWithBasicAuth::class,
|
||||||
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
'auth.session' => AuthenticateSession::class,
|
||||||
'auth.admin' => \App\Http\Middleware\IsAdmin::class,
|
'auth.admin' => IsAdmin::class,
|
||||||
'auth.moderator' => \App\Http\Middleware\IsModerator::class,
|
'auth.moderator' => IsModerator::class,
|
||||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
'cache.headers' => SetCacheHeaders::class,
|
||||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
'can' => Authorize::class,
|
||||||
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
'guest' => RedirectIfAuthenticated::class,
|
||||||
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
'password.confirm' => RequirePassword::class,
|
||||||
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
'precognitive' => HandlePrecognitiveRequests::class,
|
||||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
'signed' => ValidateSignature::class,
|
||||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
'throttle' => ThrottleRequests::class,
|
||||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
'verified' => EnsureEmailIsVerified::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ class IsModerator
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
* @param Closure(Request): (Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
if (Auth::check() && Auth::user()->hasRole(UserRole::MODERATOR)) {
|
if (Auth::check() && (
|
||||||
|
Auth::user()->hasRole(UserRole::MODERATOR) ||
|
||||||
|
Auth::user()->hasRole(UserRole::ADMINISTRATOR))) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('error_msg', 'This resource is restricted to Administrators!');
|
session()->flash('error_msg', 'This resource is restricted to Moderators!');
|
||||||
|
|
||||||
return redirect()->route('home.index');
|
return redirect()->route('home.index');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class RedirectIfAuthenticated
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
* @param Closure(Request): (Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class SetLocale
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
* @param Closure(Request): (Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,9 @@ class SetLocale
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Session (guest or user override)
|
// 2. Session (guest or user override)
|
||||||
if (session()->has('locale') && in_array($request->language, config('app.supported_locales'), true)) {
|
if ($request->session()->has('locale') &&
|
||||||
|
in_array(session('locale'), config('app.supported_locales'), true)) {
|
||||||
|
|
||||||
App::setLocale(session('locale'));
|
App::setLocale(session('locale'));
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Auth;
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
use GrantHolle\Altcha\Rules\ValidAltcha;
|
use App\Rules\ValidCaptcha;
|
||||||
use Illuminate\Auth\Events\Lockout;
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
@@ -23,21 +24,21 @@ class LoginRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email' => ['required', 'string', 'email'],
|
'email' => ['required', 'string', 'email'],
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
'altcha' => ['required', new ValidAltcha],
|
'altcha' => ['required', new ValidCaptcha],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to authenticate the request's credentials.
|
* Attempt to authenticate the request's credentials.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function authenticate(): void
|
public function authenticate(): void
|
||||||
{
|
{
|
||||||
@@ -57,7 +58,7 @@ class LoginRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* Ensure the login request is not rate limited.
|
* Ensure the login request is not rate limited.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function ensureIsNotRateLimited(): void
|
public function ensureIsNotRateLimited(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class MatrixRegisterRequest extends FormRequest
|
class MatrixRegisterRequest extends FormRequest
|
||||||
@@ -20,7 +21,7 @@ class MatrixRegisterRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
@@ -10,37 +13,147 @@ class AdminCommentSearch extends Component
|
|||||||
{
|
{
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
public $search = '';
|
public $search = '';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
public $userSearch = '';
|
public $userSearch = '';
|
||||||
|
|
||||||
public function updatingSearch(): void
|
#[Url(history: true)]
|
||||||
|
public $sortField = 'created_at';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $sortDirection = 'desc';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $perPage = 20;
|
||||||
|
|
||||||
|
public $selected = [];
|
||||||
|
|
||||||
|
public $selectPage = false;
|
||||||
|
|
||||||
|
protected $queryString = [
|
||||||
|
'search' => ['except' => ''],
|
||||||
|
'userSearch' => ['except' => ''],
|
||||||
|
'sortField' => ['except' => 'created_at'],
|
||||||
|
'sortDirection' => ['except' => 'desc'],
|
||||||
|
'perPage' => ['except' => 20],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $allowedSortFields = ['id', 'body', 'created_at', 'user_id'];
|
||||||
|
|
||||||
|
protected $allowedPerPages = [10, 20, 50, 100];
|
||||||
|
|
||||||
|
public function updatedPerPage(): void
|
||||||
{
|
{
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatingUserSearch(): void
|
public function updatedPage(): void
|
||||||
{
|
{
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectPage($value): void
|
||||||
|
{
|
||||||
|
if ($value) {
|
||||||
|
$this->selected = $this->comments->pluck('id')->map(fn ($id) => (string) $id)->toArray();
|
||||||
|
} else {
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $field): void
|
||||||
|
{
|
||||||
|
if (! in_array($field, $this->allowedSortFields)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->sortField === $field) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortField = $field;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilters(): void
|
||||||
|
{
|
||||||
|
$this->search = '';
|
||||||
|
$this->userSearch = '';
|
||||||
|
$this->sortField = 'created_at';
|
||||||
|
$this->sortDirection = 'desc';
|
||||||
|
$this->perPage = 20;
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteComment($commentId)
|
public function deleteComment(int $commentId): void
|
||||||
{
|
{
|
||||||
$comment = Comment::where('id', (int) $commentId)->firstOrFail();
|
$comment = Comment::findOrFail($commentId);
|
||||||
$comment->delete();
|
$comment->delete();
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: 'Comment deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkDelete(): void
|
||||||
|
{
|
||||||
|
$count = Comment::whereIn('id', array_map('intval', $this->selected))->delete();
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} comment(s) deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function banCommentAuthor(int $commentId): void
|
||||||
|
{
|
||||||
|
$comment = Comment::findOrFail($commentId);
|
||||||
|
$user = $comment->user;
|
||||||
|
|
||||||
|
if ($user && ! $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
|
||||||
|
} else {
|
||||||
|
$this->dispatch('notify', type: 'error', message: 'User is already banned or not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkBanAuthors(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
$userIds = Comment::whereIn('id', array_map('intval', $this->selected))
|
||||||
|
->pluck('user_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
foreach ($userIds as $userId) {
|
||||||
|
$user = User::find($userId);
|
||||||
|
if ($user && ! $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cache()->flush();
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} comment author(s) banned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCommentsProperty()
|
||||||
|
{
|
||||||
|
return Comment::query()
|
||||||
|
->with('user')
|
||||||
|
->when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%{$this->search}%"))
|
||||||
|
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($q) => $q->where('name', 'LIKE', "%{$this->userSearch}%")))
|
||||||
|
->orderBy($this->sortField, $this->sortDirection)
|
||||||
|
->paginate((int) $this->perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$comments = Comment::when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%$this->search%"))
|
|
||||||
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($query) => $query->where('name', 'LIKE', "%{$this->userSearch}%")))
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->paginate(12);
|
|
||||||
|
|
||||||
return view('livewire.admin-comment-search', [
|
return view('livewire.admin-comment-search', [
|
||||||
'comments' => $comments,
|
'comments' => $this->comments,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,32 +20,239 @@ class AdminUserSearch extends Component
|
|||||||
public $discordId = '';
|
public $discordId = '';
|
||||||
|
|
||||||
#[Url(history: true)]
|
#[Url(history: true)]
|
||||||
public $patreon = [];
|
public $email = '';
|
||||||
|
|
||||||
#[Url(history: true)]
|
#[Url(history: true)]
|
||||||
public $banned = [];
|
public $roleFilter = [];
|
||||||
|
|
||||||
public function deleteUserComments(int $userID)
|
#[Url(history: true)]
|
||||||
|
public $sortField = 'created_at';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $sortDirection = 'desc';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $perPage = 20;
|
||||||
|
|
||||||
|
public $selected = [];
|
||||||
|
|
||||||
|
public $selectAll = false;
|
||||||
|
|
||||||
|
public $selectPage = false;
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
public $showUserModal = false;
|
||||||
|
|
||||||
|
public $modalUser = null;
|
||||||
|
|
||||||
|
public $modalUserComments = [];
|
||||||
|
|
||||||
|
protected $queryString = [
|
||||||
|
'search' => ['except' => ''],
|
||||||
|
'discordId' => ['except' => ''],
|
||||||
|
'email' => ['except' => ''],
|
||||||
|
'roleFilter' => ['except' => []],
|
||||||
|
'sortField' => ['except' => 'created_at'],
|
||||||
|
'sortDirection' => ['except' => 'desc'],
|
||||||
|
'perPage' => ['except' => 20],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $allowedSortFields = ['id', 'name', 'email', 'discord_id', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
|
protected $allowedPerPages = [10, 20, 50, 100];
|
||||||
|
|
||||||
|
public function updatedPerPage(): void
|
||||||
{
|
{
|
||||||
$user = User::where('id', $userID)
|
$this->resetPage();
|
||||||
->firstOrFail();
|
}
|
||||||
|
|
||||||
Comment::where('user_id', $user->id)
|
public function updatedPage(): void
|
||||||
->delete();
|
{
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectPage($value): void
|
||||||
|
{
|
||||||
|
if ($value) {
|
||||||
|
$this->selected = $this->users->pluck('id')->map(fn ($id) => (string) $id)->toArray();
|
||||||
|
} else {
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $field): void
|
||||||
|
{
|
||||||
|
if (! in_array($field, $this->allowedSortFields)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->sortField === $field) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortField = $field;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilters(): void
|
||||||
|
{
|
||||||
|
$this->search = '';
|
||||||
|
$this->discordId = '';
|
||||||
|
$this->email = '';
|
||||||
|
$this->roleFilter = [];
|
||||||
|
$this->sortField = 'created_at';
|
||||||
|
$this->sortDirection = 'desc';
|
||||||
|
$this->perPage = 20;
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewUser(int $userId): void
|
||||||
|
{
|
||||||
|
$this->modalUser = User::find($userId);
|
||||||
|
|
||||||
|
if ($this->modalUser) {
|
||||||
|
$this->modalUserComments = $this->modalUser->comments()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
$this->showUserModal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeModal(): void
|
||||||
|
{
|
||||||
|
$this->showUserModal = false;
|
||||||
|
$this->modalUser = null;
|
||||||
|
$this->modalUserComments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function banUser(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
cache()->flush();
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unbanUser(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->removeRole(UserRole::BANNED);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been unbanned.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grantModerator(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->addRole(UserRole::MODERATOR);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been granted Moderator role.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeModerator(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->removeRole(UserRole::MODERATOR);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "Moderator role revoked from {$user->name}.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteUserComments(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
Comment::where('user_id', $user->id)->delete();
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "All comments from {$user->name} have been deleted.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUserComments = collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkBan(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->selected as $userId) {
|
||||||
|
$user = User::find($userId);
|
||||||
|
if ($user && ! $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been banned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkUnban(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->selected as $userId) {
|
||||||
|
$user = User::find($userId);
|
||||||
|
if ($user && $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->removeRole(UserRole::BANNED);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been unbanned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkDeleteComments(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->selected as $userId) {
|
||||||
|
$deleted = Comment::where('user_id', (int) $userId)->delete();
|
||||||
|
if ($deleted > 0) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "Deleted comments from {$count} user(s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsersProperty()
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
|
||||||
|
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
|
||||||
|
->when($this->email !== '', fn ($query) => $query->where('email', 'like', '%'.$this->email.'%'))
|
||||||
|
->when(! empty($this->roleFilter), function ($query) {
|
||||||
|
foreach ($this->roleFilter as $role) {
|
||||||
|
$query->whereJsonContains('roles', $role);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderBy($this->sortField, $this->sortDirection)
|
||||||
|
->paginate((int) $this->perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$users = User::when($this->patreon !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::SUPPORTER->value))
|
|
||||||
->when($this->banned !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::BANNED->value))
|
|
||||||
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
|
|
||||||
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
|
|
||||||
->paginate(20);
|
|
||||||
|
|
||||||
return view('livewire.admin-user-search', [
|
return view('livewire.admin-user-search', [
|
||||||
'users' => $users,
|
'users' => $this->users,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
|
use App\Models\ModLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\CommentNotification;
|
use App\Notifications\CommentNotification;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
@@ -43,7 +45,7 @@ class Comment extends Component
|
|||||||
'replyState.body' => 'reply',
|
'replyState.body' => 'reply',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function updatedIsEditing($isEditing)
|
public function updatedIsEditing(bool $isEditing)
|
||||||
{
|
{
|
||||||
if (! $isEditing) {
|
if (! $isEditing) {
|
||||||
return;
|
return;
|
||||||
@@ -67,11 +69,45 @@ class Comment extends Component
|
|||||||
{
|
{
|
||||||
$this->authorize('destroy', $this->comment);
|
$this->authorize('destroy', $this->comment);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($user->hasRole(UserRole::ADMINISTRATOR) || $user->hasRole(UserRole::MODERATOR)) {
|
||||||
|
// Log to ModLog
|
||||||
|
ModLog::create([
|
||||||
|
'moderator' => $user->name,
|
||||||
|
'data' => "Deleted comment {$this->comment->id} written by {$this->comment->user->id} with contents: {$this->comment->body}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->comment->deleted_by_moderator_id = $user->id;
|
||||||
|
$this->comment->save();
|
||||||
|
$this->dispatch('refresh');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->comment->delete();
|
$this->comment->delete();
|
||||||
|
|
||||||
$this->dispatch('refresh');
|
$this->dispatch('refresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function restoreComment()
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $this->comment);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($user->hasRole(UserRole::ADMINISTRATOR) || $user->hasRole(UserRole::MODERATOR)) {
|
||||||
|
// Log to ModLog
|
||||||
|
ModLog::create([
|
||||||
|
'moderator' => $user->name,
|
||||||
|
'data' => "Restored comment {$this->comment->id} written by {$this->comment->user->id} with contents: {$this->comment->body}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->comment->deleted_by_moderator_id = null;
|
||||||
|
$this->comment->save();
|
||||||
|
$this->dispatch('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function postReply()
|
public function postReply()
|
||||||
{
|
{
|
||||||
if (! ($this->comment->depth() < 2)) {
|
if (! ($this->comment->depth() < 2)) {
|
||||||
|
|||||||
@@ -23,11 +23,21 @@ class DownloadButton extends Component
|
|||||||
|
|
||||||
public $fileExtension = 'HEVC';
|
public $fileExtension = 'HEVC';
|
||||||
|
|
||||||
|
public $version = '';
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (str_contains($this->downloadUrl, 'AV1')) {
|
if (str_contains($this->downloadUrl, 'AV1')) {
|
||||||
$this->fileExtension = 'AV1';
|
$this->fileExtension = 'AV1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_contains($this->downloadUrl, 'v2')) {
|
||||||
|
$this->version = 'v2';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($this->downloadUrl, 'v3')) {
|
||||||
|
$this->version = 'v3';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clicked($downloadId)
|
public function clicked($downloadId)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Downloads;
|
use App\Models\Downloads;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -74,9 +75,9 @@ class DownloadsSearch extends Component
|
|||||||
$types[] = 'FHD';
|
$types[] = 'FHD';
|
||||||
} elseif ($label === 'FHD 48fps') {
|
} elseif ($label === 'FHD 48fps') {
|
||||||
$types[] = 'FHDi';
|
$types[] = 'FHDi';
|
||||||
} elseif ($label === 'UHD' && auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) {
|
} elseif ($label === 'UHD' && auth()->user()->hasRole(UserRole::SUPPORTER)) {
|
||||||
$types[] = 'UHD';
|
$types[] = 'UHD';
|
||||||
} elseif ($label === 'UHD 48fps' && auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) {
|
} elseif ($label === 'UHD 48fps' && auth()->user()->hasRole(UserRole::SUPPORTER)) {
|
||||||
$types[] = 'UHDi';
|
$types[] = 'UHDi';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +100,7 @@ class DownloadsSearch extends Component
|
|||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (! auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER)) {
|
if (! auth()->user()->hasRole(UserRole::SUPPORTER)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class NavLiveSearch extends Component
|
|||||||
if ($this->navSearch != '') {
|
if ($this->navSearch != '') {
|
||||||
$episodes = Episode::search($this->navSearch)
|
$episodes = Episode::search($this->navSearch)
|
||||||
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
|
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
|
||||||
|
->query(fn ($query) => $query->with(['gallery', 'studio']))
|
||||||
->take(7)
|
->take(7)
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class PlaylistOverview extends Component
|
|||||||
|
|
||||||
public Collection $playlistEpisodes;
|
public Collection $playlistEpisodes;
|
||||||
|
|
||||||
|
public bool $editingName = false;
|
||||||
|
|
||||||
|
public string $editingPlaylistName = '';
|
||||||
|
|
||||||
public function boot(PlaylistService $playlistService)
|
public function boot(PlaylistService $playlistService)
|
||||||
{
|
{
|
||||||
$this->playlistService = $playlistService;
|
$this->playlistService = $playlistService;
|
||||||
@@ -112,6 +116,53 @@ class PlaylistOverview extends Component
|
|||||||
$this->refreshEpisodes();
|
$this->refreshEpisodes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function editName()
|
||||||
|
{
|
||||||
|
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->editingPlaylistName = $this->playlist->name;
|
||||||
|
$this->editingName = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelEditName()
|
||||||
|
{
|
||||||
|
$this->editingName = false;
|
||||||
|
$this->editingPlaylistName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateName()
|
||||||
|
{
|
||||||
|
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editingPlaylistName' => 'required|max:30',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->playlist->update([
|
||||||
|
'name' => $this->editingPlaylistName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->editingName = false;
|
||||||
|
$this->editingPlaylistName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleVisibility()
|
||||||
|
{
|
||||||
|
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->playlist->update([
|
||||||
|
'is_private' => ! $this->playlist->is_private,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->playlist->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.playlist-overview', [
|
return view('livewire.playlist-overview', [
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Episode;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class ViewCount extends Component
|
|
||||||
{
|
|
||||||
public $episodeId = 0;
|
|
||||||
|
|
||||||
public $viewCount = 0;
|
|
||||||
|
|
||||||
public function mount(Episode $episode)
|
|
||||||
{
|
|
||||||
$this->episodeId = $episode->id;
|
|
||||||
$this->viewCount = $episode->view_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update()
|
|
||||||
{
|
|
||||||
$this->viewCount = Episode::where('id', $this->episodeId)->firstOrFail()->view_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.view-count');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -72,4 +72,12 @@ class Comment extends Model
|
|||||||
{
|
{
|
||||||
return cache()->remember('commentLikes'.$this->id, 300, fn () => $this->likes->count());
|
return cache()->remember('commentLikes'.$this->id, 300, fn () => $this->likes->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns wether or not comment has been removed by moderation
|
||||||
|
*/
|
||||||
|
public function isDeletedByModerator(): bool
|
||||||
|
{
|
||||||
|
return $this->deleted_by_moderator_id !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ModLog extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'moderator',
|
||||||
|
'data',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -7,6 +7,26 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
|
|
||||||
class Playlist extends Model
|
class Playlist extends Model
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'name',
|
||||||
|
'is_private',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'is_private' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Belongs To A User.
|
* Belongs To A User.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class SiteBackground extends Model
|
class SiteBackground extends Model
|
||||||
{
|
{
|
||||||
@@ -21,7 +22,7 @@ class SiteBackground extends Model
|
|||||||
/**
|
/**
|
||||||
* Returns the current IDs of active wallpaper
|
* Returns the current IDs of active wallpaper
|
||||||
*/
|
*/
|
||||||
public function getImages(): ?\Illuminate\Support\Collection
|
public function getImages(): ?Collection
|
||||||
{
|
{
|
||||||
$now = Carbon::now();
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -11,10 +11,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
|
||||||
|
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements HasPasskeys
|
||||||
{
|
{
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, InteractsWithPasskeys, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -153,7 +155,11 @@ class User extends Authenticatable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->roles = array_diff($this->roles, [$role->value]);
|
$this->roles = collect($this->roles)
|
||||||
|
->reject(fn ($value) => $value === $role->value)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VideoEngagement extends Model
|
||||||
|
{
|
||||||
|
public $table = 'video_engagement';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
protected $fillable = ['episode_id', 'user_id', 'segment'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Episode.
|
||||||
|
*/
|
||||||
|
public function episode(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Episode::class, 'episode_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the User.
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Policies;
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
@@ -17,6 +18,26 @@ class CommentPolicy
|
|||||||
|
|
||||||
public function destroy(User $user, Comment $comment): bool
|
public function destroy(User $user, Comment $comment): bool
|
||||||
{
|
{
|
||||||
|
if ($user->hasRole(UserRole::ADMINISTRATOR) ||
|
||||||
|
$user->hasRole(UserRole::MODERATOR)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return $user->id === $comment->user_id;
|
return $user->id === $comment->user_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, Comment $comment): bool
|
||||||
|
{
|
||||||
|
// Comment not deleted
|
||||||
|
if ($comment->deleted_by_moderator_id === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole(UserRole::ADMINISTRATOR) ||
|
||||||
|
$user->hasRole(UserRole::MODERATOR)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Providers;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use SocialiteProviders\Discord\Provider;
|
||||||
|
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -20,8 +22,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
|
Event::listen(function (SocialiteWasCalled $event) {
|
||||||
$event->extendSocialite('discord', \SocialiteProviders\Discord\Provider::class);
|
$event->extendSocialite('discord', Provider::class);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
|
||||||
|
use AltchaOrg\Altcha\Altcha;
|
||||||
|
use AltchaOrg\Altcha\Challenge;
|
||||||
|
use AltchaOrg\Altcha\ChallengeParameters;
|
||||||
|
use AltchaOrg\Altcha\Payload;
|
||||||
|
use AltchaOrg\Altcha\Solution;
|
||||||
|
use AltchaOrg\Altcha\VerifySolutionOptions;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation rule to verify captcha solution.
|
||||||
|
*/
|
||||||
|
class ValidCaptcha implements ValidationRule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Altcha instance.
|
||||||
|
*/
|
||||||
|
protected Altcha $altcha;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pbkdf2 algorithm instance.
|
||||||
|
*/
|
||||||
|
protected Pbkdf2 $pbkdf2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class constructor.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->pbkdf2 = new Pbkdf2;
|
||||||
|
$this->altcha = new Altcha(
|
||||||
|
hmacSignatureSecret: config('captcha.hmac_key'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse payload and return the decoded data as an array.
|
||||||
|
*/
|
||||||
|
private function parsePayload(string $value): ?array
|
||||||
|
{
|
||||||
|
$decoded = base64_decode($value, true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($decoded, true);
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if payload has required fields.
|
||||||
|
*/
|
||||||
|
private function verifyFields(array $payload): bool
|
||||||
|
{
|
||||||
|
if (! isset($payload['challenge'], $payload['solution'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($payload['challenge']) || ! is_array($payload['solution'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Challenge object from challenge data.
|
||||||
|
*/
|
||||||
|
private function createChallenge(array $challengeData): Challenge
|
||||||
|
{
|
||||||
|
return new Challenge(
|
||||||
|
ChallengeParameters::fromArray($challengeData['parameters'] ?? []),
|
||||||
|
$challengeData['signature'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Solution object from solution data.
|
||||||
|
*/
|
||||||
|
private function createSolution(array $solutionData): Solution
|
||||||
|
{
|
||||||
|
return new Solution(
|
||||||
|
counter: (int) ($solutionData['counter'] ?? 0),
|
||||||
|
derivedKey: (string) ($solutionData['derivedKey'] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the validation rule.
|
||||||
|
*
|
||||||
|
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||||
|
*/
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
$payload = $this->parsePayload($value);
|
||||||
|
if (! $payload) {
|
||||||
|
$fail('Invalid captcha.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->verifyFields($payload)) {
|
||||||
|
$fail('Invalid captcha.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$challenge = $this->createChallenge($payload['challenge']);
|
||||||
|
$solution = $this->createSolution($payload['solution']);
|
||||||
|
|
||||||
|
$result = $this->altcha->verifySolution(new VerifySolutionOptions(
|
||||||
|
algorithm: $this->pbkdf2,
|
||||||
|
payload: new Payload($challenge, $solution),
|
||||||
|
));
|
||||||
|
|
||||||
|
if (! $result->verified) {
|
||||||
|
$fail('Invalid captcha.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
|||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
use App\Models\Hentai;
|
use App\Models\Hentai;
|
||||||
use App\Models\Studios;
|
use App\Models\Studios;
|
||||||
|
use App\Models\ModLog;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -62,6 +63,68 @@ class EpisodeService
|
|||||||
return $episode;
|
return $episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyTags(Request $request, Episode $episode): void
|
||||||
|
{
|
||||||
|
$tags = json_decode($request->input('tags'));
|
||||||
|
$newtags = [];
|
||||||
|
foreach ($tags as $t) {
|
||||||
|
$newtags[] = $t->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newTagsTemp = $newtags;
|
||||||
|
$oldTagsTemp = $episode->tagNames();
|
||||||
|
|
||||||
|
sort($newTagsTemp);
|
||||||
|
sort($oldTagsTemp);
|
||||||
|
|
||||||
|
if ($newTagsTemp !== $oldTagsTemp) {
|
||||||
|
ModLog::create([
|
||||||
|
'moderator' => $request->user()->name,
|
||||||
|
'data' => sprintf(
|
||||||
|
'Updated Episode tags from %s to %s',
|
||||||
|
implode(', ', $oldTagsTemp),
|
||||||
|
implode(', ', $newTagsTemp),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$episode->retag($newtags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTitle(Request $request, Episode $episode): void
|
||||||
|
{
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
if ($episode->title !== $request->input('title')) {
|
||||||
|
$updates['title'] = $request->input('title');
|
||||||
|
$updates['title_search'] = preg_replace(
|
||||||
|
'/[^A-Za-z0-9 ]/',
|
||||||
|
'',
|
||||||
|
$request->input('title')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log to ModLog
|
||||||
|
ModLog::create([
|
||||||
|
'moderator' => $request->user()->name,
|
||||||
|
'data' => "Updating Hentai Title from {$episode->title} to {$request->input('title')}",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($episode->title_jpn !== $request->input('title_jpn')) {
|
||||||
|
$updates['title_jpn'] = $request->input('title_jpn');
|
||||||
|
|
||||||
|
// Log to ModLog
|
||||||
|
ModLog::create([
|
||||||
|
'moderator' => $request->user()->name,
|
||||||
|
'data' => "Updating Hentai Title from {$episode->title_jpn} to {$request->input('title_jpn')}",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($updates)) {
|
||||||
|
$episode->hentai->episodes()->update($updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function updateEpisode(Request $request, Studios $studio, int $episodeId): Episode
|
public function updateEpisode(Request $request, Studios $studio, int $episodeId): Episode
|
||||||
{
|
{
|
||||||
$episode = Episode::where('id', $episodeId)->firstOrFail();
|
$episode = Episode::where('id', $episodeId)->firstOrFail();
|
||||||
@@ -75,17 +138,31 @@ class EpisodeService
|
|||||||
$episode->dmca_takedown = $request->input('dmca_takedown') == 'true';
|
$episode->dmca_takedown = $request->input('dmca_takedown') == 'true';
|
||||||
$episode->save();
|
$episode->save();
|
||||||
|
|
||||||
// Tagging
|
$this->applyTags($request, $episode);
|
||||||
$tags = json_decode($request->input('tags'));
|
$this->updateTitle($request, $episode);
|
||||||
$newtags = [];
|
|
||||||
foreach ($tags as $t) {
|
|
||||||
$newtags[] = $t->value;
|
|
||||||
}
|
|
||||||
$episode->retag($newtags);
|
|
||||||
|
|
||||||
return $episode;
|
return $episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateEpisodeModerator(Request $request, int $episodeId): void
|
||||||
|
{
|
||||||
|
$episode = Episode::where('id', $episodeId)->firstOrFail();
|
||||||
|
$oldDescription = $episode->description;
|
||||||
|
$episode->description = $request->input('description');
|
||||||
|
$episode->save();
|
||||||
|
|
||||||
|
if ($episode->description !== $oldDescription) {
|
||||||
|
// Log to ModLog
|
||||||
|
ModLog::create([
|
||||||
|
'moderator' => $request->user()->name,
|
||||||
|
'data' => "Updated Episode description from {$oldDescription} to {$episode->description}",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyTags($request, $episode);
|
||||||
|
$this->updateTitle($request, $episode);
|
||||||
|
}
|
||||||
|
|
||||||
public function getOrCreateStudio(string $studioName): Studios
|
public function getOrCreateStudio(string $studioName): Studios
|
||||||
{
|
{
|
||||||
return Studios::firstOrCreate(
|
return Studios::firstOrCreate(
|
||||||
|
|||||||
+9
-4
@@ -1,5 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Exceptions\Handler;
|
||||||
|
use App\Http\Kernel;
|
||||||
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Create The Application
|
| Create The Application
|
||||||
@@ -11,7 +16,7 @@
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$app = new Illuminate\Foundation\Application(
|
$app = new Application(
|
||||||
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
|
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -28,7 +33,7 @@ $app = new Illuminate\Foundation\Application(
|
|||||||
|
|
||||||
$app->singleton(
|
$app->singleton(
|
||||||
Illuminate\Contracts\Http\Kernel::class,
|
Illuminate\Contracts\Http\Kernel::class,
|
||||||
App\Http\Kernel::class
|
Kernel::class
|
||||||
);
|
);
|
||||||
|
|
||||||
$app->singleton(
|
$app->singleton(
|
||||||
@@ -37,8 +42,8 @@ $app->singleton(
|
|||||||
);
|
);
|
||||||
|
|
||||||
$app->singleton(
|
$app->singleton(
|
||||||
Illuminate\Contracts\Debug\ExceptionHandler::class,
|
ExceptionHandler::class,
|
||||||
App\Exceptions\Handler::class
|
Handler::class
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"grantholle/laravel-altcha": "^2.1",
|
"altcha-org/altcha": "^2.0",
|
||||||
"guzzlehttp/guzzle": "^7.8.1",
|
"guzzlehttp/guzzle": "^7.8.1",
|
||||||
"hisorange/browser-detect": "^5.0",
|
"hisorange/browser-detect": "^5.0",
|
||||||
"http-interop/http-factory-guzzle": "^1.2",
|
"http-interop/http-factory-guzzle": "^1.2",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"rtconner/laravel-tagging": "^5.0",
|
"rtconner/laravel-tagging": "^5.0",
|
||||||
"socialiteproviders/discord": "^4.2",
|
"socialiteproviders/discord": "^4.2",
|
||||||
"spatie/laravel-discord-alerts": "^1.8",
|
"spatie/laravel-discord-alerts": "^1.8",
|
||||||
|
"spatie/laravel-passkeys": "^1.7",
|
||||||
"spatie/laravel-sitemap": "^7.3"
|
"spatie/laravel-sitemap": "^7.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
Generated
+1807
-753
File diff suppressed because it is too large
Load Diff
+10
-5
@@ -1,7 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\AuthServiceProvider;
|
||||||
|
use App\Providers\EventServiceProvider;
|
||||||
|
use App\Providers\RouteServiceProvider;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Mews\Captcha\Facades\Captcha;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -175,11 +180,11 @@ return [
|
|||||||
/*
|
/*
|
||||||
* Application Service Providers...
|
* Application Service Providers...
|
||||||
*/
|
*/
|
||||||
App\Providers\AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
App\Providers\AuthServiceProvider::class,
|
AuthServiceProvider::class,
|
||||||
// App\Providers\BroadcastServiceProvider::class,
|
// App\Providers\BroadcastServiceProvider::class,
|
||||||
App\Providers\EventServiceProvider::class,
|
EventServiceProvider::class,
|
||||||
App\Providers\RouteServiceProvider::class,
|
RouteServiceProvider::class,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -195,7 +200,7 @@ return [
|
|||||||
|
|
||||||
'aliases' => Facade::defaultAliases()->merge([
|
'aliases' => Facade::defaultAliases()->merge([
|
||||||
// 'Example' => App\Facades\Example::class,
|
// 'Example' => App\Facades\Example::class,
|
||||||
'Captcha' => Mews\Captcha\Facades\Captcha::class,
|
'Captcha' => Captcha::class,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -62,7 +64,7 @@ return [
|
|||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => App\Models\User::class,
|
'model' => User::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
// 'users' => [
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Altcha Captcha System
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'hmac_key' => env('ALTCHA_HMAC_KEY'),
|
||||||
|
];
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Spatie\DiscordAlerts\Jobs\SendToDiscordChannelJob;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
/*
|
/*
|
||||||
* The webhook URLs that we'll use to send a message to Discord.
|
* The webhook URLs that we'll use to send a message to Discord.
|
||||||
@@ -14,5 +16,5 @@ return [
|
|||||||
* This job will send the message to Discord. You can extend this
|
* This job will send the message to Discord. You can extend this
|
||||||
* job to set timeouts, retries, etc...
|
* job to set timeouts, retries, etc...
|
||||||
*/
|
*/
|
||||||
'job' => Spatie\DiscordAlerts\Jobs\SendToDiscordChannelJob::class,
|
'job' => SendToDiscordChannelJob::class,
|
||||||
];
|
];
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -16,7 +18,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'driver' => \Intervention\Image\Drivers\Gd\Driver::class,
|
'driver' => Driver::class,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Spatie\LaravelPasskeys\Actions\ConfigureCeremonyStepManagerFactoryAction;
|
||||||
|
use Spatie\LaravelPasskeys\Actions\FindPasskeyToAuthenticateAction;
|
||||||
|
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyAuthenticationOptionsAction;
|
||||||
|
use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction;
|
||||||
|
use Spatie\LaravelPasskeys\Actions\StorePasskeyAction;
|
||||||
|
use Spatie\LaravelPasskeys\Models\Passkey;
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
* After a successful authentication attempt using a passkey
|
||||||
|
* we'll redirect to this URL.
|
||||||
|
*/
|
||||||
|
'redirect_to_after_login' => '/',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These class are responsible for performing core tasks regarding passkeys.
|
||||||
|
* You can customize them by creating a class that extends the default, and
|
||||||
|
* by specifying your custom class name here.
|
||||||
|
*/
|
||||||
|
'actions' => [
|
||||||
|
'generate_passkey_register_options' => GeneratePasskeyRegisterOptionsAction::class,
|
||||||
|
'store_passkey' => StorePasskeyAction::class,
|
||||||
|
'generate_passkey_authentication_options' => GeneratePasskeyAuthenticationOptionsAction::class,
|
||||||
|
'find_passkey' => FindPasskeyToAuthenticateAction::class,
|
||||||
|
'configure_ceremony_step_manager_factory' => ConfigureCeremonyStepManagerFactoryAction::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These properties will be used to generate the passkey.
|
||||||
|
*/
|
||||||
|
'relying_party' => [
|
||||||
|
'name' => config('app.name'),
|
||||||
|
'id' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||||
|
'icon' => null,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The models used by the package.
|
||||||
|
*
|
||||||
|
* You can override this by specifying your own models
|
||||||
|
*/
|
||||||
|
'models' => [
|
||||||
|
'passkey' => Passkey::class,
|
||||||
|
'authenticatable' => env('AUTH_MODEL', User::class),
|
||||||
|
],
|
||||||
|
];
|
||||||
+6
-3
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -60,9 +63,9 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Episode;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Episode>
|
* @extends Factory<Episode>
|
||||||
*/
|
*/
|
||||||
class EpisodeFactory extends Factory
|
class EpisodeFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Hentai;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Hentai>
|
* @extends Factory<Hentai>
|
||||||
*/
|
*/
|
||||||
class HentaiFactory extends Factory
|
class HentaiFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Studios;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Studios>
|
* @extends Factory<Studios>
|
||||||
*/
|
*/
|
||||||
class StudiosFactory extends Factory
|
class StudiosFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
* @extends Factory<User>
|
||||||
*/
|
*/
|
||||||
class UserFactory extends Factory
|
class UserFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ return new class extends Migration
|
|||||||
|
|
||||||
$alreadyexists = Episode::where('slug', $episode->slug)->first();
|
$alreadyexists = Episode::where('slug', $episode->slug)->first();
|
||||||
if ($alreadyexists) {
|
if ($alreadyexists) {
|
||||||
throw new \RuntimeException('Migration stopped! Slug already exists: '.$episode->slug);
|
throw new RuntimeException('Migration stopped! Slug already exists: '.$episode->slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
$episode->save();
|
$episode->save();
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Spatie\LaravelPasskeys\Support\Config;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$authenticatableClass = Config::getAuthenticatableModel();
|
||||||
|
|
||||||
|
$authenticatableTableName = (new $authenticatableClass)->getTable();
|
||||||
|
|
||||||
|
Schema::create('passkeys', function (Blueprint $table) use ($authenticatableTableName, $authenticatableClass) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
$table
|
||||||
|
->foreignIdFor($authenticatableClass, 'authenticatable_id')
|
||||||
|
->constrained(table: $authenticatableTableName, indexName: 'passkeys_authenticatable_fk')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->text('name');
|
||||||
|
$table->text('credential_id');
|
||||||
|
$table->json('data');
|
||||||
|
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('subscription_key', 64)
|
||||||
|
->unique()
|
||||||
|
->nullable()
|
||||||
|
->after('roles');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('subscription_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('comments', function (Blueprint $table) {
|
||||||
|
$table->bigInteger('deleted_by_moderator_id')
|
||||||
|
->nullable()
|
||||||
|
->after('parent_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('comments', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('deleted_by_moderator_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('mod_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('moderator');
|
||||||
|
$table->text('data');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mod_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('subscription_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('subscription_key', 64)
|
||||||
|
->unique()
|
||||||
|
->nullable()
|
||||||
|
->after('roles');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('video_engagement', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('episode_id')->constrained('episodes')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->unsignedSmallInteger('segment');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// One row per user per episode per segment — no duplicates
|
||||||
|
$table->unique(['episode_id', 'user_id', 'segment']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('video_engagement');
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+541
-527
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -19,12 +19,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||||
"@jellyfin/libass-wasm": "^4.1.1",
|
"@jellyfin/libass-wasm": "^4.1.1",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@yaireo/tagify": "^4.21.2",
|
"@yaireo/tagify": "^4.21.2",
|
||||||
"altcha": "^2.3.0",
|
"altcha": "^3.0.0",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"dashjs": "^5.0.0",
|
"dashjs": "^5.0.0",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"plyr": "^3.7.8",
|
"plyr": "^3.8.4",
|
||||||
"tw-elements": "^1.1.0",
|
"tw-elements": "^1.1.0",
|
||||||
"vidstack": "^1.12.13"
|
"vidstack": "^1.12.13"
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-67
@@ -1,4 +1,5 @@
|
|||||||
@import "@fortawesome/fontawesome-free/css/all.css";
|
@import "@fortawesome/fontawesome-free/css/all.css";
|
||||||
|
@import './player.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -8,29 +9,6 @@
|
|||||||
--breakpoint-xs: 30rem;
|
--breakpoint-xs: 30rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Player */
|
|
||||||
.plyr--full-ui input[type="range"] {
|
|
||||||
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__control--overlaid {
|
|
||||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr--video .plyr__control.plyr__tab-focus,
|
|
||||||
.plyr--video .plyr__control:hover,
|
|
||||||
.plyr--video .plyr__control[aria-expanded="true"] {
|
|
||||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"]::before {
|
|
||||||
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr--full-ui {
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Player Ambient */
|
/* Player Ambient */
|
||||||
.decoy {
|
.decoy {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -50,31 +28,6 @@ input:checked~.dot {
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
#plyr__time_skip {
|
|
||||||
background: #c61e54;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
|
||||||
left: 50%;
|
|
||||||
min-width: 60px;
|
|
||||||
width: min-content;
|
|
||||||
max-width: 100px;
|
|
||||||
max-height: 90px;
|
|
||||||
opacity: 0;
|
|
||||||
display: table-cell;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
padding-top: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transition: 1s;
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
box-shadow: 0px 0px 45px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DL Button Glow */
|
/* DL Button Glow */
|
||||||
.hover\:glow:hover {
|
.hover\:glow:hover {
|
||||||
filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5));
|
filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5));
|
||||||
@@ -126,29 +79,37 @@ input:checked~.dot {
|
|||||||
|
|
||||||
/* Captcha */
|
/* Captcha */
|
||||||
:root {
|
:root {
|
||||||
--altcha-border-width: 1px;
|
color-scheme: light dark;
|
||||||
--altcha-border-radius: 0.375rem;
|
|
||||||
--altcha-color-base: #333;
|
|
||||||
--altcha-color-border: #a0a0a0;
|
|
||||||
--altcha-color-text: #fff;
|
|
||||||
--altcha-color-border-focus: currentColor;
|
|
||||||
--altcha-color-error-text: #f23939;
|
|
||||||
--altcha-color-footer-bg: #141414;
|
|
||||||
--altcha-max-width: 260px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.altcha-footer {
|
/* Stats Page - Shimmer Skeleton Loader */
|
||||||
border-bottom-left-radius: 0.375rem;
|
.shimmer-overlay {
|
||||||
border-bottom-right-radius: 0.375rem;
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.4) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
.dark .shimmer-overlay {
|
||||||
background-color: #ffffff;
|
background: linear-gradient(
|
||||||
border-color: #a0a0a0;
|
90deg,
|
||||||
color: rgb(225,29,72);
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.05) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:checked {
|
@keyframes shimmer {
|
||||||
background-color: rgb(225,29,72);
|
0% {
|
||||||
box-shadow: 0 0 0 0px #fff, 0 0 0 calc(2px + 0px) rgba(246, 59, 118, 0.5), 0 0 #0000;
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,946 @@
|
|||||||
|
/* ============================================================
|
||||||
|
HStream Custom Video Player
|
||||||
|
Dark-theme-first with rose-red accents (#c61e54)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---- Player Wrapper ---- */
|
||||||
|
.hstream-player {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: visible;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
font-family: 'Figtree', sans-serif;
|
||||||
|
direction: ltr;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Video Element ---- */
|
||||||
|
.hstream-player__video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Poster Overlay ---- */
|
||||||
|
.hstream-player__poster {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__poster img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__poster--hidden {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Large Play Overlay ---- */
|
||||||
|
.hstream-player__play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__play-overlay--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__play-btn {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(198, 30, 84, 0.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: transform 0.15s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
box-shadow: 0 0 30px rgba(198, 30, 84, 0.4), 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__play-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background: rgba(225, 29, 72, 0.9);
|
||||||
|
box-shadow: 0 0 40px rgba(198, 30, 84, 0.6), 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__play-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Controls Container ---- */
|
||||||
|
.hstream-player__controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: linear-gradient(to top, rgba(10, 10, 10, 0.92), rgba(10, 10, 10, 0.75) 60%, transparent);
|
||||||
|
padding: 40px 12px 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: opacity 0.15s ease, transform 0.12s ease;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__controls--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress Section (full width row) ---- */
|
||||||
|
.hstream-player__progress-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress {
|
||||||
|
position: relative;
|
||||||
|
height: 6px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
transition: height 0.15s ease, margin-top 0.15s ease;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress:hover,
|
||||||
|
.hstream-player__progress--dragging {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress--dragging .hstream-player__progress-thumb {
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress-buffer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress-fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(to right, #c61e54, #e11d48);
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress-heatmap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 100%;
|
||||||
|
height: 32px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress-heatmap-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #c61e54;
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 0 8px rgba(198, 30, 84, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress:hover .hstream-player__progress-thumb,
|
||||||
|
.hstream-player__progress-wrapper--active .hstream-player__progress-thumb {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Time Tooltip ---- */
|
||||||
|
.hstream-player__time-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(10, 10, 10, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 5;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__time-tooltip--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Thumbnail Preview ---- */
|
||||||
|
.hstream-player__thumbnail-preview {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 16px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 5;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__thumbnail-preview--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__thumbnail-preview-img {
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__thumbnail-preview-time {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.hstream-player__button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease, color 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__button:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__button--active {
|
||||||
|
color: #c61e54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Time Display ---- */
|
||||||
|
.hstream-player__time-display {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__time-separator {
|
||||||
|
opacity: 0.5;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Volume Wrapper ---- */
|
||||||
|
.hstream-player__volume-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider-container {
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
margin: -6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-wrapper:hover .hstream-player__volume-slider-container {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player--mobile .hstream-player__volume-slider-container {
|
||||||
|
width: 80px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider {
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
appearance: none !important;
|
||||||
|
accent-color: #c61e54 !important;
|
||||||
|
width: 56px !important;
|
||||||
|
height: 4px !important;
|
||||||
|
border-radius: 2px !important;
|
||||||
|
outline: none !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider:focus {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 4px !important;
|
||||||
|
border-radius: 2px !important;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#c61e54 0%,
|
||||||
|
#c61e54 var(--volume-pct, 50%),
|
||||||
|
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||||
|
rgba(255, 255, 255, 0.15) 100%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
appearance: none !important;
|
||||||
|
width: 13px !important;
|
||||||
|
height: 13px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background: #c61e54 !important;
|
||||||
|
margin-top: -5px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-moz-range-track {
|
||||||
|
height: 4px !important;
|
||||||
|
border-radius: 2px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-moz-range-progress {
|
||||||
|
height: 4px !important;
|
||||||
|
border-radius: 2px !important;
|
||||||
|
background: #c61e54 !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-moz-range-thumb {
|
||||||
|
width: 13px !important;
|
||||||
|
height: 13px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background: #c61e54 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Spacer ---- */
|
||||||
|
.hstream-player__spacer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Settings Menu ---- */
|
||||||
|
.hstream-player__menu-container {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 80px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 280px;
|
||||||
|
background: rgba(20, 20, 20, 0.70);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 20;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-container--open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-panel--active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-back:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-back i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item--checked {
|
||||||
|
color: #c61e54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item-radio {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item--checked .hstream-player__menu-item-radio {
|
||||||
|
border-color: #c61e54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item--checked .hstream-player__menu-item-radio::after {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c61e54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-value {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item > span:first-of-type {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-item--checked .hstream-player__menu-value {
|
||||||
|
color: #c61e54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #c61e54;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Skip Overlay (Mobile) ---- */
|
||||||
|
.hstream-player__skip-overlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(198, 30, 84, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__skip-overlay--visible {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__skip-overlay i {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Loading Spinner ---- */
|
||||||
|
.hstream-player__loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__loading--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-top-color: #c61e54;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: hstream-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hstream-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Player Switcher Toggle ---- */
|
||||||
|
.player-switcher {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(10, 10, 10, 0.65);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 6px 4px 6px 14px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.aspect-\[16\/9\]:hover .player-switcher {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-switcher--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-switcher__label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-switcher__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: 'Figtree', sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-switcher__btn:hover {
|
||||||
|
background: rgba(198, 30, 84, 0.2);
|
||||||
|
border-color: rgba(198, 30, 84, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-switcher__btn i {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-switcher__btn--active {
|
||||||
|
background: rgba(198, 30, 84, 0.25);
|
||||||
|
border-color: rgba(198, 30, 84, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-switcher__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c61e54;
|
||||||
|
box-shadow: 0 0 6px rgba(198, 30, 84, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Fullscreen (remove rounded borders) ---- */
|
||||||
|
.hstream-player:fullscreen,
|
||||||
|
.hstream-player:-webkit-full-screen {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player:fullscreen .hstream-player__video,
|
||||||
|
.hstream-player:-webkit-full-screen .hstream-player__video,
|
||||||
|
.hstream-player:fullscreen .hstream-player__poster,
|
||||||
|
.hstream-player:-webkit-full-screen .hstream-player__poster,
|
||||||
|
.hstream-player:fullscreen .hstream-player__controls,
|
||||||
|
.hstream-player:-webkit-full-screen .hstream-player__controls {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Idle State (hide controls & cursor) ---- */
|
||||||
|
.hstream-player--idle {
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player--idle .hstream-player__controls {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mobile Responsive ---- */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.hstream-player {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__controls {
|
||||||
|
padding: 36px 4px 8px 4px;
|
||||||
|
gap: 2px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__time-display {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__play-btn {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__menu-container {
|
||||||
|
right: 8px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress-wrapper {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress {
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__progress-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__skip-overlay {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__skip-overlay i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__thumbnail-preview {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider-container {
|
||||||
|
width: 60px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider {
|
||||||
|
accent-color: #c61e54 !important;
|
||||||
|
width: 56px !important;
|
||||||
|
height: 6px !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 6px !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#c61e54 0%,
|
||||||
|
#c61e54 var(--volume-pct, 50%),
|
||||||
|
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||||
|
rgba(255, 255, 255, 0.15) 100%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||||
|
width: 18px !important;
|
||||||
|
height: 18px !important;
|
||||||
|
margin-top: -6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-moz-range-track {
|
||||||
|
height: 6px !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-moz-range-progress {
|
||||||
|
height: 6px !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
background: #c61e54 !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__volume-slider::-moz-range-thumb {
|
||||||
|
width: 18px !important;
|
||||||
|
height: 18px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.hstream-player__time-display {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hstream-player__button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Plyr Player Overrides — pink accent, rounded corners, skip overlay
|
||||||
|
============================================================ */
|
||||||
|
.plyr--full-ui input[type='range'] {
|
||||||
|
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__control--overlaid {
|
||||||
|
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr--video .plyr__control.plyr__tab-focus,
|
||||||
|
.plyr--video .plyr__control:hover,
|
||||||
|
.plyr--video .plyr__control[aria-expanded='true'] {
|
||||||
|
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__menu__container .plyr__control[role='menuitemradio'][aria-checked='true']::before {
|
||||||
|
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr--full-ui {
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plyr Engagement Heatmap, overlays the progress bar track */
|
||||||
|
.plyr__progress__heatmap {
|
||||||
|
position: absolute;
|
||||||
|
left: -6px;
|
||||||
|
right: -6px;
|
||||||
|
bottom: 40%;
|
||||||
|
height: 48px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__progress__heatmap-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plyr Mobile Double-tap Skip Overlay */
|
||||||
|
#plyr__time_skip {
|
||||||
|
background: #c61e54;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
left: 50%;
|
||||||
|
min-width: 60px;
|
||||||
|
width: min-content;
|
||||||
|
max-width: 100px;
|
||||||
|
max-height: 90px;
|
||||||
|
opacity: 0;
|
||||||
|
display: table-cell;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transition: 1s;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0px 0px 45px #000000;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
|
|
||||||
// Captcha
|
// Captcha
|
||||||
import 'altcha';
|
import 'altcha';
|
||||||
|
import "altcha/themes/cupcake.css";
|
||||||
|
|
||||||
// import Alpine from 'alpinejs';
|
// import Alpine from 'alpinejs';
|
||||||
|
|
||||||
@@ -22,3 +23,16 @@ import 'altcha';
|
|||||||
// Alpine.start();
|
// Alpine.start();
|
||||||
|
|
||||||
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple });
|
initTE({ Collapse, Carousel, Clipboard, Modal, Tab, Lightbox, Tooltip, Ripple });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkey Support
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
startAuthentication,
|
||||||
|
startRegistration,
|
||||||
|
} from '@simplewebauthn/browser'
|
||||||
|
|
||||||
|
window.browserSupportsWebAuthn = browserSupportsWebAuthn;
|
||||||
|
window.startAuthentication = startAuthentication;
|
||||||
|
window.startRegistration = startRegistration;
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
if (document.getElementById("playlist-add")) {
|
if (document.getElementById("playlist-add")) {
|
||||||
function createPlaylist() {
|
function addToPlaylist() {
|
||||||
console.log('Adding to Playlist: ' + document.querySelector("#playlist").value)
|
|
||||||
|
|
||||||
window.axios.post('/hentai/add-to-playlist', {
|
window.axios.post('/hentai/add-to-playlist', {
|
||||||
playlist: document.getElementById('playlist').value,
|
playlist: document.getElementById('playlist').value,
|
||||||
episode_id: document.getElementById('e_id').value
|
episode_id: document.getElementById('e_id').value
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
if (response.status == 200) {
|
if (response.status == 200) {
|
||||||
document.getElementById("playlist-cancel").click();
|
|
||||||
|
|
||||||
if (response.data.message == 'already-added') {
|
if (response.data.message == 'already-added') {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: "Already added!",
|
title: "Already added!",
|
||||||
@@ -18,6 +14,8 @@ if (document.getElementById("playlist-add")) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.data.message == 'success') {
|
if (response.data.message == 'success') {
|
||||||
|
document.getElementById("playlist-cancel").click();
|
||||||
|
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
text: "Added episode to the playlist!",
|
text: "Added episode to the playlist!",
|
||||||
@@ -27,31 +25,64 @@ if (document.getElementById("playlist-add")) {
|
|||||||
}
|
}
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
Swal.fire({
|
||||||
|
title: "Error!",
|
||||||
|
text: "Could not add episode to playlist.",
|
||||||
|
icon: "error"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#playlist-add").addEventListener("click", createPlaylist);
|
document.querySelector("#playlist-add").addEventListener("click", addToPlaylist);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.getElementById("playlist-create-and-add")) {
|
if (document.getElementById("playlist-create-and-add")) {
|
||||||
function createAndAddPlaylist() {
|
function createAndAddPlaylist() {
|
||||||
|
const nameField = document.getElementById('playlist-name');
|
||||||
|
const visibilityField = document.getElementById('playlist-visibility');
|
||||||
|
|
||||||
|
if (!nameField.value.trim()) {
|
||||||
|
Swal.fire({
|
||||||
|
title: "Name required!",
|
||||||
|
text: "Please enter a playlist name.",
|
||||||
|
icon: "warning"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.axios.post('/hentai/create-playlist', {
|
window.axios.post('/hentai/create-playlist', {
|
||||||
name: document.getElementById('name').value,
|
name: nameField.value,
|
||||||
visiblity: document.getElementById('visiblity').value
|
visiblity: visibilityField.value
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
window.axios.post('/hentai/add-to-playlist', {
|
window.axios.post('/hentai/add-to-playlist', {
|
||||||
playlist: response.data.playlist_id,
|
playlist: response.data.playlist_id,
|
||||||
episode_id: document.getElementById('e_id').value
|
episode_id: document.getElementById('e_id').value
|
||||||
}).then(function (response) {
|
}).then(function (addResponse) {
|
||||||
if (response.status == 200) {
|
if (addResponse.status == 200) {
|
||||||
document.getElementById("playlist-cancel").click();
|
const cancelBtn = document.getElementById("playlist-cancel");
|
||||||
|
if (cancelBtn) cancelBtn.click();
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
title: "Success!",
|
||||||
|
text: "Playlist created and episode added!",
|
||||||
|
icon: "success"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
Swal.fire({
|
||||||
|
title: "Error!",
|
||||||
|
text: "Could not add episode to the new playlist.",
|
||||||
|
icon: "error"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
Swal.fire({
|
||||||
|
title: "Error!",
|
||||||
|
text: "Could not create playlist.",
|
||||||
|
icon: "error"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Engagement heatmap tracking
|
||||||
|
// Samples the user's current time while playing and sends batched segment data to the server.
|
||||||
|
// Only tracks segment >= 1 (excludes 0-10s).
|
||||||
|
// Only calls the endpoint when the user is logged in.
|
||||||
|
|
||||||
|
let engagementInterval;
|
||||||
|
let engagementSegments = new Set();
|
||||||
|
let engagementReportInterval;
|
||||||
|
const SEGMENT_DURATION = 10; // seconds per segment
|
||||||
|
const SAMPLE_INTERVAL = 5000; // sample every 5s
|
||||||
|
const REPORT_INTERVAL = 15000; // send batch every 15s
|
||||||
|
|
||||||
|
function isAuthenticated() {
|
||||||
|
const el = document.getElementById('auth_check');
|
||||||
|
return el && el.value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEngagement(episodeId, segments) {
|
||||||
|
if (!isAuthenticated()) return;
|
||||||
|
|
||||||
|
window.axios.post('/player/engagement', {
|
||||||
|
episode_id: episodeId,
|
||||||
|
segments: segments,
|
||||||
|
}).catch(() => {
|
||||||
|
// Fire-and-forget: silently ignore network errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startEngagementTracking(episodeId) {
|
||||||
|
engagementSegments.clear();
|
||||||
|
|
||||||
|
// Sample current time while playing
|
||||||
|
engagementInterval = setInterval(() => {
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
if (!video || video.paused) return;
|
||||||
|
|
||||||
|
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
|
||||||
|
// Skip segment 0 (0-10s) — no need to track the very start
|
||||||
|
if (segment >= 1) {
|
||||||
|
engagementSegments.add(segment);
|
||||||
|
}
|
||||||
|
}, SAMPLE_INTERVAL);
|
||||||
|
|
||||||
|
// Batch report to server
|
||||||
|
engagementReportInterval = setInterval(() => {
|
||||||
|
if (engagementSegments.size === 0) return;
|
||||||
|
|
||||||
|
const segments = Array.from(engagementSegments);
|
||||||
|
engagementSegments.clear();
|
||||||
|
|
||||||
|
sendEngagement(episodeId, segments);
|
||||||
|
}, REPORT_INTERVAL);
|
||||||
|
|
||||||
|
// Flush remaining segments & cleanup on page unload
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(engagementInterval);
|
||||||
|
clearInterval(engagementReportInterval);
|
||||||
|
|
||||||
|
if (engagementSegments.size > 0) {
|
||||||
|
const segments = Array.from(engagementSegments);
|
||||||
|
engagementSegments.clear();
|
||||||
|
sendEngagement(episodeId, segments);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopEngagementTracking() {
|
||||||
|
if (engagementInterval) clearInterval(engagementInterval);
|
||||||
|
if (engagementReportInterval) clearInterval(engagementReportInterval);
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// Engagement heatmap display
|
||||||
|
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
|
||||||
|
|
||||||
|
let heatmapContainer = null;
|
||||||
|
let heatmapCanvas = null;
|
||||||
|
let heatmapResizeObserver = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch engagement data from the server and render the heatmap.
|
||||||
|
* @param {string} episodeId - The episode ID.
|
||||||
|
* @param {number} duration - Video duration in seconds.
|
||||||
|
*/
|
||||||
|
export async function renderHeatmap(episodeId, duration) {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get(`/player/engagement/${episodeId}`);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHeatmapCurve(data, duration);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load engagement data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a smooth area chart on a canvas element above the progress bar.
|
||||||
|
* @param {Object} data - Key-value map of segment -> watch_count.
|
||||||
|
* @param {number} duration - Video duration in seconds.
|
||||||
|
*/
|
||||||
|
function drawHeatmapCurve(data, duration) {
|
||||||
|
const SEGMENT_DURATION = 10;
|
||||||
|
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
|
||||||
|
|
||||||
|
// Build raw counts array, filling gaps with 0
|
||||||
|
const rawCounts = [];
|
||||||
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
|
rawCounts.push(data[i] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply 3-point moving average to smooth individual spikes
|
||||||
|
const counts = smoothData(rawCounts);
|
||||||
|
|
||||||
|
const maxCount = Math.max(...counts, 1);
|
||||||
|
|
||||||
|
// Remove existing heatmap if present
|
||||||
|
if (heatmapContainer) {
|
||||||
|
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
|
||||||
|
heatmapContainer.remove();
|
||||||
|
heatmapCanvas = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the progress bar wrapper
|
||||||
|
const progressBar = document.querySelector('.plyr__progress');
|
||||||
|
if (!progressBar) return;
|
||||||
|
|
||||||
|
// Create container
|
||||||
|
heatmapContainer = document.createElement('div');
|
||||||
|
heatmapContainer.className = 'plyr__progress__heatmap';
|
||||||
|
heatmapContainer.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
// Create canvas
|
||||||
|
heatmapCanvas = document.createElement('canvas');
|
||||||
|
heatmapCanvas.className = 'plyr__progress__heatmap-canvas';
|
||||||
|
heatmapContainer.appendChild(heatmapCanvas);
|
||||||
|
|
||||||
|
// Insert as first child of the progress bar so it sits behind the scrubber
|
||||||
|
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
|
||||||
|
|
||||||
|
// Defer drawing to get container dimensions
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redraw on resize
|
||||||
|
heatmapResizeObserver = new ResizeObserver(() => {
|
||||||
|
drawCurve(heatmapCanvas, counts, maxCount);
|
||||||
|
});
|
||||||
|
heatmapResizeObserver.observe(heatmapContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a 3-point moving average to smooth out jaggedness.
|
||||||
|
* Preserves the first and last points.
|
||||||
|
*/
|
||||||
|
function smoothData(data) {
|
||||||
|
if (data.length <= 2) return [...data];
|
||||||
|
|
||||||
|
const smoothed = [data[0]]; // preserve first
|
||||||
|
|
||||||
|
for (let i = 1; i < data.length - 1; i++) {
|
||||||
|
smoothed.push((data[i - 1] + data[i] + data[i + 1]) / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
smoothed.push(data[data.length - 1]); // preserve last
|
||||||
|
return smoothed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render vertical bar heatmap directly on the progress bar track.
|
||||||
|
* Each bar represents a time segment; taller bars = more engagement.
|
||||||
|
*/
|
||||||
|
function drawCurve(canvas, counts, maxCount) {
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const rect = parent.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
|
||||||
|
canvas.width = Math.round(w * dpr);
|
||||||
|
canvas.height = Math.round(h * dpr);
|
||||||
|
canvas.style.width = w + 'px';
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
if (counts.length === 0 || maxCount === 0) return;
|
||||||
|
|
||||||
|
// Padding: leave 1px on each side so the curve doesn't clip at edges
|
||||||
|
const paddingX = 1;
|
||||||
|
const paddingY = 1;
|
||||||
|
const drawW = w - paddingX * 2;
|
||||||
|
const drawH = h - paddingY * 2;
|
||||||
|
const baseline = paddingY + drawH / 2; // curve oscillates around the center
|
||||||
|
const amplitude = (drawH / 2) * 0.8; // 80% of half-height to keep inside bounds
|
||||||
|
const n = counts.length;
|
||||||
|
|
||||||
|
// Build data points: x = horizontal position, y = vertical offset from center
|
||||||
|
const pts = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = paddingX + (i / (n - 1 || 1)) * drawW;
|
||||||
|
const ratio = counts[i] / maxCount;
|
||||||
|
// ratio 0 = bottom of amplitude range, ratio 1 = top of amplitude range
|
||||||
|
const y = baseline - (ratio - 0.5) * amplitude * 2;
|
||||||
|
pts.push({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
|
||||||
|
// Build the smooth path using quadratic bezier curves through midpoints
|
||||||
|
const path = [{ x: pts[0].x, y: pts[0].y }];
|
||||||
|
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const midX = (pts[i].x + pts[i + 1].x) / 2;
|
||||||
|
const midY = (pts[i].y + pts[i + 1].y) / 2;
|
||||||
|
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
|
||||||
|
}
|
||||||
|
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
|
||||||
|
|
||||||
|
// --- Draw a subtle glow behind the line ---
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(path[0].x, path[0].y);
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const prev = path[i - 1];
|
||||||
|
const curr = path[i];
|
||||||
|
if (curr.cp) {
|
||||||
|
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(curr.x, curr.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
ctx.lineWidth = 3.0;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// --- Draw the main waveform line ---
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(path[0].x, path[0].y);
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const prev = path[i - 1];
|
||||||
|
const curr = path[i];
|
||||||
|
if (curr.cp) {
|
||||||
|
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(curr.x, curr.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the heatmap from the DOM.
|
||||||
|
*/
|
||||||
|
export function removeHeatmap() {
|
||||||
|
if (heatmapResizeObserver) {
|
||||||
|
heatmapResizeObserver.disconnect();
|
||||||
|
heatmapResizeObserver = null;
|
||||||
|
}
|
||||||
|
if (heatmapContainer) {
|
||||||
|
heatmapContainer.remove();
|
||||||
|
heatmapContainer = null;
|
||||||
|
heatmapCanvas = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
// Plyr Fallback Player
|
||||||
|
import Plyr from 'plyr';
|
||||||
|
import 'plyr/dist/plyr.css';
|
||||||
|
|
||||||
|
import * as dashjs from 'dashjs';
|
||||||
|
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
||||||
|
|
||||||
|
import { initMobileWidescreen } from './player-mobile';
|
||||||
|
import { mobileDoubleClick } from './player-mobile';
|
||||||
|
import { playNextPlaylistVideo } from './playlist';
|
||||||
|
import { addVideoTracks } from './player-data';
|
||||||
|
import { addSubtitleTracks } from './player-data';
|
||||||
|
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
||||||
|
import { isIOS } from './detect-ios';
|
||||||
|
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
|
||||||
|
import { renderHeatmap } from './player/player-heatmap';
|
||||||
|
|
||||||
|
var player = null;
|
||||||
|
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
||||||
|
var dashSupported = dashjs.supportsMediaSource();
|
||||||
|
var apiResponse = {};
|
||||||
|
var volume = 0.5;
|
||||||
|
var muted = false;
|
||||||
|
var captions = true;
|
||||||
|
var lastTime = 0.0;
|
||||||
|
var streamServer = '';
|
||||||
|
var streamServers = [];
|
||||||
|
var streamServerIndex = 0;
|
||||||
|
var streamServerCount = 0;
|
||||||
|
var ambientMode = true;
|
||||||
|
var serverFallback = false;
|
||||||
|
var saveInterval;
|
||||||
|
var watchTracked = false;
|
||||||
|
var subtitleInstance = null;
|
||||||
|
|
||||||
|
function trackWatchTime() {
|
||||||
|
if (watchTracked) return;
|
||||||
|
var video = document.getElementsByTagName('video')[0];
|
||||||
|
if (video && video.currentTime >= 10) {
|
||||||
|
watchTracked = true;
|
||||||
|
var episodeId = document.getElementById('e_id').value;
|
||||||
|
window.axios.post('/watched/track', {
|
||||||
|
episode_id: episodeId
|
||||||
|
}).then(function () {
|
||||||
|
console.log('Watch tracked for episode ' + episodeId);
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.error('Failed to track watch: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var controls = [
|
||||||
|
'play-large',
|
||||||
|
'play',
|
||||||
|
'progress',
|
||||||
|
'current-time',
|
||||||
|
'duration',
|
||||||
|
'mute',
|
||||||
|
'volume',
|
||||||
|
'captions',
|
||||||
|
'settings',
|
||||||
|
'fullscreen',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (localStorage.hstreamVolume) {
|
||||||
|
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
|
||||||
|
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.hstreamCaptions) {
|
||||||
|
captions = (localStorage.getItem('hstreamCaptions') == 'true');
|
||||||
|
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.hstreamMuted) {
|
||||||
|
muted = (localStorage.getItem('hstreamMuted') == 'true');
|
||||||
|
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.hstreamServerFallback) {
|
||||||
|
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
|
||||||
|
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!av1Supported) {
|
||||||
|
document.getElementById('av1-unsupported').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDash(data, player) {
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
|
||||||
|
data.forEach(function (el) {
|
||||||
|
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
|
||||||
|
const dash = dashjs.MediaPlayer().create();
|
||||||
|
dash.initialize(video, el.src, true);
|
||||||
|
window.player = player;
|
||||||
|
window.dash = dash;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCanvasDimension(canvas, video) {
|
||||||
|
canvas.height = video.offsetHeight;
|
||||||
|
canvas.width = video.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintStaticVideo(ctx, video) {
|
||||||
|
if (localStorage.theme == 'light') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ambientMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAmbientMode() {
|
||||||
|
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
|
||||||
|
if (ambientMode) {
|
||||||
|
ambientMode = false;
|
||||||
|
localStorage.ambientMode = 'false';
|
||||||
|
setCanvasDimension(canvas, video);
|
||||||
|
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
|
||||||
|
} else {
|
||||||
|
ambientMode = true;
|
||||||
|
localStorage.ambientMode = 'true';
|
||||||
|
setCanvasDimension(canvas, video);
|
||||||
|
paintStaticVideo(ctx, video);
|
||||||
|
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAsiaServer() {
|
||||||
|
if (serverFallback) {
|
||||||
|
serverFallback = false;
|
||||||
|
localStorage.hstreamServerFallback = 'false';
|
||||||
|
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
|
||||||
|
streamServers = apiResponse.stream_domains;
|
||||||
|
} else {
|
||||||
|
serverFallback = true;
|
||||||
|
localStorage.hstreamServerFallback = 'true';
|
||||||
|
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
|
||||||
|
streamServers = apiResponse.asia_stream_domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamServerCount = streamServers.length;
|
||||||
|
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||||
|
streamServer = streamServers[streamServerIndex];
|
||||||
|
console.log('Selected Server: ' + streamServer);
|
||||||
|
|
||||||
|
if (player) {
|
||||||
|
clearInterval(saveInterval);
|
||||||
|
stopEngagementTracking();
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
initPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSubtitles(lang) {
|
||||||
|
if (isIOS()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||||
|
subtitleInstance.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||||
|
|
||||||
|
if (lang != 'en') {
|
||||||
|
newSubUrl += 'autotrans/' + lang + '.ass';
|
||||||
|
} else {
|
||||||
|
newSubUrl += 'eng.ass';
|
||||||
|
}
|
||||||
|
|
||||||
|
let subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||||
|
if (lang == 'hi') {
|
||||||
|
subFont = '/fonts/Hind-SemiBold.ttf';
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
video: document.getElementsByTagName('video')[0],
|
||||||
|
subUrl: newSubUrl,
|
||||||
|
workerUrl: '/build/js/subtitles-octopus-worker.js',
|
||||||
|
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
|
||||||
|
fonts: [subFont],
|
||||||
|
renderMode: 'wasm-blend',
|
||||||
|
};
|
||||||
|
|
||||||
|
subtitleInstance = new SubtitlesOctopus(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPlayer() {
|
||||||
|
player = new Plyr('#player', {
|
||||||
|
controls,
|
||||||
|
quality: {
|
||||||
|
default: 720,
|
||||||
|
options: [2161, 2160, 1081, 1080, 720]
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
qualityLabel: {
|
||||||
|
2161: '2160p48',
|
||||||
|
2160: '2160p',
|
||||||
|
1081: '1080p48',
|
||||||
|
1080: '1080p',
|
||||||
|
720: '720p'
|
||||||
|
},
|
||||||
|
qualityBadge: {
|
||||||
|
2161: 'UHD@48',
|
||||||
|
1081: 'FHD@48',
|
||||||
|
1080: 'FHD',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fullscreen: { enabled: true, fallback: true, iosNative: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||||
|
|
||||||
|
player.source = {
|
||||||
|
type: 'video',
|
||||||
|
title: apiResponse.title,
|
||||||
|
poster: apiResponse.poster,
|
||||||
|
previewThumbnails: {
|
||||||
|
enabled: true,
|
||||||
|
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
|
||||||
|
},
|
||||||
|
sources: data,
|
||||||
|
tracks: addSubtitleTracks(streamServer, apiResponse)
|
||||||
|
};
|
||||||
|
|
||||||
|
player.volume = volume;
|
||||||
|
player.muted = muted;
|
||||||
|
player.captions.language = 'en';
|
||||||
|
player.captions.active = captions;
|
||||||
|
|
||||||
|
if (dashSupported && !apiResponse.legacy) {
|
||||||
|
player.on('qualitychange', () => {
|
||||||
|
initDash(data, player);
|
||||||
|
});
|
||||||
|
|
||||||
|
initDash(data, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
|
||||||
|
setCanvasDimension(canvas, video);
|
||||||
|
paintStaticVideo(ctx, video);
|
||||||
|
|
||||||
|
var allItems = document.getElementsByClassName('plyr__control--forward');
|
||||||
|
var lastItem = allItems[allItems.length - 1];
|
||||||
|
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
||||||
|
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
||||||
|
|
||||||
|
if (localStorage.ambientMode == 'false') {
|
||||||
|
toggleAmbientMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastItem = allItems[allItems.length - 1];
|
||||||
|
let value = 'Off';
|
||||||
|
if (serverFallback) { value = 'On'; }
|
||||||
|
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
||||||
|
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
||||||
|
|
||||||
|
var clickedPlay = false;
|
||||||
|
|
||||||
|
player.on('play', () => {
|
||||||
|
if (!clickedPlay) {
|
||||||
|
player.stop();
|
||||||
|
console.log('Stopped video, because user didn\'t click play.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeId = document.getElementById('e_id').value;
|
||||||
|
startEngagementTracking(episodeId);
|
||||||
|
|
||||||
|
setCanvasDimension(canvas, video);
|
||||||
|
console.log('Play => Function Loop()');
|
||||||
|
var $this = video;
|
||||||
|
(function loop() {
|
||||||
|
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||||
|
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||||
|
setTimeout(loop, 24000 / 1001);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('seeked', () => {
|
||||||
|
paintStaticVideo(ctx, video);
|
||||||
|
if (player.currentTime > 0) {
|
||||||
|
lastTime = player.currentTime;
|
||||||
|
}
|
||||||
|
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
setCanvasDimension(canvas, video);
|
||||||
|
if (player.paused) {
|
||||||
|
paintStaticVideo(ctx, video);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('captionsenabled', () => {
|
||||||
|
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
|
||||||
|
localStorage.setItem('hstreamCaptions', 'true');
|
||||||
|
console.log('Set Captions Status to Local Storage: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('captionsdisabled', () => {
|
||||||
|
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
|
||||||
|
localStorage.setItem('hstreamCaptions', 'false');
|
||||||
|
console.log('Set Captions Status to Local Storage: false');
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('volumechange', () => {
|
||||||
|
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
|
||||||
|
localStorage.setItem('hstreamVolume', player.volume.toString());
|
||||||
|
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
|
||||||
|
localStorage.setItem('hstreamMuted', player.muted.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('ended', () => {
|
||||||
|
playNextPlaylistVideo();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('timeupdate', () => {
|
||||||
|
trackWatchTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('languagechange', (event) => {
|
||||||
|
let lang = event.detail.plyr.captions.language;
|
||||||
|
|
||||||
|
console.log('Subtitle Event ' + lang);
|
||||||
|
initSubtitles(lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
function playerPlayTemp() {
|
||||||
|
clickedPlay = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
|
||||||
|
play.addEventListener('click', playerPlayTemp)
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
|
||||||
|
|
||||||
|
initMobileWidescreen();
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const time = parseInt(params.get('t'));
|
||||||
|
if (!isNaN(time)) {
|
||||||
|
player.currentTime = time;
|
||||||
|
console.log('Skipping to ' + time);
|
||||||
|
}
|
||||||
|
if (lastTime > 0) {
|
||||||
|
player.currentTime = lastTime;
|
||||||
|
console.log('Skipping to ' + lastTime);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
player.on('ready', () => {
|
||||||
|
mobileDoubleClick(player);
|
||||||
|
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
const episodeId = document.getElementById('e_id').value;
|
||||||
|
if (video && video.duration) {
|
||||||
|
renderHeatmap(episodeId, video.duration);
|
||||||
|
} else if (video) {
|
||||||
|
video.addEventListener('loadedmetadata', function onMeta() {
|
||||||
|
video.removeEventListener('loadedmetadata', onMeta);
|
||||||
|
renderHeatmap(episodeId, video.duration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var settingElements = document.getElementsByClassName('plyr__control--forward');
|
||||||
|
if (settingElements.length == 3) {
|
||||||
|
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
|
||||||
|
|
||||||
|
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
|
||||||
|
if (settingNodes.length == 4) {
|
||||||
|
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
|
||||||
|
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
|
||||||
|
let serverSelects = document.getElementsByClassName('change_server');
|
||||||
|
for (let i = 0; i < serverSelects.length; i++) {
|
||||||
|
serverSelects[i].addEventListener('click', function () {
|
||||||
|
streamServerIndex = Number(this.value);
|
||||||
|
streamServer = streamServers[streamServerIndex];
|
||||||
|
console.log('Selected Server: ' + streamServer);
|
||||||
|
|
||||||
|
if (player) {
|
||||||
|
clearInterval(saveInterval);
|
||||||
|
stopEngagementTracking();
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
initPlayer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveInterval = setInterval(function () {
|
||||||
|
lastTime = player.currentTime;
|
||||||
|
console.log('Last Player Position: ' + lastTime);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initPlyrPlayer(episodeId) {
|
||||||
|
window.axios.post('/player/api', {
|
||||||
|
episode_id: episodeId
|
||||||
|
}).then(function (response) {
|
||||||
|
if (response.status == 200) {
|
||||||
|
apiResponse = response.data;
|
||||||
|
streamServers = apiResponse.stream_domains;
|
||||||
|
|
||||||
|
if (serverFallback) {
|
||||||
|
streamServers = apiResponse.asia_stream_domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamServerCount = streamServers.length;
|
||||||
|
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||||
|
streamServer = streamServers[streamServerIndex];
|
||||||
|
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||||
|
|
||||||
|
initPlayer();
|
||||||
|
}
|
||||||
|
}).catch(function (error) {
|
||||||
|
var alert = document.getElementById('player-alert');
|
||||||
|
if (alert) {
|
||||||
|
alert.innerText = 'The player encountered a problem: ' + error;
|
||||||
|
alert.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
+197
-345
@@ -1,28 +1,17 @@
|
|||||||
// Plyr Player
|
// HStream Custom Video Player
|
||||||
import Plyr from 'plyr';
|
|
||||||
import 'plyr/dist/plyr.css';
|
|
||||||
|
|
||||||
// Vidstack Player
|
|
||||||
import 'vidstack/player/styles/default/theme.css';
|
import 'vidstack/player/styles/default/theme.css';
|
||||||
import 'vidstack/player/styles/default/layouts/video.css';
|
import 'vidstack/player/styles/default/layouts/video.css';
|
||||||
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
|
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
|
||||||
|
|
||||||
// Dash Support
|
|
||||||
import * as dashjs from 'dashjs';
|
import * as dashjs from 'dashjs';
|
||||||
|
|
||||||
// Subtitle Support
|
|
||||||
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
||||||
|
import { HStreamPlayer } from './player/player-core';
|
||||||
// Custom JS
|
import { initMobileWidescreen, initMobileDoubleTap, isMobile } from './player/player-mobile';
|
||||||
import { initMobileWidescreen } from './player-mobile';
|
|
||||||
import { mobileDoubleClick } from './player-mobile'
|
|
||||||
import { playNextPlaylistVideo } from './playlist';
|
import { playNextPlaylistVideo } from './playlist';
|
||||||
import { addVideoTracks } from './player-data';
|
import { addVideoTracks, addSubtitleTracks } from './player/player-data';
|
||||||
import { addSubtitleTracks } from './player-data';
|
|
||||||
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
|
||||||
import { isIOS } from './detect-ios';
|
import { isIOS } from './detect-ios';
|
||||||
|
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
|
||||||
|
import { renderHeatmap } from './player/player-heatmap';
|
||||||
|
|
||||||
// Variables
|
|
||||||
var player = null;
|
var player = null;
|
||||||
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
||||||
var dashSupported = dashjs.supportsMediaSource();
|
var dashSupported = dashjs.supportsMediaSource();
|
||||||
@@ -33,124 +22,48 @@ var captions = true;
|
|||||||
var lastTime = 0.0;
|
var lastTime = 0.0;
|
||||||
var streamServer = '';
|
var streamServer = '';
|
||||||
var streamServers = [];
|
var streamServers = [];
|
||||||
|
var fallbackServers = [];
|
||||||
var streamServerIndex = 0;
|
var streamServerIndex = 0;
|
||||||
var streamServerCount = 0;
|
var streamServerCount = 0;
|
||||||
var ambientMode = true;
|
var ambientMode = true;
|
||||||
var serverFallback = false;
|
|
||||||
var saveInterval;
|
var saveInterval;
|
||||||
|
var watchTracked = false;
|
||||||
var subtitleInstance = null;
|
var subtitleInstance = null;
|
||||||
|
|
||||||
var controls = [
|
function trackWatchTime() {
|
||||||
'play-large', // The large play button in the center
|
if (watchTracked) return;
|
||||||
'play', // Play/pause playback
|
var videoEl = document.getElementsByTagName('video')[0];
|
||||||
'progress', // The progress bar and scrubber for playback and buffering
|
if (videoEl && videoEl.currentTime >= 10) {
|
||||||
'current-time', // The current time of playback
|
watchTracked = true;
|
||||||
'duration', // The full duration of the media
|
var episodeId = document.getElementById('e_id').value;
|
||||||
'mute', // Toggle mute
|
window.axios.post('/watched/track', {
|
||||||
'volume', // Volume control
|
episode_id: episodeId
|
||||||
'captions', // Toggle captions
|
}).then(function () {
|
||||||
'settings', // Settings menu
|
console.log('Watch tracked for episode ' + episodeId);
|
||||||
'fullscreen', // Toggle fullscreen
|
}).catch(function (error) {
|
||||||
];
|
console.error('Failed to track watch: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load Volume from LocalStorage
|
|
||||||
if (localStorage.hstreamVolume) {
|
if (localStorage.hstreamVolume) {
|
||||||
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
|
volume = parseFloat(localStorage.getItem('hstreamVolume'));
|
||||||
|
if (!isNaN(volume)) volume = Math.max(0, Math.min(1, volume));
|
||||||
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Captions from LocalStorage
|
|
||||||
if (localStorage.hstreamCaptions) {
|
if (localStorage.hstreamCaptions) {
|
||||||
captions = (localStorage.getItem('hstreamCaptions') == 'true');
|
captions = (localStorage.getItem('hstreamCaptions') === 'true');
|
||||||
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Muted from LocalStorage
|
if (localStorage.hstreamMuted) {
|
||||||
if (localStorage.hstreamCaptions) {
|
muted = (localStorage.getItem('hstreamMuted') === 'true');
|
||||||
muted = (localStorage.getItem('hstreamMuted') == 'true');
|
|
||||||
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asia Server Fallback
|
|
||||||
if (localStorage.hstreamServerFallback) {
|
|
||||||
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
|
|
||||||
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alert User when AV1 is not supported
|
|
||||||
if (!av1Supported) {
|
if (!av1Supported) {
|
||||||
document.getElementById("av1-unsupported").classList.remove("hidden");
|
document.getElementById('av1-unsupported').classList.remove('hidden');
|
||||||
}
|
|
||||||
|
|
||||||
function initDash(data, player) {
|
|
||||||
const video = document.querySelector('video');
|
|
||||||
|
|
||||||
data.forEach(function (el) {
|
|
||||||
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
|
|
||||||
const dash = dashjs.MediaPlayer().create();
|
|
||||||
dash.initialize(video, el.src, true);
|
|
||||||
// Expose player and dash so they can be used from the console
|
|
||||||
window.player = player;
|
|
||||||
window.dash = dash;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCanvasDimension(canvas, video) {
|
|
||||||
canvas.height = video.offsetHeight;
|
|
||||||
canvas.width = video.offsetWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintStaticVideo(ctx, video) {
|
|
||||||
if (localStorage.theme == 'light') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!ambientMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAmbientMode() {
|
|
||||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
|
||||||
if (ambientMode) {
|
|
||||||
ambientMode = false;
|
|
||||||
localStorage.ambientMode = 'false';
|
|
||||||
setCanvasDimension(canvas, video);
|
|
||||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
|
|
||||||
} else {
|
|
||||||
ambientMode = true;
|
|
||||||
localStorage.ambientMode = 'true';
|
|
||||||
setCanvasDimension(canvas, video);
|
|
||||||
paintStaticVideo(ctx, video);
|
|
||||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAsiaServer() {
|
|
||||||
if (serverFallback) {
|
|
||||||
serverFallback = false;
|
|
||||||
localStorage.hstreamServerFallback = 'false';
|
|
||||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
|
|
||||||
streamServers = apiResponse.stream_domains;
|
|
||||||
} else {
|
|
||||||
serverFallback = true;
|
|
||||||
localStorage.hstreamServerFallback = 'true';
|
|
||||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
|
|
||||||
streamServers = apiResponse.asia_stream_domains;
|
|
||||||
}
|
|
||||||
|
|
||||||
streamServerCount = streamServers.length;
|
|
||||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
|
||||||
streamServer = streamServers[streamServerIndex];
|
|
||||||
console.log('Selected Server: ' + streamServer);
|
|
||||||
|
|
||||||
if (player) {
|
|
||||||
clearInterval(saveInterval);
|
|
||||||
player.destroy();
|
|
||||||
}
|
|
||||||
initPlayer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSubtitles(lang) {
|
function initSubtitles(lang) {
|
||||||
@@ -158,32 +71,28 @@ function initSubtitles(lang) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispose old instance
|
if (subtitleInstance !== null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||||
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
|
|
||||||
subtitleInstance.dispose();
|
subtitleInstance.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
var newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||||
|
|
||||||
if (lang != 'en') {
|
if (lang !== 'en') {
|
||||||
newSubUrl += 'autotrans/' + lang + '.ass';
|
newSubUrl += 'autotrans/' + lang + '.ass';
|
||||||
}
|
} else {
|
||||||
else {
|
newSubUrl += 'eng.ass';
|
||||||
newSubUrl += 'eng.ass'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let subFont = '/fonts/Figtree-ExtraBold.woff2';
|
var subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||||
// Hindi font
|
if (lang === 'hi') {
|
||||||
if (lang == 'hi') {
|
|
||||||
subFont = '/fonts/Hind-SemiBold.ttf';
|
subFont = '/fonts/Hind-SemiBold.ttf';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtitles
|
|
||||||
var options = {
|
var options = {
|
||||||
video: document.getElementsByTagName('video')[0], // HTML5 video element
|
video: document.getElementsByTagName('video')[0],
|
||||||
subUrl: newSubUrl, // Link to subtitles
|
subUrl: newSubUrl,
|
||||||
workerUrl: '/build/js/subtitles-octopus-worker.js', // Link to WebAssembly-based file "libassjs-worker.js"
|
workerUrl: '/build/js/subtitles-octopus-worker.js',
|
||||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js', // Link to non-WebAssembly worker
|
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
|
||||||
fonts: [subFont],
|
fonts: [subFont],
|
||||||
renderMode: 'wasm-blend',
|
renderMode: 'wasm-blend',
|
||||||
};
|
};
|
||||||
@@ -191,215 +100,154 @@ function initSubtitles(lang) {
|
|||||||
subtitleInstance = new SubtitlesOctopus(options);
|
subtitleInstance = new SubtitlesOctopus(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initPlayer() {
|
function initPlayerQualityChange(data) {
|
||||||
player = new Plyr('#player', {
|
|
||||||
controls,
|
|
||||||
quality: {
|
|
||||||
default: 720,
|
|
||||||
options: [2161, 2160, 1081, 1080, 720]
|
|
||||||
},
|
|
||||||
i18n: {
|
|
||||||
qualityLabel: {
|
|
||||||
2161: "2160p48",
|
|
||||||
2160: "2160p",
|
|
||||||
1081: "1080p48",
|
|
||||||
1080: "1080p",
|
|
||||||
720: "720p"
|
|
||||||
},
|
|
||||||
qualityBadge: {
|
|
||||||
2161: "UHD@48",
|
|
||||||
1081: "FHD@48",
|
|
||||||
1080: "FHD",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fullscreen: { enabled: true, fallback: true, iosNative: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Player Track Data
|
|
||||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
|
||||||
|
|
||||||
player.source = {
|
|
||||||
type: 'video',
|
|
||||||
title: apiResponse.title,
|
|
||||||
poster: apiResponse.poster,
|
|
||||||
previewThumbnails: {
|
|
||||||
enabled: true,
|
|
||||||
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
|
|
||||||
},
|
|
||||||
sources: data,
|
|
||||||
tracks: addSubtitleTracks(streamServer, apiResponse)
|
|
||||||
};
|
|
||||||
|
|
||||||
player.volume = volume;
|
|
||||||
player.muted = muted;
|
|
||||||
//player.captions.languages = ['en'];
|
|
||||||
player.captions.language = 'en';
|
|
||||||
player.captions.active = captions;
|
|
||||||
|
|
||||||
if (dashSupported && !apiResponse.legacy) {
|
if (dashSupported && !apiResponse.legacy) {
|
||||||
player.on('qualitychange', () => {
|
player.on('qualitychange', function () {
|
||||||
initDash(data, player);
|
initDash(data);
|
||||||
});
|
});
|
||||||
|
initDash(data);
|
||||||
initDash(data, player);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ambient Mode
|
function initDash(data) {
|
||||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
var videoEl = document.querySelector('video');
|
||||||
setCanvasDimension(canvas, video);
|
var quality = player.quality;
|
||||||
paintStaticVideo(ctx, video);
|
|
||||||
|
|
||||||
var allItems = document.getElementsByClassName('plyr__control--forward');
|
data.forEach(function (el) {
|
||||||
var lastItem = allItems[allItems.length - 1];
|
if (el.mode === 'mpd' && el.size === quality) {
|
||||||
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
var dash = dashjs.MediaPlayer().create();
|
||||||
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
dash.initialize(videoEl, el.src, true);
|
||||||
|
window.dash = dash;
|
||||||
if (localStorage.ambientMode == 'false') {
|
player.dash = dash;
|
||||||
toggleAmbientMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server select (Asia)
|
|
||||||
lastItem = allItems[allItems.length - 1];
|
|
||||||
let value = 'Off';
|
|
||||||
if (serverFallback) { value = 'On'; }
|
|
||||||
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
|
||||||
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
|
||||||
|
|
||||||
var clickedPlay = false;
|
|
||||||
|
|
||||||
player.on('play', () => {
|
|
||||||
if (!clickedPlay) {
|
|
||||||
player.stop();
|
|
||||||
console.log("Stopped video, because user didn't click play.")
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanvasDimension(canvas, video);
|
|
||||||
console.log('Play => Function Loop()');
|
|
||||||
var $this = video;
|
|
||||||
(function loop() {
|
|
||||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
|
||||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
|
||||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
|
|
||||||
player.on('seeked', () => {
|
|
||||||
paintStaticVideo(ctx, video);
|
|
||||||
if (player.currentTime > 0) {
|
|
||||||
lastTime = player.currentTime;
|
|
||||||
}
|
|
||||||
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
setCanvasDimension(canvas, video);
|
|
||||||
if (player.paused) {
|
|
||||||
paintStaticVideo(ctx, video);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
player.on('captionsenabled', () => {
|
function initPlayer() {
|
||||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
|
var videoEl = document.querySelector('#player');
|
||||||
localStorage.setItem('hstreamCaptions', 'true');
|
var container = videoEl.parentElement;
|
||||||
console.log('Set Captions Status to Local Storage: true');
|
|
||||||
});
|
|
||||||
|
|
||||||
player.on('captionsdisabled', () => {
|
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
|
var subtitleTracks = addSubtitleTracks(streamServer, apiResponse);
|
||||||
localStorage.setItem('hstreamCaptions', 'false');
|
var vttThumbsUrl = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||||
console.log('Set Captions Status to Local Storage: false');
|
|
||||||
});
|
|
||||||
|
|
||||||
player.on('volumechange', () => {
|
player = new HStreamPlayer({
|
||||||
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
|
container: container,
|
||||||
localStorage.setItem('hstreamVolume', player.volume.toString())
|
video: videoEl,
|
||||||
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
|
apiResponse: apiResponse,
|
||||||
localStorage.setItem('hstreamMuted', player.muted.toString())
|
streamServer: streamServer,
|
||||||
});
|
streamServers: streamServers,
|
||||||
|
fallbackServers: fallbackServers,
|
||||||
player.on('ended', () => {
|
streamServerIndex: streamServerIndex,
|
||||||
|
av1Supported: av1Supported,
|
||||||
|
dashSupported: dashSupported,
|
||||||
|
poster: apiResponse.poster,
|
||||||
|
title: apiResponse.title,
|
||||||
|
data: data,
|
||||||
|
subtitleTracks: subtitleTracks,
|
||||||
|
volume: volume,
|
||||||
|
muted: muted,
|
||||||
|
captionsActive: captions,
|
||||||
|
captionLanguage: 'en',
|
||||||
|
ambientMode: ambientMode,
|
||||||
|
isMobile: isMobile(),
|
||||||
|
quality: parseInt(localStorage.getItem('hstreamQuality')) || 1080,
|
||||||
|
lastTime: lastTime,
|
||||||
|
subtitleInstance: subtitleInstance,
|
||||||
|
onEnded: function () {
|
||||||
playNextPlaylistVideo();
|
playNextPlaylistVideo();
|
||||||
});
|
},
|
||||||
|
onTimeUpdate: function () {
|
||||||
player.on('languagechange', (event) => {
|
trackWatchTime();
|
||||||
let lang = event.detail.plyr.captions.language;
|
},
|
||||||
|
onQualityChange: function (size) {
|
||||||
console.log('Subtitle Event ' + lang);
|
if (dashSupported && !apiResponse.legacy) {
|
||||||
|
initDash(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onVolumeChange: function () {
|
||||||
|
localStorage.setItem('hstreamVolume', player.volume.toString());
|
||||||
|
localStorage.setItem('hstreamMuted', player.muted.toString());
|
||||||
|
},
|
||||||
|
onCaptionsToggle: function (active) {
|
||||||
|
localStorage.setItem('hstreamCaptions', active.toString());
|
||||||
|
if (subtitleInstance && subtitleInstance.canvas) {
|
||||||
|
subtitleInstance.canvas.style.visibility = active ? 'visible' : 'hidden';
|
||||||
|
}
|
||||||
|
var libassParent = document.querySelector('.libassjs-canvas-parent');
|
||||||
|
if (libassParent) {
|
||||||
|
libassParent.style.visibility = active ? 'visible' : 'hidden';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLanguageChange: function (lang) {
|
||||||
initSubtitles(lang);
|
initSubtitles(lang);
|
||||||
});
|
if (player) {
|
||||||
|
player.setSubtitleInstance(subtitleInstance);
|
||||||
function playerPlayTemp() {
|
|
||||||
clickedPlay = true;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
|
onServerChange: function (index) {
|
||||||
play.addEventListener('click', playerPlayTemp)
|
streamServerIndex = index;
|
||||||
);
|
var allServers = streamServers.concat(fallbackServers);
|
||||||
|
streamServer = allServers[streamServerIndex];
|
||||||
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
|
|
||||||
|
|
||||||
initMobileWidescreen();
|
|
||||||
|
|
||||||
// Start time
|
|
||||||
setTimeout(function () {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const time = parseInt(params.get("t"));
|
|
||||||
if (!isNaN(time)) {
|
|
||||||
player.currentTime = time;
|
|
||||||
console.log("Skipping to " + time)
|
|
||||||
}
|
|
||||||
if (lastTime > 0) {
|
|
||||||
player.currentTime = lastTime;
|
|
||||||
console.log("Skipping to " + lastTime)
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
player.on('ready', () => {
|
|
||||||
mobileDoubleClick(player);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server Select
|
|
||||||
// I hate this...
|
|
||||||
var settingElements = document.getElementsByClassName('plyr__control--forward');
|
|
||||||
if (settingElements.length == 3) {
|
|
||||||
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
|
|
||||||
|
|
||||||
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
|
|
||||||
if (settingNodes.length == 4) {
|
|
||||||
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event Listeners
|
|
||||||
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
|
|
||||||
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
|
|
||||||
let serverSelects = document.getElementsByClassName('change_server');
|
|
||||||
for (let i = 0; i < serverSelects.length; i++) {
|
|
||||||
serverSelects[i].addEventListener('click', function() {
|
|
||||||
streamServerIndex = Number(this.value);
|
|
||||||
streamServer = streamServers[streamServerIndex];
|
|
||||||
console.log('Selected Server: ' + streamServer);
|
console.log('Selected Server: ' + streamServer);
|
||||||
|
|
||||||
if (player) {
|
if (player) {
|
||||||
clearInterval(saveInterval);
|
clearInterval(saveInterval);
|
||||||
|
stopEngagementTracking();
|
||||||
player.destroy();
|
player.destroy();
|
||||||
}
|
}
|
||||||
initPlayer();
|
initPlayer();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
window.player = player;
|
||||||
|
|
||||||
|
if (player.captionsActive) {
|
||||||
|
initSubtitles(player.captionLanguage);
|
||||||
|
player.setSubtitleInstance(subtitleInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodically save last timestamp
|
if (!isMobile()) {
|
||||||
|
player.initThumbnails(vttThumbsUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashSupported && !apiResponse.legacy) {
|
||||||
|
initDash(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
initMobileWidescreen(container, videoEl);
|
||||||
|
initMobileDoubleTap(container, videoEl, player);
|
||||||
|
|
||||||
|
var episodeId = document.getElementById('e_id').value;
|
||||||
|
player.initHeatmap(episodeId);
|
||||||
|
|
||||||
|
videoEl.addEventListener('play', function onFirstPlay() {
|
||||||
|
videoEl.removeEventListener('play', onFirstPlay);
|
||||||
|
startEngagementTracking(episodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
var time = parseInt(params.get('t'));
|
||||||
|
if (!isNaN(time)) {
|
||||||
|
player.currentTime = time;
|
||||||
|
console.log('Skipping to ' + time);
|
||||||
|
}
|
||||||
|
if (lastTime > 0) {
|
||||||
|
player.currentTime = lastTime;
|
||||||
|
console.log('Skipping to ' + lastTime);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
saveInterval = setInterval(function () {
|
saveInterval = setInterval(function () {
|
||||||
lastTime = player.currentTime;
|
lastTime = player.currentTime;
|
||||||
console.log("Last Player Position: " + lastTime);
|
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initVidstackPlayer() {
|
async function initVidstackPlayer() {
|
||||||
const videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
var videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
||||||
const videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
var videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||||
const videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
var videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
||||||
|
|
||||||
player = await VidstackPlayer.create({
|
player = await VidstackPlayer.create({
|
||||||
target: '#player',
|
target: '#player',
|
||||||
@@ -421,52 +269,56 @@ async function initVidstackPlayer() {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ambient Mode
|
window.player = player;
|
||||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
|
||||||
setCanvasDimension(canvas, video);
|
|
||||||
paintStaticVideo(ctx, video);
|
|
||||||
|
|
||||||
player.addEventListener('play', () => {
|
player.addEventListener('time-update', function () {
|
||||||
setCanvasDimension(canvas, video);
|
trackWatchTime();
|
||||||
console.log('Play => Function Loop()');
|
|
||||||
var $this = video;
|
|
||||||
(function loop() {
|
|
||||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
|
||||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
|
||||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Data from API
|
window.setPlayerPreference = function(pref) {
|
||||||
window.axios.post('/player/api', {
|
localStorage.setItem('hstreamPlayerPreference', pref);
|
||||||
episode_id: document.getElementById('e_id').value
|
window.location.reload();
|
||||||
}).then(function (response) {
|
};
|
||||||
if (response.status == 200) {
|
|
||||||
apiResponse = response.data;
|
|
||||||
streamServers = apiResponse.stream_domains;
|
|
||||||
|
|
||||||
if (serverFallback) {
|
const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||||
streamServers = apiResponse.asia_stream_domains;
|
|
||||||
|
if (playerPreference === 'plyr' && !isIOS()) {
|
||||||
|
import('./player-plyr.js').then(m => m.initPlyrPlayer(document.getElementById('e_id').value));
|
||||||
|
} else {
|
||||||
|
window.axios.post('/player/api', {
|
||||||
|
episode_id: document.getElementById('e_id').value
|
||||||
|
}).then(function (response) {
|
||||||
|
if (response.status === 200) {
|
||||||
|
apiResponse = response.data;
|
||||||
|
streamServers = apiResponse.stream_domains || [];
|
||||||
|
fallbackServers = apiResponse.asia_stream_domains || [];
|
||||||
|
|
||||||
|
const cdnCount = streamServers.length;
|
||||||
|
if (cdnCount > 0) {
|
||||||
|
streamServerIndex = Math.floor(Math.random() * cdnCount);
|
||||||
|
streamServer = streamServers[streamServerIndex];
|
||||||
|
} else {
|
||||||
|
const fallbackCount = fallbackServers.length;
|
||||||
|
streamServerIndex = Math.floor(Math.random() * fallbackCount);
|
||||||
|
streamServer = fallbackServers[streamServerIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
streamServerCount = streamServers.length;
|
streamServerCount = streamServers.length + fallbackServers.length;
|
||||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
|
||||||
streamServer = streamServers[streamServerIndex];
|
|
||||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||||
|
|
||||||
if (!isIOS()) {
|
if (!isIOS()) {
|
||||||
initPlayer();
|
initPlayer();
|
||||||
}
|
} else {
|
||||||
else {
|
console.log('Detected Apple device. Using Vidstack fallback player.');
|
||||||
console.log("Detected Apple Shit. Using different player.")
|
|
||||||
initVidstackPlayer();
|
initVidstackPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
var alert = document.getElementById("player-alert");
|
var alert = document.getElementById('player-alert');
|
||||||
|
if (alert) {
|
||||||
alert.innerText = 'The player encountered a problem: ' + error;
|
alert.innerText = 'The player encountered a problem: ' + error;
|
||||||
alert.classList.remove("hidden");
|
alert.classList.remove('hidden');
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
export function addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported) {
|
||||||
|
if (dashSupported) {
|
||||||
|
return addDashTracks(streamServer, apiResponse, av1Supported);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addLegacyTracks(streamServer, apiResponse, av1Supported);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function addDashTracks(streamServer, apiResponse, av1Supported) {
|
||||||
|
var data = [];
|
||||||
|
|
||||||
|
// 720p
|
||||||
|
data.push({
|
||||||
|
src: streamServer + '/' + apiResponse.stream_url + '/720/manifest.mpd',
|
||||||
|
size: 720,
|
||||||
|
mode: 'mpd',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (av1Supported) {
|
||||||
|
// 1080p
|
||||||
|
data.push({
|
||||||
|
src: streamServer + '/' + apiResponse.stream_url + '/1080/manifest.mpd',
|
||||||
|
size: 1080,
|
||||||
|
mode: 'mpd',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2160p
|
||||||
|
data.push({
|
||||||
|
src: streamServer + '/' + apiResponse.stream_url + '/2160/manifest.mpd',
|
||||||
|
size: 2160,
|
||||||
|
mode: 'mpd',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (apiResponse.interpolated == 1) {
|
||||||
|
// 1080p Interpolated
|
||||||
|
data.push({
|
||||||
|
src: streamServer + '/' + apiResponse.stream_url + '/1080i/manifest.mpd',
|
||||||
|
size: 1081,
|
||||||
|
mode: 'mpd',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiResponse.interpolated_uhd == 1) {
|
||||||
|
// 2160p Interpolated
|
||||||
|
data.push({
|
||||||
|
src: streamServer + '/' + apiResponse.stream_url + '/2160i/manifest.mpd',
|
||||||
|
size: 2161,
|
||||||
|
mode: 'mpd',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLegacyTracks(streamServer, apiResponse, av1Supported) {
|
||||||
|
var data = [];
|
||||||
|
|
||||||
|
// 720p
|
||||||
|
data.push({
|
||||||
|
src: streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4',
|
||||||
|
type: 'video/mp4',
|
||||||
|
size: 720,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function addSubtitleTracks(streamServer, apiResponse) {
|
||||||
|
var data = [];
|
||||||
|
|
||||||
|
// Default
|
||||||
|
data.push({
|
||||||
|
kind: 'captions',
|
||||||
|
label: 'English',
|
||||||
|
srclang: 'en',
|
||||||
|
src: '',
|
||||||
|
default: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var key in apiResponse.extra_subtitles) {
|
||||||
|
data.push({
|
||||||
|
kind: 'captions',
|
||||||
|
label: apiResponse.extra_subtitles[key] + ' (Auto Transl.)',
|
||||||
|
srclang: key,
|
||||||
|
src: '',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Engagement heatmap tracking
|
||||||
|
// Samples the user's current time while playing and sends batched segment data to the server.
|
||||||
|
// Only tracks segment >= 1 (excludes 0-10s).
|
||||||
|
// Only calls the endpoint when the user is logged in.
|
||||||
|
|
||||||
|
let engagementInterval;
|
||||||
|
let engagementSegments = new Set();
|
||||||
|
let engagementReportInterval;
|
||||||
|
const SEGMENT_DURATION = 10; // seconds per segment
|
||||||
|
const SAMPLE_INTERVAL = 5000; // sample every 5s
|
||||||
|
const REPORT_INTERVAL = 15000; // send batch every 15s
|
||||||
|
|
||||||
|
function isAuthenticated() {
|
||||||
|
const el = document.getElementById('auth_check');
|
||||||
|
return el && el.value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEngagement(episodeId, segments) {
|
||||||
|
if (!isAuthenticated()) return;
|
||||||
|
|
||||||
|
window.axios.post('/player/engagement', {
|
||||||
|
episode_id: episodeId,
|
||||||
|
segments: segments,
|
||||||
|
}).catch(() => {
|
||||||
|
// Fire-and-forget: silently ignore network errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startEngagementTracking(episodeId) {
|
||||||
|
engagementSegments.clear();
|
||||||
|
|
||||||
|
// Sample current time while playing
|
||||||
|
engagementInterval = setInterval(() => {
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
if (!video || video.paused) return;
|
||||||
|
|
||||||
|
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
|
||||||
|
// Skip segment 0 (0-10s) — no need to track the very start
|
||||||
|
if (segment >= 1) {
|
||||||
|
engagementSegments.add(segment);
|
||||||
|
}
|
||||||
|
}, SAMPLE_INTERVAL);
|
||||||
|
|
||||||
|
// Batch report to server
|
||||||
|
engagementReportInterval = setInterval(() => {
|
||||||
|
if (engagementSegments.size === 0) return;
|
||||||
|
|
||||||
|
const segments = Array.from(engagementSegments);
|
||||||
|
engagementSegments.clear();
|
||||||
|
|
||||||
|
sendEngagement(episodeId, segments);
|
||||||
|
}, REPORT_INTERVAL);
|
||||||
|
|
||||||
|
// Flush remaining segments & cleanup on page unload
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(engagementInterval);
|
||||||
|
clearInterval(engagementReportInterval);
|
||||||
|
|
||||||
|
if (engagementSegments.size > 0) {
|
||||||
|
const segments = Array.from(engagementSegments);
|
||||||
|
engagementSegments.clear();
|
||||||
|
sendEngagement(episodeId, segments);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopEngagementTracking() {
|
||||||
|
if (engagementInterval) clearInterval(engagementInterval);
|
||||||
|
if (engagementReportInterval) clearInterval(engagementReportInterval);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Engagement heatmap display
|
||||||
|
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
|
||||||
|
|
||||||
|
let heatmapContainer = null;
|
||||||
|
let heatmapCanvas = null;
|
||||||
|
let heatmapResizeObserver = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch engagement data from the server and render the heatmap.
|
||||||
|
* @param {string} episodeId - The episode ID.
|
||||||
|
* @param {number} duration - Video duration in seconds.
|
||||||
|
*/
|
||||||
|
export async function renderHeatmap(episodeId, duration) {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get(`/player/engagement/${episodeId}`);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHeatmapCurve(data, duration);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load engagement data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a smooth area chart on a canvas element above the progress bar.
|
||||||
|
* @param {Object} data - Key-value map of segment -> watch_count.
|
||||||
|
* @param {number} duration - Video duration in seconds.
|
||||||
|
*/
|
||||||
|
function drawHeatmapCurve(data, duration) {
|
||||||
|
const SEGMENT_DURATION = 10;
|
||||||
|
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
|
||||||
|
|
||||||
|
// Build raw counts array, filling gaps with 0
|
||||||
|
const rawCounts = [];
|
||||||
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
|
rawCounts.push(data[i] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply weighted moving average to smooth individual spikes
|
||||||
|
const counts = smoothData(rawCounts);
|
||||||
|
|
||||||
|
const maxCount = Math.max(...counts, 1);
|
||||||
|
|
||||||
|
// Remove existing heatmap if present
|
||||||
|
if (heatmapContainer) {
|
||||||
|
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
|
||||||
|
heatmapContainer.remove();
|
||||||
|
heatmapCanvas = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressBar = document.querySelector('.hstream-player__progress');
|
||||||
|
if (!progressBar) return;
|
||||||
|
|
||||||
|
heatmapContainer = document.createElement('div');
|
||||||
|
heatmapContainer.className = 'hstream-player__progress-heatmap';
|
||||||
|
heatmapContainer.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
heatmapCanvas = document.createElement('canvas');
|
||||||
|
heatmapCanvas.className = 'hstream-player__progress-heatmap-canvas';
|
||||||
|
heatmapContainer.appendChild(heatmapCanvas);
|
||||||
|
|
||||||
|
// Insert as first child of the progress bar so it sits behind the scrubber
|
||||||
|
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
|
||||||
|
|
||||||
|
// Defer drawing to get container dimensions
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redraw on resize
|
||||||
|
heatmapResizeObserver = new ResizeObserver(() => {
|
||||||
|
drawCurve(heatmapCanvas, counts, maxCount);
|
||||||
|
});
|
||||||
|
heatmapResizeObserver.observe(heatmapContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply two passes of a 5-point weighted (Gaussian) moving average.
|
||||||
|
* Near edges falls back to a 3-point average.
|
||||||
|
* Preserves the first and last points.
|
||||||
|
*/
|
||||||
|
function smoothData(data) {
|
||||||
|
if (data.length <= 2) return [...data];
|
||||||
|
|
||||||
|
let result = data;
|
||||||
|
|
||||||
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
|
const smoothed = [result[0]];
|
||||||
|
|
||||||
|
for (let i = 1; i < result.length - 1; i++) {
|
||||||
|
if (result.length > 4 && i >= 2 && i <= result.length - 3) {
|
||||||
|
smoothed.push(
|
||||||
|
(result[i - 2] * 1 + result[i - 1] * 2 + result[i] * 4 +
|
||||||
|
result[i + 1] * 2 + result[i + 2] * 1) / 10
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
smoothed.push((result[i - 1] + result[i] + result[i + 1]) / 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
smoothed.push(result[result.length - 1]);
|
||||||
|
result = smoothed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render vertical bar heatmap directly on the progress bar track.
|
||||||
|
* Each bar represents a time segment; taller bars = more engagement.
|
||||||
|
*/
|
||||||
|
function drawCurve(canvas, counts, maxCount) {
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const rect = parent.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
|
||||||
|
canvas.width = Math.round(w * dpr);
|
||||||
|
canvas.height = Math.round(h * dpr);
|
||||||
|
canvas.style.width = w + 'px';
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
if (counts.length === 0 || maxCount === 0) return;
|
||||||
|
|
||||||
|
const paddingX = 1;
|
||||||
|
const paddingY = 2;
|
||||||
|
const drawW = w - paddingX * 2;
|
||||||
|
const drawH = h - paddingY * 2;
|
||||||
|
const n = counts.length;
|
||||||
|
|
||||||
|
const pts = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = paddingX + (i / (n - 1 || 1)) * drawW;
|
||||||
|
const ratio = Math.min(counts[i] / maxCount, 1);
|
||||||
|
const y = paddingY + (1 - ratio) * drawH;
|
||||||
|
pts.push({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
|
||||||
|
// Build the smooth path using quadratic bezier curves through midpoints
|
||||||
|
const path = [{ x: pts[0].x, y: pts[0].y }];
|
||||||
|
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const midX = (pts[i].x + pts[i + 1].x) / 2;
|
||||||
|
const midY = (pts[i].y + pts[i + 1].y) / 2;
|
||||||
|
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
|
||||||
|
}
|
||||||
|
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
|
||||||
|
|
||||||
|
// --- Draw a subtle glow behind the line ---
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(path[0].x, path[0].y);
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const prev = path[i - 1];
|
||||||
|
const curr = path[i];
|
||||||
|
if (curr.cp) {
|
||||||
|
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(curr.x, curr.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
ctx.lineWidth = 3.0;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// --- Draw the main waveform line ---
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(path[0].x, path[0].y);
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const prev = path[i - 1];
|
||||||
|
const curr = path[i];
|
||||||
|
if (curr.cp) {
|
||||||
|
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(curr.x, curr.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the heatmap from the DOM.
|
||||||
|
*/
|
||||||
|
export function removeHeatmap() {
|
||||||
|
if (heatmapResizeObserver) {
|
||||||
|
heatmapResizeObserver.disconnect();
|
||||||
|
heatmapResizeObserver = null;
|
||||||
|
}
|
||||||
|
if (heatmapContainer) {
|
||||||
|
heatmapContainer.remove();
|
||||||
|
heatmapContainer = null;
|
||||||
|
heatmapCanvas = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Mobile-specific player features:
|
||||||
|
* - Double-tap left/right to skip ±10s
|
||||||
|
* - Object-fit toggle button for widescreen fill
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isMobile() {
|
||||||
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initMobileWidescreen(playerWrapper, video) {
|
||||||
|
if (!isMobile()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = playerWrapper.querySelector('.hstream-player__controls');
|
||||||
|
if (!controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'hstream-player__button hstream-player__mobile-fill-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.setAttribute('aria-label', 'Toggle screen fill');
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-arrows-left-right-to-line"></i>';
|
||||||
|
btn.title = 'Fill Screen';
|
||||||
|
|
||||||
|
const fullscreenBtn = controls.querySelector('[data-action="fullscreen"]');
|
||||||
|
if (fullscreenBtn) {
|
||||||
|
fullscreenBtn.insertAdjacentElement('beforebegin', btn);
|
||||||
|
} else {
|
||||||
|
controls.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fillEnabled = true;
|
||||||
|
video.style.objectFit = 'cover';
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (fillEnabled) {
|
||||||
|
video.style.objectFit = 'contain';
|
||||||
|
fillEnabled = false;
|
||||||
|
btn.classList.remove('hstream-player__button--active');
|
||||||
|
} else {
|
||||||
|
video.style.objectFit = 'cover';
|
||||||
|
fillEnabled = true;
|
||||||
|
btn.classList.add('hstream-player__button--active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.classList.add('hstream-player__button--active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initMobileDoubleTap(playerWrapper, video, player) {
|
||||||
|
if (!isMobile()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipOverlay = playerWrapper.querySelector('.hstream-player__skip-overlay');
|
||||||
|
if (!skipOverlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiClickCounter {
|
||||||
|
constructor() {
|
||||||
|
this.timers = [];
|
||||||
|
this.count = 0;
|
||||||
|
this.reseted = 0;
|
||||||
|
this.lastSide = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clicked() {
|
||||||
|
this.count += 1;
|
||||||
|
const xcount = this.count;
|
||||||
|
this.timers.push(setTimeout(() => this.reset(xcount), 500));
|
||||||
|
return this.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCount(n) {
|
||||||
|
this.reseted = this.count;
|
||||||
|
this.count = n;
|
||||||
|
this.timers.forEach(t => clearTimeout(t));
|
||||||
|
this.timers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(xcount) {
|
||||||
|
if (this.count > xcount) return;
|
||||||
|
this.count = 0;
|
||||||
|
this.lastSide = null;
|
||||||
|
this.reseted = 0;
|
||||||
|
skipOverlay.classList.remove('hstream-player__skip-overlay--visible');
|
||||||
|
this.timers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counter = new MultiClickCounter();
|
||||||
|
|
||||||
|
const handleTap = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const count = counter.clicked();
|
||||||
|
if (count < 2) return;
|
||||||
|
|
||||||
|
const rect = e.target.getBoundingClientRect();
|
||||||
|
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||||
|
const perc = (x / rect.width) * 100;
|
||||||
|
|
||||||
|
let shouldReset = true;
|
||||||
|
const lastSide = counter.lastSide;
|
||||||
|
|
||||||
|
if (lastSide === null) {
|
||||||
|
shouldReset = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (perc < 40) {
|
||||||
|
if (player.currentTime === 0) return;
|
||||||
|
counter.lastSide = 'L';
|
||||||
|
if (shouldReset && lastSide !== 'L') {
|
||||||
|
counter.resetCount(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const skipSeconds = (count - 1) * 10;
|
||||||
|
player.currentTime = Math.max(0, player.currentTime - skipSeconds);
|
||||||
|
skipOverlay.innerHTML = '<i class="fa-solid fa-backward"></i>' + skipSeconds + 's';
|
||||||
|
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||||
|
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||||
|
} else if (perc > 60) {
|
||||||
|
if (player.currentTime >= player.duration) return;
|
||||||
|
counter.lastSide = 'R';
|
||||||
|
if (shouldReset && lastSide !== 'R') {
|
||||||
|
counter.resetCount(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const skipSeconds = (count - 1) * 10;
|
||||||
|
player.currentTime = Math.min(player.duration, player.currentTime + skipSeconds);
|
||||||
|
skipOverlay.innerHTML = '<i class="fa-solid fa-forward"></i>' + skipSeconds + 's';
|
||||||
|
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||||
|
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||||
|
} else {
|
||||||
|
player.togglePlay();
|
||||||
|
counter.lastSide = 'C';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
playerWrapper.addEventListener('click', handleTap);
|
||||||
|
|
||||||
|
video.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Builds the server/CDN selector submenu panel for the settings menu.
|
||||||
|
* @param {string[]} streamServers - Regular CDN server URLs
|
||||||
|
* @param {string[]} fallbackServers - Fallback server URLs
|
||||||
|
* @param {number} selectedIndex - Index in the combined server list
|
||||||
|
* @param {function} onSelect - Callback receiving the combined index
|
||||||
|
*/
|
||||||
|
export function buildServerMenu(streamServers, fallbackServers, selectedIndex, onSelect) {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'hstream-player__menu-panel';
|
||||||
|
panel.setAttribute('data-panel', 'server');
|
||||||
|
|
||||||
|
const backBtn = document.createElement('button');
|
||||||
|
backBtn.className = 'hstream-player__menu-back';
|
||||||
|
backBtn.type = 'button';
|
||||||
|
backBtn.innerHTML = '<i class="fa-solid fa-chevron-left"></i> Server';
|
||||||
|
backBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menuContainer = panel.closest('.hstream-player__menu-container');
|
||||||
|
if (menuContainer) {
|
||||||
|
menuContainer.querySelectorAll('.hstream-player__menu-panel').forEach(p => p.classList.remove('hstream-player__menu-panel--active'));
|
||||||
|
const mainPanel = menuContainer.querySelector('[data-panel="main"]');
|
||||||
|
if (mainPanel) mainPanel.classList.add('hstream-player__menu-panel--active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
panel.appendChild(backBtn);
|
||||||
|
|
||||||
|
const addServerItems = (servers, labelPrefix, startIndex) => {
|
||||||
|
for (let i = 0; i < servers.length; i++) {
|
||||||
|
const index = startIndex + i;
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'hstream-player__menu-item';
|
||||||
|
item.type = 'button';
|
||||||
|
item.setAttribute('role', 'menuitemradio');
|
||||||
|
|
||||||
|
if (index === selectedIndex) {
|
||||||
|
item.classList.add('hstream-player__menu-item--checked');
|
||||||
|
item.setAttribute('aria-checked', 'true');
|
||||||
|
} else {
|
||||||
|
item.setAttribute('aria-checked', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = i + 1;
|
||||||
|
item.innerHTML = `<span>${labelPrefix} ${num} <span class="hstream-player__menu-value"><span class="hstream-player__menu-badge">${labelPrefix}${num}</span></span></span><span class="hstream-player__menu-item-radio"></span>`;
|
||||||
|
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(index);
|
||||||
|
});
|
||||||
|
panel.appendChild(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'hstream-player__menu-divider';
|
||||||
|
panel.appendChild(divider);
|
||||||
|
|
||||||
|
addServerItems(streamServers, 'Server', 0);
|
||||||
|
|
||||||
|
if (fallbackServers && fallbackServers.length > 0) {
|
||||||
|
const fbDivider = document.createElement('div');
|
||||||
|
fbDivider.className = 'hstream-player__menu-divider';
|
||||||
|
panel.appendChild(fbDivider);
|
||||||
|
|
||||||
|
addServerItems(fallbackServers, 'Fallback', streamServers.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* VTT-based sprite thumbnail preview.
|
||||||
|
* Parses WEBVTT cues with Media Fragment URIs (#xywh=x,y,w,h) and renders
|
||||||
|
* a floating preview image above the progress bar on hover.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ThumbnailPreview {
|
||||||
|
constructor(progressWrapper, video) {
|
||||||
|
this.progressWrapper = progressWrapper;
|
||||||
|
this.video = video;
|
||||||
|
this.cues = [];
|
||||||
|
this.spriteImg = null;
|
||||||
|
this.thumbnailWidth = 160;
|
||||||
|
this.thumbnailHeight = 90;
|
||||||
|
this.visible = false;
|
||||||
|
|
||||||
|
this.el = document.createElement('div');
|
||||||
|
this.el.className = 'hstream-player__thumbnail-preview';
|
||||||
|
this.el.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
this.imgEl = document.createElement('div');
|
||||||
|
this.imgEl.className = 'hstream-player__thumbnail-preview-img';
|
||||||
|
this.el.appendChild(this.imgEl);
|
||||||
|
|
||||||
|
this.timeEl = document.createElement('div');
|
||||||
|
this.timeEl.className = 'hstream-player__thumbnail-preview-time';
|
||||||
|
this.el.appendChild(this.timeEl);
|
||||||
|
|
||||||
|
this.el.style.display = 'none';
|
||||||
|
this.progressWrapper.appendChild(this.el);
|
||||||
|
|
||||||
|
this._onMove = this._onMove.bind(this);
|
||||||
|
this._onLeave = this._onLeave.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse the thumbnail VTT file.
|
||||||
|
* @param {string} vttUrl
|
||||||
|
*/
|
||||||
|
async load(vttUrl) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(vttUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch VTT: ' + response.status);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
const baseDir = vttUrl.substring(0, vttUrl.lastIndexOf('/') + 1);
|
||||||
|
this.cues = this._parseVTT(text, baseDir);
|
||||||
|
if (this.cues.length > 0) {
|
||||||
|
this.spriteImg = new Image();
|
||||||
|
this.spriteImg.crossOrigin = 'anonymous';
|
||||||
|
this.spriteImg.src = this.cues[0].spriteUrl;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
this.spriteImg.onload = resolve;
|
||||||
|
this.spriteImg.onerror = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._attach();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ThumbnailPreview] Could not load thumbnails:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse WEBVTT text extracting cues with sprite coordinates.
|
||||||
|
*/
|
||||||
|
_parseVTT(text, baseDir) {
|
||||||
|
const cues = [];
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const cueRegex = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/;
|
||||||
|
const xywhRegex = /#xywh=(\d+),(\d+),(\d+),(\d+)/;
|
||||||
|
|
||||||
|
const resolveUrl = (maybeRelative) => {
|
||||||
|
if (!baseDir || maybeRelative.startsWith('http://') || maybeRelative.startsWith('https://') || maybeRelative.startsWith('data:') || maybeRelative.startsWith('/')) {
|
||||||
|
return maybeRelative;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(maybeRelative, baseDir).href;
|
||||||
|
} catch (e) {
|
||||||
|
return baseDir + maybeRelative;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
const match = line.match(cueRegex);
|
||||||
|
if (match) {
|
||||||
|
const startTime = this._timeToSeconds(match[1]);
|
||||||
|
const endTime = this._timeToSeconds(match[2]);
|
||||||
|
i++;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const payload = lines[i].trim();
|
||||||
|
if (payload === '' || payload.match(cueRegex)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const xywh = payload.match(xywhRegex);
|
||||||
|
if (xywh) {
|
||||||
|
const rawUrl = payload.substring(0, xywh.index);
|
||||||
|
cues.push({
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
spriteUrl: resolveUrl(rawUrl),
|
||||||
|
x: parseInt(xywh[1], 10),
|
||||||
|
y: parseInt(xywh[2], 10),
|
||||||
|
w: parseInt(xywh[3], 10),
|
||||||
|
h: parseInt(xywh[4], 10),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const noteMatch = payload.match(/^NOTE/);
|
||||||
|
if (!noteMatch) {
|
||||||
|
const urlMatch = payload.match(/^(\S+)/);
|
||||||
|
if (urlMatch) {
|
||||||
|
cues.push({ startTime, endTime, spriteUrl: resolveUrl(urlMatch[1]), x: 0, y: 0, w: 0, h: 0 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return cues;
|
||||||
|
}
|
||||||
|
|
||||||
|
_timeToSeconds(timestamp) {
|
||||||
|
const [h, m, s] = timestamp.split(':');
|
||||||
|
return parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
_attach() {
|
||||||
|
this.progressWrapper.addEventListener('mousemove', this._onMove);
|
||||||
|
this.progressWrapper.addEventListener('mouseleave', this._onLeave);
|
||||||
|
this.progressWrapper.addEventListener('touchmove', this._onMove, { passive: true });
|
||||||
|
this.progressWrapper.addEventListener('touchend', this._onLeave);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMove(e) {
|
||||||
|
const rect = this.progressWrapper.getBoundingClientRect();
|
||||||
|
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||||
|
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const time = ratio * this.video.duration;
|
||||||
|
|
||||||
|
const cue = this._findCue(time);
|
||||||
|
if (!cue) {
|
||||||
|
this._hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._show(cue, time, rect, x);
|
||||||
|
}
|
||||||
|
|
||||||
|
_findCue(time) {
|
||||||
|
for (let i = 0; i < this.cues.length; i++) {
|
||||||
|
if (time >= this.cues[i].startTime && time <= this.cues[i].endTime) {
|
||||||
|
return this.cues[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_show(cue, time, progressRect, mouseX) {
|
||||||
|
this.imgEl.style.backgroundImage = `url(${cue.spriteUrl})`;
|
||||||
|
this.imgEl.style.width = cue.w + 'px';
|
||||||
|
this.imgEl.style.height = cue.h + 'px';
|
||||||
|
this.imgEl.style.backgroundPosition = `-${cue.x}px -${cue.y}px`;
|
||||||
|
|
||||||
|
const mins = Math.floor(time / 60);
|
||||||
|
const secs = Math.floor(time % 60);
|
||||||
|
this.timeEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const containerWidth = this.progressWrapper.offsetWidth;
|
||||||
|
const halfW = cue.w / 2;
|
||||||
|
let left = mouseX;
|
||||||
|
if (left < halfW + 4) left = halfW + 4;
|
||||||
|
if (left > containerWidth - halfW - 4) left = containerWidth - halfW - 4;
|
||||||
|
|
||||||
|
this.el.style.left = left + 'px';
|
||||||
|
this.el.style.display = '';
|
||||||
|
|
||||||
|
const timeTooltip = this.progressWrapper.querySelector('.hstream-player__time-tooltip');
|
||||||
|
if (timeTooltip) {
|
||||||
|
timeTooltip.classList.remove('hstream-player__time-tooltip--visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.visible) {
|
||||||
|
this.visible = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.el.classList.add('hstream-player__thumbnail-preview--visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_hide() {
|
||||||
|
this.visible = false;
|
||||||
|
this.el.classList.remove('hstream-player__thumbnail-preview--visible');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.visible) {
|
||||||
|
this.el.style.display = 'none';
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onLeave() {
|
||||||
|
this._hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.progressWrapper.removeEventListener('mousemove', this._onMove);
|
||||||
|
this.progressWrapper.removeEventListener('mouseleave', this._onLeave);
|
||||||
|
this.progressWrapper.removeEventListener('touchmove', this._onMove);
|
||||||
|
this.progressWrapper.removeEventListener('touchend', this._onLeave);
|
||||||
|
if (this.el.parentNode) {
|
||||||
|
this.el.parentNode.removeChild(this.el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-44
@@ -1,56 +1,52 @@
|
|||||||
const sleep = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));
|
function initGalleryPreviews() {
|
||||||
var old_timestamp = document.getElementById('ts_reference').value;
|
const previews = document.querySelectorAll('.preview-gallery');
|
||||||
|
|
||||||
function initPreviews() {
|
previews.forEach((img) => {
|
||||||
var thumbs = document.querySelectorAll('div[data-thumbs]');
|
// Prevent double initialization
|
||||||
thumbs.forEach(function (thumb) {
|
if (img.dataset.previewInitialized) return;
|
||||||
var thumbsJSON = JSON.parse(thumb.dataset.thumbs);
|
|
||||||
var originalImage = thumb.children[0].children[1].src;
|
|
||||||
var interval;
|
|
||||||
var i = 1;
|
|
||||||
|
|
||||||
function clear() {
|
img.dataset.previewInitialized = 'true';
|
||||||
thumb.children[0].children[1].src = originalImage;
|
|
||||||
i = 1;
|
|
||||||
clearTimeout(interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle() {
|
let images = [];
|
||||||
if (i == 0) {
|
|
||||||
clear();
|
try {
|
||||||
|
images = JSON.parse(img.dataset.gallery);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Invalid gallery JSON', e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb.children[0].children[1].src = thumbsJSON[i];
|
if (images.length <= 1) return;
|
||||||
i = (i + 1) % thumbsJSON.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function interval() {
|
const original = img.src;
|
||||||
// Start Preview
|
|
||||||
interval = setInterval(toggle, 700);
|
|
||||||
}
|
|
||||||
|
|
||||||
thumb.addEventListener('mouseenter', interval);
|
let index = 0;
|
||||||
thumb.addEventListener('mouseleave', clear);
|
let interval = null;
|
||||||
|
|
||||||
|
const startPreview = () => {
|
||||||
|
console.log("startPreview");
|
||||||
|
interval = setInterval(() => {
|
||||||
|
index = (index + 1) % images.length;
|
||||||
|
img.src = images[index];
|
||||||
|
}, 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPreview = () => {
|
||||||
|
console.log("stopPreview");
|
||||||
|
clearInterval(interval);
|
||||||
|
interval = null;
|
||||||
|
|
||||||
|
index = 0;
|
||||||
|
img.src = original;
|
||||||
|
};
|
||||||
|
|
||||||
|
img.addEventListener('mouseenter', startPreview);
|
||||||
|
img.addEventListener('mouseleave', stopPreview);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
// Initial page load
|
||||||
for (let i = 0; i < 9; i++) {
|
document.addEventListener('DOMContentLoaded', initGalleryPreviews);
|
||||||
var new_timestamp = document.getElementById('ts_reference').value;
|
|
||||||
if (new_timestamp != old_timestamp) {
|
|
||||||
console.log('== Changed ==');
|
|
||||||
initPreviews();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
console.log('== Didnt Change ==');
|
|
||||||
await sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('contentChanged', event => {
|
// Livewire v3 navigation/update
|
||||||
console.log('== Received contentChanged Event ==');
|
document.addEventListener('contentChanged', initGalleryPreviews);
|
||||||
init();
|
|
||||||
});
|
|
||||||
|
|
||||||
initPreviews();
|
|
||||||
+274
-34
@@ -1,73 +1,313 @@
|
|||||||
import Chart from 'chart.js/auto';
|
import Chart from 'chart.js/auto';
|
||||||
|
|
||||||
// Theming
|
/**
|
||||||
if (localStorage.theme !== 'light') {
|
* Theme-aware chart defaults
|
||||||
Chart.defaults.color = "#ADBABD";
|
*/
|
||||||
Chart.defaults.borderColor = "rgba(255,255,255,0.1)";
|
function getChartColors() {
|
||||||
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)";
|
const isDark = localStorage.theme !== 'light' &&
|
||||||
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)";
|
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
return {
|
||||||
|
textColor: '#ADBABD',
|
||||||
|
gridColor: 'rgba(255, 255, 255, 0.06)',
|
||||||
|
fillStart: 'rgba(190, 18, 60, 0.2)',
|
||||||
|
fillEnd: 'rgba(190, 18, 60, 0.0)',
|
||||||
|
borderColor: 'rgba(190, 18, 60, 0.9)',
|
||||||
|
pointColor: 'rgba(190, 18, 60, 1)',
|
||||||
|
pointHoverColor: '#ffffff',
|
||||||
|
skeletonBase: '#262626',
|
||||||
|
skeletonShimmer: '#333333',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
textColor: '#6B7280',
|
||||||
|
gridColor: 'rgba(0, 0, 0, 0.06)',
|
||||||
|
fillStart: 'rgba(190, 18, 60, 0.15)',
|
||||||
|
fillEnd: 'rgba(190, 18, 60, 0.0)',
|
||||||
|
borderColor: 'rgba(190, 18, 60, 1.0)',
|
||||||
|
pointColor: 'rgba(190, 18, 60, 1)',
|
||||||
|
pointHoverColor: '#ffffff',
|
||||||
|
skeletonBase: '#E5E7EB',
|
||||||
|
skeletonShimmer: '#F3F4F6',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Tags from API
|
/**
|
||||||
window.axios.get('/v1/monthly-views').then(function (response) {
|
* Show the skeleton loader
|
||||||
if (response.status != 200) {
|
*/
|
||||||
return;
|
function showSkeleton() {
|
||||||
|
const skeleton = document.getElementById('chart-skeleton');
|
||||||
|
const canvas = document.getElementById('monthlyChart');
|
||||||
|
const error = document.getElementById('chart-error');
|
||||||
|
|
||||||
|
if (skeleton) skeleton.style.display = '';
|
||||||
|
if (canvas) canvas.style.opacity = '0';
|
||||||
|
if (error) error.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the skeleton and show the chart canvas
|
||||||
|
*/
|
||||||
|
function hideSkeleton() {
|
||||||
|
const skeleton = document.getElementById('chart-skeleton');
|
||||||
|
const canvas = document.getElementById('monthlyChart');
|
||||||
|
|
||||||
|
if (skeleton) {
|
||||||
|
// Fade out skeleton
|
||||||
|
skeleton.style.transition = 'opacity 0.4s ease-out';
|
||||||
|
skeleton.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (skeleton) skeleton.style.display = 'none';
|
||||||
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
if (canvas) {
|
||||||
labels: response.data.map((entry) => { return entry.date }),
|
setTimeout(() => {
|
||||||
|
canvas.style.opacity = '1';
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error state
|
||||||
|
*/
|
||||||
|
function showError() {
|
||||||
|
const skeleton = document.getElementById('chart-skeleton');
|
||||||
|
const error = document.getElementById('chart-error');
|
||||||
|
|
||||||
|
if (skeleton) skeleton.style.display = 'none';
|
||||||
|
if (error) {
|
||||||
|
error.classList.remove('hidden');
|
||||||
|
error.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide error state
|
||||||
|
*/
|
||||||
|
function hideError() {
|
||||||
|
const error = document.getElementById('chart-error');
|
||||||
|
if (error) {
|
||||||
|
error.classList.add('hidden');
|
||||||
|
error.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create gradient fill for the chart
|
||||||
|
*/
|
||||||
|
function createGradient(ctx, colors) {
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight);
|
||||||
|
gradient.addColorStop(0, colors.fillStart);
|
||||||
|
gradient.addColorStop(1, colors.fillEnd);
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
let monthlyViewChart = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the chart with data
|
||||||
|
*/
|
||||||
|
function renderChart(data) {
|
||||||
|
const colors = getChartColors();
|
||||||
|
const canvas = document.getElementById('monthlyChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Destroy previous chart instance if it exists
|
||||||
|
if (monthlyViewChart) {
|
||||||
|
monthlyViewChart.destroy();
|
||||||
|
monthlyViewChart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradient = createGradient(ctx, colors);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map((entry) => entry.date),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Views',
|
label: 'Views',
|
||||||
fill: false,
|
fill: true,
|
||||||
backgroundColor: 'rgba(190, 18, 60, 0.3)',
|
backgroundColor: gradient,
|
||||||
borderColor: 'rgba(190, 18, 60, 1.0)',
|
borderColor: colors.borderColor,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
pointBackgroundColor: colors.pointColor,
|
||||||
|
pointBorderColor: colors.pointColor,
|
||||||
|
pointHoverBackgroundColor: colors.pointHoverColor,
|
||||||
|
pointHoverBorderColor: colors.borderColor,
|
||||||
|
pointHoverBorderWidth: 2,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointRadius: 2.5,
|
||||||
|
pointHitRadius: 20,
|
||||||
cubicInterpolationMode: 'monotone',
|
cubicInterpolationMode: 'monotone',
|
||||||
data: response.data.map((entry) => { return entry.count }),
|
tension: 0.4,
|
||||||
|
data: data.map((entry) => entry.count),
|
||||||
}]
|
}]
|
||||||
}
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: data,
|
data: chartData,
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: 1200,
|
||||||
|
easing: 'easeOutQuart',
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: false,
|
||||||
text: 'Views the last 28 days',
|
},
|
||||||
font: {
|
legend: {
|
||||||
size: 18
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 17, 17, 0.95)',
|
||||||
|
titleColor: '#ffffff',
|
||||||
|
bodyColor: '#D1D5DB',
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 10,
|
||||||
|
displayColors: false,
|
||||||
|
bodyFont: {
|
||||||
|
size: 13,
|
||||||
|
},
|
||||||
|
titleFont: {
|
||||||
|
size: 12,
|
||||||
|
weight: '600',
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return 'Views: ' + new Intl.NumberFormat().format(context.parsed.y);
|
||||||
|
},
|
||||||
|
title: function(context) {
|
||||||
|
return context[0].label;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
interaction: {
|
interaction: {
|
||||||
intersect: false,
|
intersect: false,
|
||||||
|
mode: 'index',
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
display: true,
|
display: true,
|
||||||
|
grid: {
|
||||||
|
color: colors.gridColor,
|
||||||
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.textColor,
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
maxTicksLimit: 14,
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
display: true
|
display: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: true,
|
display: true,
|
||||||
title: {
|
beginAtZero: true,
|
||||||
display: true,
|
grid: {
|
||||||
text: 'Views'
|
color: colors.gridColor,
|
||||||
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.textColor,
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
callback: function(value) {
|
||||||
|
if (value >= 1000000) return (value / 1000000).toFixed(1) + 'M';
|
||||||
|
if (value >= 1000) return (value / 1000).toFixed(1) + 'K';
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
},
|
},
|
||||||
suggestedMin: 0,
|
|
||||||
suggestedMax: 40000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const monthlyViewChart = new Chart(
|
hideError();
|
||||||
document.getElementById('monthlyChart'),
|
monthlyViewChart = new Chart(canvas, config);
|
||||||
config
|
hideSkeleton();
|
||||||
);
|
}
|
||||||
}).catch(function (error) {
|
|
||||||
console.log(error);
|
/**
|
||||||
|
* Fetch chart data from the API
|
||||||
|
*/
|
||||||
|
async function fetchChartData() {
|
||||||
|
showSkeleton();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get('/v1/monthly-views');
|
||||||
|
|
||||||
|
if (response.status !== 200 || !response.data || response.data.length === 0) {
|
||||||
|
throw new Error('Invalid or empty response');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChart(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chart data:', error);
|
||||||
|
showError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry loading the chart (called from error state button)
|
||||||
|
*/
|
||||||
|
window.retryChart = function() {
|
||||||
|
if (monthlyViewChart) {
|
||||||
|
monthlyViewChart.destroy();
|
||||||
|
monthlyViewChart = null;
|
||||||
|
}
|
||||||
|
fetchChartData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for theme changes to re-render chart
|
||||||
|
const themeObserver = new MutationObserver(() => {
|
||||||
|
if (monthlyViewChart) {
|
||||||
|
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
|
||||||
|
date: monthlyViewChart.data.labels[index],
|
||||||
|
count: value,
|
||||||
|
}));
|
||||||
|
renderChart(data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Observe theme class changes on html element
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
if (htmlElement) {
|
||||||
|
themeObserver.observe(htmlElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the fetch when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Small delay to ensure the page has rendered and skeleton is visible
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchChartData();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window resize for theme changes (system preference)
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
if (!('theme' in localStorage) && monthlyViewChart) {
|
||||||
|
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
|
||||||
|
date: monthlyViewChart.data.labels[index],
|
||||||
|
count: value,
|
||||||
|
}));
|
||||||
|
renderChart(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -7,14 +7,19 @@ function darkModeListener() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("input[type='checkbox']#toogleTheme").addEventListener("click", darkModeListener);
|
document.querySelector("input[type='checkbox']#toogleTheme")?.addEventListener("click", darkModeListener);
|
||||||
|
|
||||||
if(localStorage.theme) {
|
if(localStorage.theme) {
|
||||||
if (localStorage.theme == 'light') {
|
if (localStorage.theme == 'light') {
|
||||||
if (document.querySelector("html").classList.contains('dark')) {
|
if (document.querySelector("html").classList.contains('dark')) {
|
||||||
document.querySelector("html").classList.toggle("dark");
|
document.querySelector("html").classList.toggle("dark");
|
||||||
}
|
}
|
||||||
document.getElementById("toogleTheme").checked = true;
|
|
||||||
|
const toggleThemeButton = document.getElementById("toogleTheme");
|
||||||
|
if (toggleThemeButton) {
|
||||||
|
toggleThemeButton.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default Dark Theme
|
// Default Dark Theme
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
<div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-900">
|
<div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-900">
|
||||||
@include('layouts.navigation')
|
@include('layouts.navigation')
|
||||||
@include('user.partials.background')
|
@include('partials.background')
|
||||||
<div class="mt-[65px]">
|
<div class="mt-[65px]">
|
||||||
@include('admin.partials.sidenav')
|
@include('admin.partials.sidenav')
|
||||||
<div class="pl-64">
|
<div class="pl-64">
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
<div data-te-modal-init class="fixed left-0 top-0 z-[1055] hidden h-full w-full overflow-y-auto overflow-x-hidden outline-none" id="modalEditEpisode" tabindex="-1" aria-labelledby="Upload" aria-modal="true" role="dialog">
|
<div
|
||||||
<div data-te-modal-dialog-ref class="pointer-events-none relative flex min-h-[calc(100%-1rem)] w-auto translate-y-[-50px] items-center opacity-0 transition-all duration-300 ease-in-out min-[576px]:mx-auto min-[576px]:mt-7 min-[576px]:min-h-[calc(100%-3.5rem)] min-[576px]:max-w-[95%] md:min-[576px]:max-w-[90%] lg:min-[576px]:max-w-[80%] xl:min-[576px]:max-w-[70%] 2xl:min-[576px]:max-w-[50%]">
|
data-te-modal-init
|
||||||
<div class="flex relative flex-col w-full text-current bg-clip-padding bg-white rounded-md border-none shadow-lg outline-none pointer-events-auto dark:bg-neutral-800">
|
id="modalEditEpisode"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog"
|
||||||
|
class="fixed inset-0 z-[1055] hidden overflow-y-auto bg-black/60 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div data-te-modal-dialog-ref class="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div class="relative w-full max-w-7xl overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900">
|
||||||
<x-modal-header :title="__('Edit Episode')"/>
|
<x-modal-header :title="__('Edit Episode')"/>
|
||||||
|
|
||||||
<!--Modal body-->
|
<!--Modal body-->
|
||||||
<div class="relative p-4 pt-0">
|
<div class="relative p-4 pt-0">
|
||||||
<form method="POST" action="{{ route('admin.edit') }}" enctype="multipart/form-data">
|
<form method="POST" action="{{ route('admin.episode.edit') }}" enctype="multipart/form-data">
|
||||||
@csrf
|
@csrf
|
||||||
<div class="grid grid-cols-3">
|
<div class="flex flex-col gap-2 p-2">
|
||||||
|
<div>
|
||||||
|
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title">Title:</label>
|
||||||
|
<x-text-input id="title" value="{{ $episode->title }}" class="block w-full" type="text" name="title" required autofocus/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title_jpn">Title JPN:</label>
|
||||||
|
<x-text-input id="title_jpn" value="{{ $episode->title_jpn }}" class="block w-full" type="text" name="title_jpn" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 p-2">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="row-span-2 p-0">
|
<div class="row-span-2 p-0">
|
||||||
@@ -16,6 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||||
<div class="grid grid-rows-2">
|
<div class="grid grid-rows-2">
|
||||||
<!-- Studio -->
|
<!-- Studio -->
|
||||||
<div class="p-2 pt-0">
|
<div class="p-2 pt-0">
|
||||||
@@ -47,13 +67,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||||
<!-- Stream URL -->
|
<!-- Stream URL -->
|
||||||
<div class="p-2 pt-0">
|
<div class="p-2 pt-0">
|
||||||
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="baseurl">Stream:</label>
|
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="baseurl">Stream:</label>
|
||||||
<x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" value="{{ $episode->url }}" required />
|
<x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" value="{{ $episode->url }}" required />
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<input name="episode_id" id="episode_id" type="hidden" value="{{ $episode->id }}" />
|
<input name="episode_id" id="episode_id" type="hidden" value="{{ $episode->id }}" />
|
||||||
|
|
||||||
@@ -62,6 +85,7 @@
|
|||||||
<textarea rows="4" cols="50" id="description" name="description" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600" required>{{ $episode->description }}</textarea>
|
<textarea rows="4" cols="50" id="description" name="description" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600" required>{{ $episode->description }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||||
<!-- Episodes -->
|
<!-- Episodes -->
|
||||||
<div class="grid grid-cols-2">
|
<div class="grid grid-cols-2">
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
@@ -95,8 +119,10 @@
|
|||||||
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="downloadUHDi1">Download 4k Interpolated:</label>
|
<label class="w-full leading-tight text-gray-800 dark:text-gray-200" for="downloadUHDi1">Download 4k Interpolated:</label>
|
||||||
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" value="{{ $episode->getDownloadByType('UHDi')->url ?? '' }}" />
|
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" value="{{ $episode->getDownloadByType('UHDi')->url ?? '' }}" />
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="flex flex-wrap flex-shrink-0 justify-end items-center p-4 rounded-b-md">
|
<div class="sticky bottom-0 flex items-center justify-end gap-3 border-t border-neutral-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90">
|
||||||
|
@if(auth()->user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||||
<div class="inline-block mr-2">
|
<div class="inline-block mr-2">
|
||||||
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
type="checkbox" value="true" id="v2" name="v2" />
|
type="checkbox" value="true" id="v2" name="v2" />
|
||||||
@@ -111,10 +137,17 @@
|
|||||||
DMCA Takedown
|
DMCA Takedown
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="inline-block px-6 pt-2.5 pb-2 text-xs font-medium leading-normal uppercase rounded transition duration-150 ease-in-out bg-primary-100 text-primary-700 hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
|
@endif
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-te-modal-dismiss
|
||||||
|
class="rounded-xl border border-neutral-300 px-5 py-2.5 text-sm font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="inline-block px-6 pt-2.5 pb-2 ml-1 text-xs font-medium leading-normal text-white uppercase bg-rose-600 rounded transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light">
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-te-ripple-init
|
||||||
|
class="rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-rose-600/20 transition hover:bg-rose-700">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
@auth
|
|
||||||
@if(Auth::user()->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
|
||||||
<div class="relative p-5 bg-white dark:bg-neutral-700/40 rounded-lg overflow-hidden z-10">
|
|
||||||
<div class="float-left">
|
|
||||||
<a data-te-toggle="modal" data-te-target="#modalUploadEpisode" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
|
|
||||||
<i class="fa-solid fa-plus pr-[6px]"></i> Add Episode
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="float-right">
|
|
||||||
<a data-te-toggle="modal" data-te-target="#modalAddSubtitles" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
|
|
||||||
<i class="fa-solid fa-plus pr-[6px]"></i> Add Subtitles
|
|
||||||
</a>
|
|
||||||
<a data-te-toggle="modal" data-te-target="#modalEditEpisode" class="text-xl text-gray-800 dark:text-gray-200 leading-tight cursor-pointer whitespace-nowrap">
|
|
||||||
<i class="fa-solid fa-pen pr-[6px]"></i> Edit Episode
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endauth
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<x-guest-layout>
|
<x-guest-layout>
|
||||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<x-guest-layout>
|
<x-guest-layout>
|
||||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<div class="w-full sm:max-w-md mt-6">
|
<div class="w-full sm:max-w-md mt-6">
|
||||||
<ul class="flex list-none flex-row flex-wrap border-b-0 pl-0 relative " role="tablist" data-te-nav-ref>
|
<ul class="flex list-none flex-row flex-wrap border-b-0 pl-0 relative " role="tablist" data-te-nav-ref>
|
||||||
<li role="presentation" class="flex-auto text-center">
|
<li role="presentation" class="flex-auto text-center">
|
||||||
<a href="#tabs-login" class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
<a href="#tabs-login" class="rounded-l-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-800 backdrop-blur-sm dark:hover:bg-neutral-900 dark:data-[te-nav-active]:text-white"
|
||||||
data-te-toggle="pill" data-te-target="#tabs-login" data-te-nav-active role="tab" aria-controls="tabs-login" aria-selected="true">
|
data-te-toggle="pill" data-te-target="#tabs-login" data-te-nav-active role="tab" aria-controls="tabs-login" aria-selected="true">
|
||||||
{{ __('Login') }}
|
{{ __('Login') }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li role="presentation" class="flex-auto text-center">
|
<li role="presentation" class="flex-auto text-center">
|
||||||
<a href="#tabs-register" class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-950/50 backdrop-blur-sm dark:hover:bg-neutral-800 dark:data-[te-nav-active]:text-white"
|
<a href="#tabs-register" class="rounded-r-lg my-2 block border-x-0 border-b-2 border-t-0 border-transparent px-7 pb-3.5 pt-4 text-xs font-medium uppercase leading-tight text-neutral-500 hover:isolate hover:border-transparent hover:bg-neutral-50 focus:isolate focus:border-transparent data-[te-nav-active]:border-rose-600 data-[te-nav-active]:text-black dark:text-neutral-400 bg-white/50 dark:bg-neutral-800 backdrop-blur-sm dark:hover:bg-neutral-900 dark:data-[te-nav-active]:text-white"
|
||||||
data-te-toggle="pill" data-te-target="#tabs-register" role="tab" aria-controls="tabs-register" aria-selected="false">
|
data-te-toggle="pill" data-te-target="#tabs-register" role="tab" aria-controls="tabs-register" aria-selected="false">
|
||||||
{{ __('Register') }}
|
{{ __('Register') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<!-- Login -->
|
<!-- Login -->
|
||||||
<div class="w-full sm:max-w-md hidden opacity-100 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-login" role="tabpanel" aria-labelledby="tabs-login" data-te-tab-active>
|
<div class="w-full sm:max-w-md hidden opacity-100 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-login" role="tabpanel" aria-labelledby="tabs-login" data-te-tab-active>
|
||||||
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
<div class="px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||||
<div class="w-full text-center text-white mb-3">
|
<div class="w-full text-center text-white mb-3">
|
||||||
<a href="{{ route('discord.login') }}">
|
<a href="{{ route('discord.login') }}">
|
||||||
<div
|
<div
|
||||||
@@ -28,10 +28,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@if (session('error'))
|
||||||
|
<div class="mb-4 rounded-md bg-red-200 p-4 border border-red-200">
|
||||||
|
<div class="text-sm text-red-700">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Or -->
|
<!-- Or -->
|
||||||
<div class="grid grid-cols-3">
|
<div class="grid grid-cols-3">
|
||||||
<hr class="self-center border-neutral-600">
|
<hr class="self-center border-neutral-600">
|
||||||
<p>OR</p>
|
<p class="text-neutral-800 dark:text-neutral-400">OR</p>
|
||||||
|
<hr class="self-center border-neutral-600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Passkey Login -->
|
||||||
|
<div class="w-full text-center text-white mb-3">
|
||||||
|
<x-authenticate-passkey />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 pt-3">
|
||||||
|
<hr class="self-center border-neutral-600">
|
||||||
|
<p class="text-neutral-800 dark:text-neutral-400">OR</p>
|
||||||
<hr class="self-center border-neutral-600">
|
<hr class="self-center border-neutral-600">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,8 +88,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block">
|
<div class="block pt-3 w-3/4 mx-auto">
|
||||||
<altcha-widget id="captcha" floating challengeurl="/altcha-challenge"></altcha-widget>
|
<altcha-widget id="captcha" theme="cupcake" challenge="/altcha-challenge"></altcha-widget>
|
||||||
<x-input-error :messages="$errors->get('altcha')" class="mt-2" />
|
<x-input-error :messages="$errors->get('altcha')" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,7 +110,7 @@
|
|||||||
|
|
||||||
<!-- Register -->
|
<!-- Register -->
|
||||||
<div class="w-full sm:max-w-md hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-register" role="tabpanel" aria-labelledby="tabs-register">
|
<div class="w-full sm:max-w-md hidden opacity-0 transition-opacity duration-150 ease-linear data-[te-tab-active]:block" id="tabs-register" role="tabpanel" aria-labelledby="tabs-register">
|
||||||
<div class="px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
<div class="px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||||
<form method="POST" action="{{ route('register') }}">
|
<form method="POST" action="{{ route('register') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
@@ -132,8 +151,8 @@
|
|||||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block">
|
<div class="block pt-3 w-3/4 mx-auto">
|
||||||
<altcha-widget id="captcha" floating challengeurl="/altcha-challenge"></altcha-widget>
|
<altcha-widget id="captcha" theme="cupcake" challenge="/altcha-challenge"></altcha-widget>
|
||||||
<x-input-error :messages="$errors->get('altcha')" class="mt-2" />
|
<x-input-error :messages="$errors->get('altcha')" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<x-guest-layout>
|
<x-guest-layout>
|
||||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||||
<form method="POST" action="{{ route('password.store') }}">
|
<form method="POST" action="{{ route('password.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<x-guest-layout>
|
<x-guest-layout>
|
||||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-950/50 shadow-md overflow-hidden sm:rounded-lg">
|
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-neutral-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
|
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@props([
|
||||||
|
'episode',
|
||||||
|
'view',
|
||||||
|
'displayjapanese' => false
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$title = $displayjapanese
|
||||||
|
? "{$episode->title_jpn} ({$episode->title}) - {$episode->episode}"
|
||||||
|
: "{$episode->title} - {$episode->episode}";
|
||||||
|
|
||||||
|
$isLoggedIn = auth()->check();
|
||||||
|
|
||||||
|
$isWatched = $isLoggedIn
|
||||||
|
? $episode->userWatched(auth()->id())
|
||||||
|
: false;
|
||||||
|
|
||||||
|
$problematic = cache()->rememberForever(
|
||||||
|
"episodeProblematic{$episode->id}",
|
||||||
|
fn () => $episode->getProblematicTags()
|
||||||
|
);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="group w-full p-1">
|
||||||
|
<a
|
||||||
|
href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
|
||||||
|
class="block overflow-hidden rounded-2xl border border-neutral-200 bg-white transition-all duration-300 hover:-translate-y-1 hover:border-neutral-400 hover:shadow-xl dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700"
|
||||||
|
>
|
||||||
|
<div class="relative overflow-hidden">
|
||||||
|
|
||||||
|
{{-- Thumbnail / Cover --}}
|
||||||
|
@if ($view === 'poster')
|
||||||
|
<img
|
||||||
|
src="{{ $episode->cover_url }}"
|
||||||
|
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||||
|
loading="lazy"
|
||||||
|
width="400"
|
||||||
|
class="aspect-[11/16] w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||||
|
>
|
||||||
|
@elseif ($view === 'thumbnail')
|
||||||
|
@php
|
||||||
|
$galleryImages = $episode->gallery
|
||||||
|
->pluck('thumbnail_url')
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
@endphp
|
||||||
|
<img
|
||||||
|
src="{{ $galleryImages->first() }}"
|
||||||
|
alt="{{ $episode->title }} - {{ $episode->episode }}"
|
||||||
|
loading="lazy"
|
||||||
|
width="1000"
|
||||||
|
data-gallery='@json($galleryImages)'
|
||||||
|
class="preview-gallery aspect-video w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||||
|
>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Dark Overlay --}}
|
||||||
|
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent"></div>
|
||||||
|
|
||||||
|
{{-- Top Meta --}}
|
||||||
|
<div class="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between p-3">
|
||||||
|
|
||||||
|
{{-- Problematic Tags --}}
|
||||||
|
@if (!empty($problematic))
|
||||||
|
<div class="rounded-full bg-red-700/40 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-white ring-1 ring-red-700/70">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation mr-1"></i>
|
||||||
|
{{ $problematic }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Resolution --}}
|
||||||
|
<div class="ml-auto rounded-full bg-black/70 px-2 py-1 text-[11px] font-semibold tracking-wide text-white ring-1 ring-white/10">
|
||||||
|
{{ $episode->getResolution() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Bottom Content --}}
|
||||||
|
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 p-4">
|
||||||
|
|
||||||
|
{{-- Title --}}
|
||||||
|
<h3 class=" text-sm font-semibold leading-snug text-white md:text-base">
|
||||||
|
{{ $title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{{-- Bottom Row --}}
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-3">
|
||||||
|
|
||||||
|
{{-- Stats --}}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm font-bold text-neutral-200">
|
||||||
|
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<i class="fa-regular fa-eye text-neutral-200 font-bold"></i>
|
||||||
|
{{ $episode->viewCountFormatted() }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<i class="fa-regular fa-heart text-neutral-200 font-bold"></i>
|
||||||
|
{{ $episode->likeCount() }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<i class="fa-regular fa-comment text-neutral-200 font-bold"></i>
|
||||||
|
{{ $episode->commentCount() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Watched Status (logged in users only) --}}
|
||||||
|
@auth
|
||||||
|
@if ($isWatched)
|
||||||
|
<div class="shrink-0 rounded-full bg-emerald-800/40 px-2.5 py-1 text-xs font-semibold text-emerald-300 ring-1 ring-emerald-500/30">
|
||||||
|
@if ($view === 'thumbnail')
|
||||||
|
<i class="fa-solid fa-eye mr-1"></i> Watched
|
||||||
|
@else
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="shrink-0 rounded-full bg-rose-800/40 px-2.5 py-1 text-xs font-semibold text-rose-300 ring-1 ring-rose-500/30">
|
||||||
|
<i class="fa-solid fa-eye-slash"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user