Compare commits

8 Commits

Author SHA1 Message Date
w33b f7c37df755 Show subtitles toggle & menu 2026-07-29 22:12:51 +02:00
w33b 6a9d3b25bf Add styling to plyr and fix switcher not hiding 2026-07-28 21:54:50 +02:00
w33b 75b98de746 Add plyr again as fallback 2026-07-28 21:41:30 +02:00
w33b eaf48276f0 Update player setting design 2026-07-28 20:33:20 +02:00
w33b 2e3918def4 Custom video player 2026-07-26 22:26:37 +02:00
w33b 7553b9f895 Update stats page design 2026-07-25 14:18:50 +02:00
w33b 5af1c3c447 Update profile pages design 2026-07-25 13:54:21 +02:00
w33b 2b1a967065 Update nav live search design 2026-07-25 12:38:00 +02:00
38 changed files with 6135 additions and 1411 deletions
+96
View File
@@ -3,11 +3,13 @@
namespace App\Helpers;
use App\Models\Comment;
use App\Models\Downloads;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\PopularDaily;
use App\Models\PopularMonthly;
use App\Models\PopularWeekly;
use App\Models\User;
use Conner\Tagging\Model\Tag;
use Illuminate\Support\Facades\Cache;
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)
{
$guestString = $guest ? 'guest' : 'authed';
+12
View File
@@ -103,6 +103,18 @@ class HomeController extends Controller
'viewCount' => CacheHelper::getTotalViewCount(),
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
'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(),
]);
}
@@ -20,10 +20,17 @@ class NotificationController extends Controller
}
/**
* Delete Notifcation
* Delete a notification or clear all.
*/
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([
'id' => 'required|exists:notifications,id',
]);
+1
View File
@@ -20,6 +20,7 @@ class NavLiveSearch extends Component
if ($this->navSearch != '') {
$episodes = Episode::search($this->navSearch)
->when(Auth::guest(), fn ($query) => $query->whereNotIn('tags', ['Loli', 'Shota']))
->query(fn ($query) => $query->with(['gallery', 'studio']))
->take(7)
->get();
}
+373 -454
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@
"chart.js": "^4.5.0",
"dashjs": "^5.0.0",
"hammerjs": "^2.0.8",
"plyr": "^3.7.8",
"plyr": "^3.8.4",
"tw-elements": "^1.1.0",
"vidstack": "^1.12.13"
}
+33 -66
View File
@@ -1,4 +1,5 @@
@import "@fortawesome/fontawesome-free/css/all.css";
@import './player.css';
@tailwind base;
@tailwind components;
@@ -8,47 +9,6 @@
--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 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%;
}
/* Player Ambient */
.decoy {
position: absolute;
@@ -68,31 +28,6 @@ input:checked~.dot {
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 */
.hover\:glow:hover {
filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5));
@@ -146,3 +81,35 @@ input:checked~.dot {
:root {
color-scheme: light dark;
}
/* Stats Page - Shimmer Skeleton Loader */
.shimmer-overlay {
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;
}
.dark .shimmer-overlay {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.05) 50%,
transparent 100%
);
background-size: 200% 100%;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
+946
View File
@@ -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;
}
+436
View File
@@ -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;
}
+182 -378
View File
@@ -1,30 +1,17 @@
// Plyr Player
import Plyr from 'plyr';
import 'plyr/dist/plyr.css';
// Vidstack Player
// HStream Custom Video Player
import 'vidstack/player/styles/default/theme.css';
import 'vidstack/player/styles/default/layouts/video.css';
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
// Dash Support
import * as dashjs from 'dashjs';
// Subtitle Support
import SubtitlesOctopus from '@jellyfin/libass-wasm';
// Custom JS
import { initMobileWidescreen } from './player-mobile';
import { mobileDoubleClick } from './player-mobile'
import { HStreamPlayer } from './player/player-core';
import { initMobileWidescreen, initMobileDoubleTap, isMobile } from './player/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 { addVideoTracks, addSubtitleTracks } from './player/player-data';
import { isIOS } from './detect-ios';
import { startEngagementTracking, stopEngagementTracking } from './player-engagement';
import { renderHeatmap } from './player-heatmap';
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
import { renderHeatmap } from './player/player-heatmap';
// Variables
var player = null;
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
var dashSupported = dashjs.supportsMediaSource();
@@ -35,23 +22,23 @@ var captions = true;
var lastTime = 0.0;
var streamServer = '';
var streamServers = [];
var fallbackServers = [];
var streamServerIndex = 0;
var streamServerCount = 0;
var ambientMode = true;
var serverFallback = false;
var saveInterval;
var watchTracked = false;
var subtitleInstance = null;
// Track that the user watched at least 10 seconds of the video
function trackWatchTime() {
if (watchTracked) return;
var video = document.getElementsByTagName('video')[0];
if (video && video.currentTime >= 10) {
var videoEl = document.getElementsByTagName('video')[0];
if (videoEl && videoEl.currentTime >= 10) {
watchTracked = true;
var episodeId = document.getElementById('e_id').value;
window.axios.post('/watched/track', {
episode_id: episodeId
}).then(function (response) {
}).then(function () {
console.log('Watch tracked for episode ' + episodeId);
}).catch(function (error) {
console.error('Failed to track watch: ' + error);
@@ -59,119 +46,24 @@ function trackWatchTime() {
}
}
var subtitleInstance = null;
var controls = [
'play-large', // The large play button in the center
'play', // Play/pause playback
'progress', // The progress bar and scrubber for playback and buffering
'current-time', // The current time of playback
'duration', // The full duration of the media
'mute', // Toggle mute
'volume', // Volume control
'captions', // Toggle captions
'settings', // Settings menu
'fullscreen', // Toggle fullscreen
];
// Load Volume from LocalStorage
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);
}
// Load Captions from LocalStorage
if (localStorage.hstreamCaptions) {
captions = (localStorage.getItem('hstreamCaptions') == 'true');
captions = (localStorage.getItem('hstreamCaptions') === 'true');
console.log('Loaded Captions Status from Local Storage: ' + captions);
}
// Load Muted from LocalStorage
if (localStorage.hstreamCaptions) {
muted = (localStorage.getItem('hstreamMuted') == 'true');
if (localStorage.hstreamMuted) {
muted = (localStorage.getItem('hstreamMuted') === 'true');
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) {
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);
stopEngagementTracking();
player.destroy();
}
initPlayer();
document.getElementById('av1-unsupported').classList.remove('hidden');
}
function initSubtitles(lang) {
@@ -179,32 +71,28 @@ function initSubtitles(lang) {
return;
}
// Dispose old instance
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
if (subtitleInstance !== null && subtitleInstance instanceof SubtitlesOctopus) {
subtitleInstance.dispose();
}
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
var newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
if (lang != 'en') {
if (lang !== 'en') {
newSubUrl += 'autotrans/' + lang + '.ass';
}
else {
newSubUrl += 'eng.ass'
} else {
newSubUrl += 'eng.ass';
}
let subFont = '/fonts/Figtree-ExtraBold.woff2';
// Hindi font
if (lang == 'hi') {
var subFont = '/fonts/Figtree-ExtraBold.woff2';
if (lang === 'hi') {
subFont = '/fonts/Hind-SemiBold.ttf';
}
// Subtitles
var options = {
video: document.getElementsByTagName('video')[0], // HTML5 video element
subUrl: newSubUrl, // Link to subtitles
workerUrl: '/build/js/subtitles-octopus-worker.js', // Link to WebAssembly-based file "libassjs-worker.js"
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js', // Link to non-WebAssembly worker
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',
};
@@ -212,214 +100,95 @@ function initSubtitles(lang) {
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 }
});
// 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;
function initPlayerQualityChange(data) {
if (dashSupported && !apiResponse.legacy) {
player.on('qualitychange', () => {
initDash(data, player);
player.on('qualitychange', function () {
initDash(data);
});
initDash(data, player);
initDash(data);
}
}
// Ambient Mode
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
setCanvasDimension(canvas, video);
paintStaticVideo(ctx, video);
function initDash(data) {
var videoEl = document.querySelector('video');
var quality = player.quality;
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();
}
// 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.")
}
// Start engagement heatmap tracking
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); // 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);
data.forEach(function (el) {
if (el.mode === 'mpd' && el.size === quality) {
var dash = dashjs.MediaPlayer().create();
dash.initialize(videoEl, el.src, true);
window.dash = dash;
player.dash = dash;
}
});
}
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');
});
function initPlayer() {
var videoEl = document.querySelector('#player');
var container = videoEl.parentElement;
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');
});
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
var subtitleTracks = addSubtitleTracks(streamServer, apiResponse);
var vttThumbsUrl = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
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', () => {
player = new HStreamPlayer({
container: container,
video: videoEl,
apiResponse: apiResponse,
streamServer: streamServer,
streamServers: streamServers,
fallbackServers: fallbackServers,
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();
});
// Track watch time after 10 seconds of playback
player.on('timeupdate', () => {
},
onTimeUpdate: function () {
trackWatchTime();
});
player.on('languagechange', (event) => {
let lang = event.detail.plyr.captions.language;
console.log('Subtitle Event ' + lang);
},
onQualityChange: function (size) {
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);
});
function playerPlayTemp() {
clickedPlay = true;
if (player) {
player.setSubtitleInstance(subtitleInstance);
}
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
play.addEventListener('click', playerPlayTemp)
);
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);
// Load engagement heatmap once video duration is known
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);
});
}
});
// 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];
},
onServerChange: function (index) {
streamServerIndex = index;
var allServers = streamServers.concat(fallbackServers);
streamServer = allServers[streamServerIndex];
console.log('Selected Server: ' + streamServer);
if (player) {
@@ -428,21 +197,57 @@ function initPlayer() {
player.destroy();
}
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 () {
lastTime = player.currentTime;
console.log("Last Player Position: " + lastTime);
}, 10000);
}
async function initVidstackPlayer() {
const videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
const videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
const videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
var videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
var videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
var videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
player = await VidstackPlayer.create({
target: '#player',
@@ -464,57 +269,56 @@ async function initVidstackPlayer() {
]
});
// Ambient Mode
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
setCanvasDimension(canvas, video);
paintStaticVideo(ctx, video);
window.player = player;
player.addEventListener('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
}
})();
});
// Track watch time after 10 seconds of playback
player.addEventListener('time-update', () => {
player.addEventListener('time-update', function () {
trackWatchTime();
});
}
// Get Data from API
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;
window.setPlayerPreference = function(pref) {
localStorage.setItem('hstreamPlayerPreference', pref);
window.location.reload();
};
if (serverFallback) {
streamServers = apiResponse.asia_stream_domains;
const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
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;
streamServerIndex = Math.floor(Math.random() * streamServerCount);
streamServer = streamServers[streamServerIndex];
streamServerCount = streamServers.length + fallbackServers.length;
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
if (!isIOS()) {
initPlayer();
}
else {
console.log("Detected Apple Shit. Using different player.")
} else {
console.log('Detected Apple device. Using Vidstack fallback player.');
initVidstackPlayer();
}
}
}).catch(function (error) {
var alert = document.getElementById("player-alert");
}).catch(function (error) {
var alert = document.getElementById('player-alert');
if (alert) {
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
+94
View File
@@ -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;
}
+72
View File
@@ -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);
}
+214
View File
@@ -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;
}
}
+151
View File
@@ -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;
}
+218
View File
@@ -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);
}
}
}
+274 -34
View File
@@ -1,73 +1,313 @@
import Chart from 'chart.js/auto';
// Theming
if (localStorage.theme !== 'light') {
Chart.defaults.color = "#ADBABD";
Chart.defaults.borderColor = "rgba(255,255,255,0.1)";
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)";
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)";
/**
* Theme-aware chart defaults
*/
function getChartColors() {
const isDark = localStorage.theme !== 'light' &&
(!('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) {
if (response.status != 200) {
return;
/**
* Show the skeleton loader
*/
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 = {
labels: response.data.map((entry) => { return entry.date }),
if (canvas) {
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: [{
label: 'Views',
fill: false,
backgroundColor: 'rgba(190, 18, 60, 0.3)',
borderColor: 'rgba(190, 18, 60, 1.0)',
fill: true,
backgroundColor: gradient,
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',
data: response.data.map((entry) => { return entry.count }),
tension: 0.4,
data: data.map((entry) => entry.count),
}]
}
};
const config = {
type: 'line',
data: data,
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 1200,
easing: 'easeOutQuart',
},
plugins: {
title: {
display: true,
text: 'Views the last 28 days',
font: {
size: 18
display: false,
},
legend: {
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: {
intersect: false,
mode: 'index',
},
scales: {
x: {
display: true,
grid: {
color: colors.gridColor,
drawBorder: false,
},
ticks: {
color: colors.textColor,
font: {
size: 11,
},
maxTicksLimit: 14,
maxRotation: 0,
},
title: {
display: true
display: false,
}
},
y: {
display: true,
title: {
display: true,
text: 'Views'
beginAtZero: true,
grid: {
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(
document.getElementById('monthlyChart'),
config
);
}).catch(function (error) {
console.log(error);
hideError();
monthlyViewChart = new Chart(canvas, config);
hideSkeleton();
}
/**
* 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);
}
});
@@ -0,0 +1,17 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-16">
<div class="flex flex-col md:flex-row gap-6 md:gap-8">
{{-- Sidebar --}}
<div class="w-full md:w-64 xl:w-72 shrink-0">
@include('profile.partials.sidebar')
</div>
{{-- Content --}}
<div class="flex-1 min-w-0">
{{ $slot }}
</div>
</div>
</div>
</x-app-layout>
+352 -65
View File
@@ -1,78 +1,365 @@
<x-app-layout>
<div class="container mx-auto px-4 py-12 md:py-24">
<section class="text-center mb-16">
<!-- Logo -->
<div class="flex justify-center mb-8">
<div class="container mx-auto px-4 py-8 md:py-16 max-w-7xl">
{{-- Header Section --}}
<section class="text-center mb-10 md:mb-14">
<div class="flex justify-center mb-6">
<img
src="/images/cropped-HS-1-270x270.webp"
alt="hstream.moe Logo"
class="max-w-[150px] w-full h-auto rounded-lg"
class="max-w-[120px] w-full h-auto rounded-xl shadow-lg hover:scale-105 transition-transform duration-300"
/>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
<!-- View Count Card -->
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-eye text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ number_format($viewCount) }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
total views
</h5>
</div>
<!-- Episode Count Card -->
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-video text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ $episodeCount }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
episodes on this site
</h5>
</div>
<!-- Hentai Count Card -->
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-list text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ $hentaiCount }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
hentais on this site
</h5>
</div>
<!-- Watch Time Card -->
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex justify-center mb-4">
<i class="fa-solid fa-clock text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{{ number_format($viewCount * 6) }}
</div>
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
estimated minutes of watch time
</h5>
</div>
</div>
<!-- Chart Container -->
<div class="mt-12 mx-auto max-w-4xl">
<div class="bg-gray-50 dark:bg-neutral-950 rounded-xl p-4 md:p-6 shadow-inner hidden sm:block">
<canvas id="monthlyChart" class="w-full h-64 md:h-80"></canvas>
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-2 tracking-tight">
Site Statistics
</h1>
<p class="text-gray-500 dark:text-neutral-400 text-sm md:text-base max-w-lg mx-auto">
A comprehensive overview of hstream.moe's content and community activity
</p>
<div class="mt-5 flex justify-center">
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Live data · Updated hourly
</div>
</div>
</section>
{{-- Primary Stats Grid --}}
<section class="mb-8">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
{{-- Total Views --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-sky-400/10 dark:bg-sky-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center">
<i class="fa-solid fa-eye text-sky-600 dark:text-sky-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $viewCount }}">
{{ number_format($viewCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Views</p>
</div>
</div>
{{-- Total Episodes --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-violet-400/10 dark:bg-violet-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-violet-100 dark:bg-violet-900/50 flex items-center justify-center">
<i class="fa-solid fa-video text-violet-600 dark:text-violet-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $episodeCount }}">
{{ number_format($episodeCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Episodes</p>
</div>
</div>
{{-- Total Series --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-rose-400/10 dark:bg-rose-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-rose-100 dark:bg-rose-900/50 flex items-center justify-center">
<i class="fa-solid fa-list text-rose-600 dark:text-rose-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $hentaiCount }}">
{{ number_format($hentaiCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Series</p>
</div>
</div>
{{-- Total Users --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="absolute top-0 right-0 w-24 h-24 bg-emerald-400/10 dark:bg-emerald-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
<i class="fa-solid fa-users text-emerald-600 dark:text-emerald-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $userCount }}">
{{ number_format($userCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Registered Users</p>
</div>
</div>
</div>
</section>
{{-- Secondary Stats Grid --}}
<section class="mb-8">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
{{-- Total Likes --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-red-100 dark:bg-red-900/50 flex items-center justify-center">
<i class="fa-solid fa-heart text-red-500 dark:text-red-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $likeCount }}">
{{ number_format($likeCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Likes</p>
</div>
</div>
{{-- Total Comments --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
<i class="fa-solid fa-comments text-amber-600 dark:text-amber-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $commentCount }}">
{{ number_format($commentCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Comments</p>
</div>
</div>
{{-- Total Downloads --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
<i class="fa-solid fa-download text-blue-600 dark:text-blue-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $downloadCount }}">
{{ number_format($downloadCount) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Downloads</p>
</div>
</div>
{{-- Avg Views Per Episode --}}
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
<div class="relative z-10">
<div class="flex items-center gap-2 mb-3">
<div class="w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/50 flex items-center justify-center">
<i class="fa-solid fa-chart-simple text-orange-600 dark:text-orange-400 text-sm"></i>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $avgViewsPerEpisode }}">
{{ number_format($avgViewsPerEpisode) }}
</div>
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Avg Views / Episode</p>
</div>
</div>
</div>
</section>
{{-- Tertiary Stats Row: Today, This Week, New Episodes, 4K Content --}}
<section class="mb-10">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
{{-- Today's Views --}}
<div class="relative overflow-hidden bg-gradient-to-br from-sky-50 to-sky-100/50 dark:from-sky-950/40 dark:to-sky-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-sky-200/50 dark:border-sky-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-sky-600 dark:text-sky-400 font-semibold uppercase tracking-wider mb-2">Today</p>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $todayViews }}">
{{ number_format($todayViews) }}
</div>
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">views so far</p>
</div>
</div>
{{-- This Week's Views with Trend --}}
<div class="relative overflow-hidden bg-gradient-to-br from-violet-50 to-violet-100/50 dark:from-violet-950/40 dark:to-violet-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-violet-200/50 dark:border-violet-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-violet-600 dark:text-violet-400 font-semibold uppercase tracking-wider mb-2">Views This Week</p>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $weeklyViews }}">
{{ number_format($weeklyViews) }}
</div>
<div class="flex items-center gap-1">
@php
$trend = $prevWeeklyViews > 0 ? round((($weeklyViews - $prevWeeklyViews) / $prevWeeklyViews) * 100) : 0;
@endphp
@if($trend > 0)
<i class="fa-solid fa-arrow-trend-up text-green-500 text-xs"></i>
<span class="text-xs text-green-600 dark:text-green-400 font-semibold">{{ $trend }}% vs last week</span>
@elseif($trend < 0)
<i class="fa-solid fa-arrow-trend-down text-red-500 text-xs"></i>
<span class="text-xs text-red-600 dark:text-red-400 font-semibold">{{ abs($trend) }}% vs last week</span>
@else
<span class="text-xs text-gray-500 dark:text-neutral-400 font-medium">same as last week</span>
@endif
</div>
</div>
</div>
{{-- New Episodes This Week --}}
<div class="relative overflow-hidden bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-950/40 dark:to-emerald-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-emerald-200/50 dark:border-emerald-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-emerald-600 dark:text-emerald-400 font-semibold uppercase tracking-wider mb-2">New This Week</p>
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $newEpisodesThisWeek }}">
{{ number_format($newEpisodesThisWeek) }}
</div>
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">episodes added</p>
</div>
</div>
{{-- 4K / 48fps Content --}}
<div class="relative overflow-hidden bg-gradient-to-br from-rose-50 to-rose-100/50 dark:from-rose-950/40 dark:to-rose-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-rose-200/50 dark:border-rose-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="relative z-10">
<p class="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wider mb-2">High Quality</p>
<div class="flex items-baseline gap-2 mb-1">
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodes4k }}">{{ number_format($episodes4k) }}</span>
<span class="text-sm text-gray-500 dark:text-neutral-400">4K</span>
<span class="text-gray-300 dark:text-neutral-700">/</span>
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodesUHD48 }}">{{ number_format($episodesUHD48) }}</span>
<span class="text-sm text-gray-500 dark:text-neutral-400">48fps</span>
</div>
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">4K & 48fps content</p>
</div>
</div>
</div>
</section>
{{-- Views Chart Section --}}
<section class="mb-10">
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-bold text-gray-900 dark:text-white">Views Over Time</h2>
<p class="text-xs text-gray-500 dark:text-neutral-400">Daily views for the past 28 days</p>
</div>
<div class="hidden sm:flex items-center gap-3 text-xs text-gray-500 dark:text-neutral-400">
<span class="flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
</span>
</div>
</div>
{{-- Chart wrapper with skeleton loader --}}
<div class="relative min-h-[300px] md:min-h-[350px]">
{{-- Skeleton Loader --}}
<div id="chart-skeleton" class="absolute inset-0 flex flex-col justify-end px-1 pb-6 z-10">
<div class="flex items-end justify-between gap-1 md:gap-2 h-full">
@for ($i = 0; $i < 28; $i++)
@php
$heights = [55, 40, 65, 35, 60, 45, 70, 50, 75, 38, 58, 42, 80, 48, 68, 33, 62, 44, 72, 52, 55, 40, 65, 58, 78, 46, 63, 50];
$h = $heights[$i % count($heights)];
@endphp
<div class="flex-1 flex flex-col items-center justify-end gap-1">
<div class="w-full rounded-md bg-gray-200 dark:bg-neutral-800 animate-pulse" style="height: {{ $h }}%; min-height: 8px;"></div>
</div>
@endfor
</div>
{{-- Shimmer overlay --}}
<div class="absolute inset-0 shimmer-overlay rounded-lg"></div>
</div>
{{-- Actual Chart Canvas --}}
<canvas id="monthlyChart" class="w-full h-[300px] md:h-[350px] relative z-0 opacity-0 transition-opacity duration-500"></canvas>
{{-- Error State --}}
<div id="chart-error" class="absolute inset-0 hidden flex-col items-center justify-center bg-white/90 dark:bg-neutral-900/90 rounded-lg z-20">
<div class="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-3">
<i class="fa-solid fa-triangle-exclamation text-red-500 text-xl"></i>
</div>
<p class="text-sm text-gray-600 dark:text-neutral-300 font-medium">Unable to load chart data</p>
<button onclick="retryChart()" class="mt-3 px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-700 rounded-lg transition-colors">
<i class="fa-solid fa-rotate mr-1"></i> Retry
</button>
</div>
</div>
{{-- Mobile legend --}}
<div class="flex sm:hidden items-center justify-center gap-3 mt-3 text-xs text-gray-500 dark:text-neutral-400">
<span class="flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
</span>
</div>
</div>
</section>
{{-- Bottom Section: Top Tags --}}
@if($topTags->isNotEmpty())
<section class="mb-8">
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
<h2 class="text-lg font-bold text-gray-900 dark:text-white mb-4">Most Popular Tags</h2>
<div class="flex flex-wrap gap-2">
@foreach($topTags as $tag)
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold
bg-gray-100 dark:bg-neutral-800 text-gray-700 dark:text-neutral-300
hover:bg-rose-100 dark:hover:bg-rose-900/40 hover:text-rose-700 dark:hover:text-rose-400
transition-colors duration-200 cursor-default">
#{{ $tag->name }}
<span class="text-[10px] text-gray-400 dark:text-neutral-500">{{ number_format($tag->count) }}</span>
</span>
@endforeach
</div>
</div>
</section>
@endif
{{-- Footer Note --}}
<div class="text-center text-xs text-gray-400 dark:text-neutral-600">
Statistics are cached and update periodically. Data shown may not reflect real-time changes.
</div>
</div>
{{-- Animated Counter Script --}}
<script>
document.addEventListener('DOMContentLoaded', () => {
const animateCounters = () => {
document.querySelectorAll('.stat-counter').forEach(counter => {
const target = parseInt(counter.dataset.target, 10);
if (isNaN(target)) return;
const duration = 1500;
const start = performance.now();
const startVal = 0;
const step = (currentTime) => {
const elapsed = currentTime - start;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(startVal + (target - startVal) * eased);
const formatted = new Intl.NumberFormat().format(current);
counter.textContent = formatted;
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
});
};
// Use IntersectionObserver to trigger counters when visible
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateCounters();
observer.disconnect();
}
});
}, { threshold: 0.3 });
const firstCounter = document.querySelector('.stat-counter');
if (firstCounter) {
observer.observe(firstCounter);
} else {
// Fallback
animateCounters();
}
});
</script>
@vite(['resources/js/stats.js'])
</x-app-layout>
@@ -39,11 +39,12 @@
aria-live="polite"
>
<div
class="max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-lg transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
<div class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-neutral-800">
class="w-full max-h-[70vh] overflow-auto rounded-2xl bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 shadow-xl transition-all transform hidden group-focus-within:block group-focus-within:translate-y-0">
{{-- Header --}}
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-neutral-800">
<div class="text-sm text-gray-700 dark:text-gray-200 font-medium">
@if($episodes->count())
{{ __('Search result for ') }} {{ $query ?: $navSearch }}
{{ __('Search result for ') }}<span class="text-rose-600 dark:text-rose-400 font-semibold">"{{ $query ?: $navSearch }}"</span>
@else
{{ __('No results') }}
@endif
@@ -60,46 +61,105 @@
</div>
</div>
{{-- content area: responsive grid --}}
<div class="p-4">
{{-- Results List --}}
<div>
@if($episodes->count())
<div class="grid grid-cols-1 gap-4 md:grid-cols-1 lg:grid-cols-2">
<ul class="divide-y divide-gray-100 dark:divide-neutral-800" role="listbox">
@foreach($episodes as $episode)
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}" class="group block rounded-xl overflow-hidden bg-neutral-50 dark:bg-neutral-950 border border-transparent hover:border-gray-200 dark:hover:border-neutral-700 shadow-sm hover:shadow-md transition">
<div class="relative aspect-video">
<li role="option" aria-selected="false">
<a href="{{ route('hentai.index', ['title' => $episode->slug ]) }}"
class="flex items-center gap-4 px-5 py-2 group/row hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
>
{{-- Left: Cover Image --}}
<div class="flex-shrink-0 relative">
<div class="w-16 h-[6rem] rounded-lg overflow-hidden ring-1 ring-gray-200/80 dark:ring-neutral-700/80 shadow-sm group-hover/row:ring-rose-300 dark:group-hover/row:ring-rose-700 group-hover/row:shadow-md transition-all duration-200">
<img
alt="{{ $episode->title }} - {{ $episode->episode }}"
loading="lazy"
class="object-cover w-full h-full"
src="{{ $episode->gallery->first()->thumbnail_url }}"
class="object-cover w-full h-full group-hover/row:scale-105 transition-transform duration-300"
src="{{ $episode->cover_url }}"
>
<span class="absolute right-0 top-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs font-semibold rounded-tr rounded-bl-xl px-2 py-1">{{ $episode->getResolution() }}</span>
<div class="absolute left-0 bottom-0 bg-white/90 dark:bg-neutral-800/80 dark:text-white text-xs rounded-tr-xl px-2 py-1 font-medium">
<i class="fa-regular fa-eye mr-1"></i> {{ $episode->viewCountFormatted() }}
<i class="fa-regular fa-heart ml-2"></i> {{ $episode->likeCount() }}
</div>
<span class="absolute -top-1.5 -right-1.5 bg-rose-600 text-white text-[0.6rem] font-bold leading-none px-1.5 py-0.5 rounded-md shadow-sm">
E{{ $episode->episode }}
</span>
</div>
{{-- Center: Title, Publisher, Tags --}}
<div class="flex-1 min-w-0">
{{-- Row 1: Title --}}
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate group-hover/row:text-rose-700 dark:group-hover/row:text-rose-400 transition-colors duration-150">
{{ $episode->title }}
</h3>
{{-- Row 2: Publisher --}}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
<i class="fa-regular fa-building mr-1 text-[0.65rem] opacity-70"></i>
{{ $episode->studio?->name ?? __('Unknown') }}
</p>
{{-- Row 3: Tags --}}
<div class="mt-1.5 flex items-center gap-1 flex-wrap">
@php
$tags = $episode->tagNames();
$visibleTags = array_slice($tags, 0, 3);
$remainingCount = count($tags) - 3;
@endphp
@foreach($visibleTags as $tag)
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-rose-100/80 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300 ring-1 ring-inset ring-rose-200/60 dark:ring-rose-800/60">
{{ $tag }}
</span>
@endforeach
@if($remainingCount > 0)
<span class="inline-flex items-center px-1.5 py-0 text-[0.6rem] font-medium leading-tight rounded-md bg-gray-100 text-gray-500 dark:bg-neutral-800 dark:text-gray-400">
+{{ $remainingCount }}
</span>
@endif
</div>
</div>
<div class="p-3">
<h3 class="text-sm font-semibold truncate text-gray-900 dark:text-white">{{ $episode->title }} - {{ $episode->episode }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"> {{ \Illuminate\Support\Str::limit($episode->description ?? '', 80) }}</p>
{{-- Right: View Count, Like Count --}}
<div class="flex-shrink-0 flex flex-col items-end gap-1.5">
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Views') }}">
<i class="fa-regular fa-eye text-[0.65rem] opacity-70"></i>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $episode->viewCountFormatted() }}</span>
</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums" title="{{ __('Likes') }}">
<i class="fa-regular fa-heart text-[0.65rem] opacity-70 text-rose-500 dark:text-rose-400"></i>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ number_format($episode->likeCount()) }}</span>
</div>
</div>
</a>
</li>
@endforeach
{{-- Advanced Search card --}}
<a href="{{ route('hentai.search', ['search' => $query]) }}" class="flex items-center justify-center rounded-xl border border-dashed border-gray-200 dark:border-neutral-700 p-6 hover:bg-gray-50 dark:hover:bg-neutral-900 transition">
<div class="text-center">
<div class="text-2xl font-bold text-rose-600 mb-1">🔎</div>
<div class="font-semibold text-sm dark:text-white">Advanced Search</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">View more results</div>
{{-- Advanced Search footer --}}
<li>
<a href="{{ route('hentai.search', ['search' => $query]) }}"
class="block px-5 py-3.5 text-center group/advanced hover:bg-rose-50/60 dark:hover:bg-rose-950/30 transition-colors duration-150"
>
<div class="flex items-center justify-center gap-2">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200 group-hover/advanced:text-rose-700 dark:group-hover/advanced:text-rose-400 transition-colors">
{{ __('Advanced Search') }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('View all results') }}</span>
<svg class="w-4 h-4 text-gray-400 group-hover/advanced:text-rose-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</a>
</div>
</li>
</ul>
@else
{{-- Empty state --}}
<div class="py-12 text-center text-sm text-gray-600 dark:text-gray-300">
<div class="mb-3">No results found for {{ $query ?: $navSearch }}</div>
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-block px-4 py-2 rounded-lg bg-rose-700 text-white text-sm hover:bg-rose-800">Try advanced search</a>
<div class="mb-3">{{ __('No results found for') }} <span class="font-semibold text-rose-600">"{{ $query ?: $navSearch }}"</span></div>
<a href="{{ route('hentai.search', ['search' => $navSearch ?: $query]) }}" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-rose-700 text-white text-sm font-medium hover:bg-rose-800 transition shadow-sm hover:shadow-md">
<span>{{ __('Try advanced search') }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
@endif
</div>
@@ -1,36 +1,38 @@
<div>
<div class="mx-auto max-w-5xl px-4 space-y-6">
<!-- Filters -->
<div class="p-4 bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow flex flex-col sm:flex-row gap-3">
<!-- Search -->
<div class="space-y-5">
{{-- Filters --}}
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
<div class="flex flex-col sm:flex-row gap-3">
{{-- Search --}}
<div class="relative flex-1">
<input
wire:model.live.debounce.500ms="commentSearch"
type="search"
placeholder="Search comments..."
class="w-full pl-10 pr-4 py-3 rounded-lg border-neutral-300 dark:text-neutral-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
>
<div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="w-4 h-4 text-gray-400 dark:text-gray-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
</div>
<input
wire:model.live.debounce.500ms="commentSearch"
type="search"
placeholder="Search comments..."
class="w-full pl-10 pr-4 py-2.5 rounded-xl border-neutral-300 dark:text-neutral-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:border-rose-700 transition"
>
</div>
<!-- Order -->
{{-- Order --}}
<div class="relative">
<i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
<select
wire:model.live="order"
class="px-4 py-3 rounded-lg border-neutral-300 dark:text-gray-300 bg-white/80 dark:bg-neutral-900/50 dark:border-neutral-700 min-w-[128px]"
class="appearance-none pl-10 pr-8 py-2.5 rounded-xl border-neutral-300 dark:text-gray-300 bg-white dark:bg-neutral-900 dark:border-neutral-700 min-w-[128px]"
>
<option value="created_at_desc">Newest</option>
<option value="created_at_asc">Oldest</option>
</select>
</div>
</div>
</div>
<!-- Comments -->
<div class="space-y-4">
{{-- Comments --}}
<div class="space-y-3">
@forelse ($comments as $comment)
@php
@@ -46,35 +48,40 @@
wire:key="comment-{{ $comment->id }}"
class="block group">
<div class="bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow hover:shadow-lg transition overflow-hidden">
<div class="rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200 overflow-hidden">
<div class="flex flex-col sm:flex-row">
<!-- Thumbnail -->
<div class="sm:w-48 shrink-0">
{{-- Thumbnail --}}
<div class="sm:w-44 shrink-0">
<img
src="{{ $episode->gallery->first()->thumbnail_url }}"
alt=""
class="w-full h-40 sm:h-full object-cover"
alt="{{ $episode->title ?? '' }}"
class="w-full h-36 sm:h-full object-cover"
loading="lazy"
>
</div>
<!-- Content -->
<div class="flex-1 p-4 flex flex-col justify-between">
{{-- Content --}}
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
<!-- Comment -->
<div class="text-gray-800 dark:text-gray-200 text-sm line-clamp-3">
{{-- Episode Title --}}
<p class="text-xs font-medium text-rose-600 dark:text-rose-400 truncate mb-1">
{{ $episode->title ?? 'Episode' }}
</p>
{{-- Comment --}}
<div class="text-sm text-gray-700 dark:text-gray-300 line-clamp-2 leading-relaxed">
{!! $comment->presenter()->markdownBody() !!}
</div>
<!-- Meta -->
<div class="flex items-center justify-between mt-3 text-xs text-gray-700 dark:text-gray-400">
<span>
{{-- Meta --}}
<div class="flex items-center justify-between mt-2.5 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<i class="fa-solid fa-clock text-[10px]"></i>
{{ $comment->presenter()->relativeCreatedAt() }}
</span>
<span class="text-rose-600 font-medium group-hover:underline">
<span class="text-rose-600 dark:text-rose-400 font-medium group-hover:underline">
View comment
</span>
</div>
@@ -83,19 +90,19 @@
</div>
</a>
@empty
<div class="flex bg-white/40 dark:bg-neutral-950/40 backdrop-blur rounded-xl shadow hover:shadow-lg transition overflow-hidden">
<div class="text-gray-800 dark:text-gray-200 text-center w-full p-4">
<p class="text-lg">No results</p>
<p class="text-sm opacity-70">(╥﹏╥)</p>
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-comment-slash text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No comments found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You haven't commented on anything yet.</p>
</div>
@endforelse
</div>
<!-- Pagination -->
{{-- Pagination --}}
<div>
{{ $comments->links('pagination::tailwind') }}
</div>
</div>
</div>
+122 -10
View File
@@ -1,27 +1,139 @@
<div>
<div class="md:ml-8 my-8 md:my-0 space-y-6 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[95%]">
@include('livewire.partials.search-filter')
<div class="space-y-5">
{{-- Slim Profile Filter --}}
<div class="rounded-2xl border border-neutral-200/70 bg-white/80 p-4 shadow-sm backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/70">
<div class="flex flex-col sm:flex-row gap-3">
{{-- Search --}}
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<svg class="h-4 w-4 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
</svg>
</div>
<input
wire:model.live.debounce.400ms="search"
type="search"
placeholder="Search liked episodes..."
class="w-full rounded-xl border border-neutral-300 bg-white py-2.5 pl-11 pr-4 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:placeholder-neutral-500"
/>
</div>
{{-- Order --}}
<div class="relative">
<i class="fa-solid fa-sort pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
<select
wire:model.live="order"
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
>
<option value="az">A-Z</option>
<option value="za">Z-A</option>
<option value="recently-uploaded">{{ __('home.recently-uploaded') }}</option>
<option value="recently-released">{{ __('home.recently-released') }}</option>
<option value="oldest-uploads">{{ __('search.oldest-uploads') }}</option>
<option value="oldest-releases">{{ __('search.oldest-releases') }}</option>
<option value="view-count">{{ __('search.view-count') }}</option>
</select>
</div>
{{-- View --}}
<div class="relative">
<i class="fa-solid fa-list pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"></i>
<select
wire:model.live="view"
class="w-full appearance-none rounded-xl border border-neutral-300 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
>
<option value="thumbnail">Thumbnail</option>
<option value="poster">Poster</option>
</select>
</div>
{{-- Filter Buttons --}}
<div class="flex gap-2">
<button
type="button"
data-te-toggle="modal"
data-te-target="#modalGenres"
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<i class="fa-solid fa-sliders text-[10px]"></i>
Genres
@if($tagcount > 0)
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $tagcount }}</span>
@endif
</button>
<button
type="button"
data-te-toggle="modal"
data-te-target="#modalBlacklist"
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<i class="fa-solid fa-shield text-[10px]"></i>
Blacklist
@if($blacklistcount > 0)
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $blacklistcount }}</span>
@endif
</button>
<button
type="button"
data-te-toggle="modal"
data-te-target="#modalStudios"
class="inline-flex items-center gap-1.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 hover:bg-rose-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<i class="fa-solid fa-microphone-lines text-[10px]"></i>
Studios
@if($studiocount > 0)
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-rose-600 px-1 text-[9px] font-bold text-white">{{ $studiocount }}</span>
@endif
</button>
</div>
{{-- Hide Watched --}}
@auth
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-xs font-medium text-neutral-600 shadow-sm transition hover:border-rose-400 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 whitespace-nowrap">
<input
id="checkBoxHideWatched"
type="checkbox"
wire:model.live="hideWatched"
class="h-4 w-4 rounded border-neutral-300 text-rose-600 focus:ring-rose-500 dark:border-neutral-700 dark:bg-neutral-800"
/>
<span>Hide watched</span>
</label>
@endauth
</div>
</div>
{{-- Modals --}}
@include('modals.filter-genres')
@include('modals.filter-studios')
@include('modals.filter-blacklist')
<input type="hidden" id="ts_reference" value="{{ Carbon\Carbon::now()->timestamp }}" />
<div class="relative md:ml-8 pt-5 mx-auto space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[95%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
{{-- Results --}}
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
<div class="flex items-center justify-center">
<div class="flex justify-center">
<div class="mt-4">
@if ($view == 'thumbnail')
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@else
<div class="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-8 gap-2">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
@endif
@forelse($episodes as $episode)
@include('livewire.partials.search-result')
@empty
<div class="col-span-full">
<p class="text-2xl w-52 pt-6">No results (╥﹏╥)</p>
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-heart-crack text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No liked episodes</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Start exploring and like what you enjoy!</p>
</div>
</div>
@endforelse
</div>
</div>
</div>
{{ $episodes->appends(['tags' => $selectedtags])->links('pagination::tailwind') }}
</div>
</div
</div>
</div>
+38 -15
View File
@@ -1,28 +1,51 @@
<div>
<div class="relative mx-auto sm:px-6 lg:px-8 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
<ol class="border-l border-neutral-300 dark:border-neutral-500">
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage" class="text-gray-900 dark:text-white">
<div class="relative">
{{-- Timeline --}}
<ol class="relative border-l-2 border-neutral-300/70 dark:border-neutral-600/70 ml-3 sm:ml-4">
@foreach ($watchedGrouped as $day => $episodes)
<li>
<div class="flex items-center pt-3 flex-start">
<div class="-ml-[5px] mr-3 h-[9px] w-[9px] rounded-full bg-neutral-300 dark:bg-neutral-500">
</div>
<p class="text-sm text-neutral-500 dark:text-neutral-300">
<li class="mb-10 ml-6 sm:ml-8 last:mb-0">
{{-- Timeline Dot --}}
<span class="absolute flex items-center justify-center w-6 h-6 rounded-full -left-3 ring-2 ring-white dark:ring-neutral-950 bg-rose-600 dark:bg-rose-500">
<i class="fa-solid fa-circle text-[6px] text-white"></i>
</span>
{{-- Date Header --}}
<div class="mb-4">
<time class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold text-gray-600 dark:text-gray-300 bg-gray-100/70 dark:bg-neutral-800/70">
<i class="fa-solid fa-calendar text-[10px] text-rose-500"></i>
{{ $episodes->first()->created_at->diffForHumans(['parts' => 1]) }}
</p>
</time>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">
{{ $episodes->count() }} {{ Str::plural('episode', $episodes->count()) }}
</span>
</div>
<div class="flex justify-center">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4">
{{-- Episodes Grid --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach ($episodes as $episode)
<div class="mt-2 mb-6 ml-4">
<x-episode-cover :episode="$episode->episode" view="thumbnail" />
</div>
@endforeach
</div>
</div>
</li>
@endforeach
</ol>
{{-- Pagination --}}
@if($watched->hasPages())
<div class="mt-8">
{{ $watched->links('pagination::tailwind') }}
</div>
@endif
{{-- Empty State --}}
@if($watched->isEmpty())
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-eye-slash text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No watch history</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Start watching and your history will appear here.</p>
</div>
@endif
</div>
</div>
+10 -9
View File
@@ -1,11 +1,12 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row">
@include('profile.partials.sidebar')
<div class="flex flex-col gap-2">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-comment text-rose-500"></i>
{{ __('nav.comments') }}
</h2>
{{-- Content from Livewire --}}
<livewire:user-comments :model="$user"/>
</div>
</div>
</div>
</x-app-layout>
</x-profile-layout>
+121 -7
View File
@@ -1,9 +1,123 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="grid md:grid-flow-col gap-4 w-5/6 flex-row">
@include('profile.partials.sidebar')
@include('profile.partials.info')
<x-profile-layout>
<div class="space-y-6">
{{-- Welcome Banner --}}
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-rose-600 via-rose-700 to-pink-700 p-6 sm:p-8 text-white shadow-xl shadow-rose-600/20">
<div class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMiIvPjwvZz48L2c+PC9zdmc+')] opacity-50"></div>
<div class="relative z-10">
<h1 class="text-2xl sm:text-3xl font-bold">Welcome back, {{ $user->name }}!</h1>
<p class="mt-2 text-rose-100 text-sm sm:text-base max-w-4xl">
Here's an overview of your activity on hstream.moe. Dive back into your favorites or discover something new.
</p>
</div>
{{-- Decorative circles --}}
<div class="absolute -top-10 -right-10 h-40 w-40 rounded-full bg-white/5 blur-2xl"></div>
<div class="absolute -bottom-10 -left-10 h-32 w-32 rounded-full bg-white/5 blur-2xl"></div>
</div>
{{-- Quick Links Grid --}}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 gap-3 sm:gap-4">
<a href="{{ route('profile.likes') }}"
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
<div class="flex items-center gap-3 mb-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-rose-100 dark:bg-rose-900/40 text-rose-600 dark:text-rose-400 group-hover:scale-110 transition-transform duration-200">
<i class="fa-solid fa-heart text-lg"></i>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Liked Episodes</span>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->likes()) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Episodes you've liked</p>
</a>
<a href="{{ route('user.watched') }}"
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
<div class="flex items-center gap-3 mb-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-100 dark:bg-sky-900/40 text-sky-600 dark:text-sky-400 group-hover:scale-110 transition-transform duration-200">
<i class="fa-solid fa-eye text-lg"></i>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Watch History</span>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->watched->count()) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Episodes watched</p>
</a>
<a href="{{ route('profile.playlists') }}"
class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 sm:p-5 hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200">
<div class="flex items-center gap-3 mb-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/40 text-violet-600 dark:text-violet-400 group-hover:scale-110 transition-transform duration-200">
<i class="fa-solid fa-rectangle-list text-lg"></i>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Your Playlists</span>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ number_format($user->playlists->count()) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Custom collections</p>
</a>
</div>
{{-- Recent Activity Section --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-5 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-5 flex items-center gap-2">
<i class="fa-solid fa-clock-rotate-left text-rose-500"></i>
Recent Activity
</h3>
@php
$recentWatched = $user->watched()->with('episode')->latest()->take(4)->get();
$recentComments = $user->comments()->latest()->take(2)->get();
@endphp
@if($recentWatched->isEmpty() && $recentComments->isEmpty())
<div class="text-center py-10">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-ghost text-2xl text-gray-400 dark:text-gray-500"></i>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">No activity yet. Start watching something!</p>
</div>
@else
<div class="space-y-4">
{{-- Recently Watched --}}
@if($recentWatched->isNotEmpty())
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-500 mb-3">Recently Watched</p>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
@foreach($recentWatched as $watched)
<a href="{{ route('hentai.index', ['title' => $watched->episode->slug]) }}"
class="group relative overflow-hidden rounded-lg bg-gray-100 dark:bg-neutral-800 aspect-video block">
<img src="{{ $watched->episode->gallery->first()->thumbnail_url ?? '/images/default-avatar.webp' }}"
alt="{{ $watched->episode->title }}"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy">
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<p class="absolute bottom-2 left-2 right-2 text-xs text-white font-medium truncate">
{{ $watched->episode->title }}
</p>
</div>
</a>
@endforeach
</div>
</div>
</x-app-layout>
@endif
{{-- Recent Comments --}}
@if($recentComments->isNotEmpty())
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-500 mb-3">Recent Comments</p>
<div class="space-y-2">
@foreach($recentComments as $comment)
<a href="{{ route('hentai.index', ['title' => $comment->commentable->slug ?? '#']) }}#comment-{{ $comment->id }}"
class="block rounded-lg bg-gray-50/70 dark:bg-neutral-900/50 p-3 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors">
<div class="text-sm text-gray-700 dark:text-gray-200 line-clamp-2">
{!! $comment->presenter()->markdownBody() !!}
</div>
<p class="text-xs text-gray-400 dark:text-gray-400/80 mt-1.5">
{{ $comment->presenter()->relativeCreatedAt() }}
</p>
</a>
@endforeach
</div>
</div>
@endif
</div>
@endif
</div>
</div>
</x-profile-layout>
+12 -7
View File
@@ -1,9 +1,14 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-heart text-rose-500"></i>
{{ __('nav.likes') }}
</h2>
</div>
{{-- Content from Livewire --}}
@livewire('user-likes')
</div>
</div>
</x-app-layout>
</x-profile-layout>
+56 -27
View File
@@ -1,55 +1,84 @@
<x-app-layout>
@include('partials.background')
<div
class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row justify-center md:justify-normal">
<div class="grid md:grid-flow-col gap-4 xl:w-5/6 flex-row">
@include('profile.partials.sidebar')
<div class="flex flex-col gap-2">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-bell text-rose-500"></i>
Notifications
</h2>
@if($notifications->isNotEmpty())
<form method="POST" action="{{ route('profile.notifications.delete') }}" class="hidden sm:block">
@csrf
@method('delete')
<button type="submit"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 dark:border-red-800/50 px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors">
<i class="fa-solid fa-trash-can text-[10px]"></i>
Clear All
</button>
</form>
@endif
</div>
@forelse($notifications as $notification)
<div
class="bg-white/40 dark:bg-neutral-950/40 backdrop-blur border border-gray-200 dark:border-neutral-700 rounded-xl shadow-sm p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-x-4 transition hover:shadow-md">
<div class="group relative rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:shadow-md transition-all duration-200 overflow-hidden">
<!-- Content -->
<div class="flex flex-col gap-2 w-full h-full mt-2">
<div class="flex items-center justify-between flex-none h-2">
<span class="text-xs font-semibold uppercase tracking-wide text-sky-600 dark:text-rose-500">
{{-- Unread indicator --}}
@if(is_null($notification->read_at))
<div class="absolute left-0 top-0 bottom-0 w-1 bg-rose-500"></div>
@endif
<div class="p-4 sm:p-5 {{ is_null($notification->read_at) ? 'pl-5 sm:pl-6' : '' }}">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
{{-- Content --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1.5">
<span class="inline-flex items-center gap-1 rounded-full bg-rose-100 dark:bg-rose-900/30 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-rose-700 dark:text-rose-400">
<i class="fa-solid fa-tag text-[9px]"></i>
{{ $notification->data['type'] ?? 'Notification' }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ $notification->created_at->diffForHumans(['parts' => 1]) }}
</span>
</div>
<p class="text-sm sm:text-base text-gray-700 dark:text-gray-300 leading-relaxed h-full">
<p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{{ $notification->data['message'] ?? '' }}
</p>
</div>
<!-- Actions -->
<div class="flex gap-2 sm:gap-2 shrink-0">
{{-- Actions --}}
<div class="flex items-center gap-2 shrink-0">
@if(isset($notification->data['url']))
<a href="{{ $notification->data['url'] }}"
class="text-center rounded-lg bg-sky-600 px-3 py-2 text-xs sm:text-sm font-medium text-white hover:bg-sky-700 transition">
class="inline-flex items-center gap-1 rounded-lg bg-rose-600 px-3.5 py-2 text-xs font-semibold text-white hover:bg-rose-700 shadow-sm shadow-rose-600/20 transition-all duration-150 hover:shadow-md hover:shadow-rose-600/25">
Open
<i class="fa-solid fa-arrow-right text-[10px]"></i>
</a>
@endif
<form method="POST" action="{{ route('profile.notifications.delete') }}">
@csrf
@method('delete')
<input type="hidden" value="{{ $notification->id }}" name="id">
<button type="submit"
class="w-full rounded-lg bg-rose-600 px-3 py-2 text-xs sm:text-sm font-medium text-white hover:bg-rose-700 transition">
Delete
class="inline-flex items-center rounded-lg p-2 text-gray-400 hover:bg-red-50 dark:hover:bg-red-950/30 hover:text-red-500 dark:hover:text-red-400 transition-colors"
title="Delete">
<i class="fa-solid fa-xmark text-sm"></i>
</button>
</form>
</div>
</div>
</div>
</div>
@empty
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
<p class="text-lg">No notifications</p>
<p class="text-sm opacity-70">(╥﹏╥)</p>
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-bell-slash text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No notifications</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You're all caught up! Nothing new here.</p>
</div>
@endforelse
</div>
</div>
</div>
</x-app-layout>
</x-profile-layout>
@@ -1,18 +1,26 @@
@auth
<div
class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
<div class="flex flex-col p-2">
<a class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if(request()->routeIs('profile.settings')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"
href="{{ route('profile.settings') }}"><i class="fa-solid fa-gear pr-4"></i>
Settings</a>
<div class="mt-5 overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
<div class="flex flex-col p-1.5">
<a href="{{ route('profile.settings') }}"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.settings'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-gear w-5 text-center text-base
@if(request()->routeIs('profile.settings')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
Settings
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit"
class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-right-from-bracket pr-4"></i>
Logout</button>
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-red-50/60 dark:hover:bg-red-950/40 hover:text-red-600 dark:hover:text-red-400 border-l-[3px] border-transparent transition-all duration-150">
<i class="fa-solid fa-right-from-bracket w-5 text-center text-base text-gray-400 dark:text-gray-500"></i>
Logout
</button>
</form>
</div>
</div>
@@ -0,0 +1,86 @@
<div class="md:hidden mt-5 -mx-1">
<nav class="flex gap-1 overflow-x-auto pb-2 scrollbar-hide">
<a href="{{ route('profile.show') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.show'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-user text-[10px]"></i>
{{ __('nav.profile') }}
</a>
<a href="{{ route('profile.notifications') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.notifications'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-bell text-[10px]"></i>
Notifications
@php $unreadCount = auth()->user()->unreadNotifications()->count(); @endphp
@if($unreadCount > 0)
<span class="inline-flex items-center justify-center h-4 min-w-[16px] rounded-full bg-rose-500 px-1 text-[9px] font-bold text-white">
{{ $unreadCount > 99 ? '99+' : $unreadCount }}
</span>
@endif
</a>
<a href="{{ route('profile.likes') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.likes'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-heart text-[10px]"></i>
{{ __('nav.likes') }}
</a>
<a href="{{ route('user.watched') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('user.watched'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-eye text-[10px]"></i>
{{ __('nav.watched') }}
</a>
<a href="{{ route('profile.comments') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.comments'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-comment text-[10px]"></i>
{{ __('nav.comments') }}
</a>
<a href="{{ route('profile.playlists') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.playlists'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-rectangle-list text-[10px]"></i>
{{ __('nav.playlists') }}
</a>
<a href="{{ route('profile.settings') }}"
class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium transition-all duration-150
@if(request()->routeIs('profile.settings'))
bg-rose-600 text-white shadow-md shadow-rose-600/25
@else
bg-white/60 text-gray-600 dark:bg-neutral-900/60 dark:text-gray-400 hover:bg-white/90 dark:hover:bg-neutral-800/90
@endif">
<i class="fa-solid fa-gear text-[10px]"></i>
Settings
</a>
</nav>
</div>
@@ -1,15 +1,68 @@
<div
class="overflow-hidden relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl flex items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
<img class="absolute -left-6 w-24 h-24 rounded-full shadow-lg" src="{{ $user->getAvatar() }}">
<div class="flex flex-col py-5 pl-24">
<strong class="text-slate-900 text-xl font-bold dark:text-slate-200">
{{ $user->name }}
@if ($user->hasRole(\App\Enums\UserRole::SUPPORTER))
<a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i
class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a>
<div class="overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
<div class="relative">
{{-- Profile Banner Gradient --}}
<div class="h-20 bg-gradient-to-br from-rose-500 via-rose-600 to-pink-600 dark:from-rose-700 dark:via-rose-800 dark:to-pink-800"></div>
{{-- Avatar overlapping the banner --}}
<div class="flex justify-center -mt-10">
<div class="relative">
<img class="h-20 w-20 rounded-full border-4 border-white dark:border-neutral-900 shadow-lg object-cover bg-white dark:bg-neutral-800"
src="{{ auth()->user()->getAvatar() }}"
alt="{{ auth()->user()->name }}">
@if(auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER))
<span class="absolute -bottom-1 -right-1 flex h-7 w-7 items-center justify-center rounded-full bg-rose-600 text-white shadow-md ring-2 ring-white dark:ring-neutral-900"
data-te-toggle="tooltip"
title="Badge of appreciation for the horny people supporting us! :3">
<i class="fa-solid fa-heart text-[11px]"></i>
</span>
@endif
</strong>
<span class="text-slate-500 text-sm font-medium dark:text-slate-400">Joined
{{ $user->created_at->format('Y-m') }}</span>
</div>
</div>
{{-- User Info --}}
<div class="px-4 pb-4 pt-2 text-center">
<h2 class="text-base font-bold text-gray-900 dark:text-gray-100 truncate">
{{ auth()->user()->name }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Joined {{ auth()->user()->created_at->format('F Y') }}
</p>
</div>
{{-- Quick Stats Row --}}
<div class="grid grid-cols-4 border-t border-gray-200/60 dark:border-neutral-800/60">
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->watched->count()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Views
</div>
</div>
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->commentCount()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Cmts
</div>
</div>
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->likes()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Likes
</div>
</div>
<div class="py-3 text-center hover:bg-gray-50/50 dark:hover:bg-neutral-900/30 transition-colors cursor-default">
<div class="text-sm font-bold text-gray-800 dark:text-gray-200">
{{ number_format(auth()->user()->playlists->count()) }}
</div>
<div class="text-[10px] font-medium uppercase tracking-wider text-gray-600 dark:text-gray-500">
Lists
</div>
</div>
</div>
</div>
</div>
@@ -1,37 +1,96 @@
<div class="flex flex-col">
<div>
{{-- Profile Card --}}
@include('profile.partials.profile')
<div
class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
<div class="flex flex-col p-2">
{{-- Desktop Navigation (hidden on mobile) --}}
<div class="hidden md:block">
<nav
class="mt-5 overflow-hidden rounded-xl bg-white/40 shadow-lg ring-1 ring-black/5 dark:bg-neutral-950/40 backdrop-blur dark:ring-white/10">
<div class="flex flex-col p-1.5">
<a href="{{ route('profile.show') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.show')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-user pr-4"></i> {{ __('nav.profile') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.show'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-user w-5 text-center text-base
@if(request()->routeIs('profile.show')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.profile') }}</span>
</a>
<a href="{{ route('profile.notifications') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.notifications')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-bell pr-4"></i> Notifications</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.notifications'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-bell w-5 text-center text-base
@if(request()->routeIs('profile.notifications')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>Notifications</span>
@php $unreadCount = auth()->user()->unreadNotifications()->count(); @endphp
@if($unreadCount > 0)
<span class="ml-auto inline-flex items-center justify-center h-5 min-w-[20px] rounded-full bg-rose-600 px-1.5 text-[10px] font-bold text-white">
{{ $unreadCount > 99 ? '99+' : $unreadCount }}
</span>
@endif
</a>
<a href="{{ route('profile.likes') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.likes')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-heart pr-4"></i> {{ __('nav.likes') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.likes'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-heart w-5 text-center text-base
@if(request()->routeIs('profile.likes')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.likes') }}</span>
</a>
<a href="{{ route('user.watched') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('user.watched')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-eye pr-4"></i>
{{ __('nav.watched') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('user.watched'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-eye w-5 text-center text-base
@if(request()->routeIs('user.watched')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.watched') }}</span>
</a>
<a href="{{ route('profile.comments') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.comments')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-comment pr-4"></i>
{{ __('nav.comments') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.comments'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-comment w-5 text-center text-base
@if(request()->routeIs('profile.comments')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.comments') }}</span>
</a>
<a href="{{ route('profile.playlists') }}"
class="block cursor-pointer w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if (request()->routeIs('profile.playlists')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"><i
class="fa-solid fa-rectangle-list pr-4"></i>
{{ __('nav.playlists') }}</a>
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@if(request()->routeIs('profile.playlists'))
bg-rose-600/10 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400 border-l-[3px] border-rose-600 dark:border-rose-400 ml-[-3px]
@else
text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-neutral-800/60 border-l-[3px] border-transparent
@endif">
<i class="fa-solid fa-rectangle-list w-5 text-center text-base
@if(request()->routeIs('profile.playlists')) text-rose-600 dark:text-rose-400 @else text-gray-400 dark:text-gray-500 @endif"></i>
<span>{{ __('nav.playlists') }}</span>
</a>
</div>
</nav>
{{-- Actions --}}
@include('profile.partials.actions')
</div>
@include('profile.partials.actions')
{{-- Mobile Navigation (horizontal scroll tabs, visible only on mobile) --}}
@include('profile.partials.mobile-nav')
</div>
@@ -1,34 +1,43 @@
<div>
<div class="grid-cols-1 sm:grid md:grid-cols-3 ">
@if(count($playlists) > 0)
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
@foreach($playlists as $playlist)
@php
$count = $playlist->episodes->count();
@endphp
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white/60 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-950/60 sm:shrink-0 sm:grow sm:basis-0">
@if($count > 0)
<a href="{{ route('profile.playlist.show', $playlist->id) }}">
@else
<a href="#!">
@endif
<div class="group rounded-xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden hover:shadow-md hover:ring-rose-300/50 dark:hover:ring-rose-700/30 transition-all duration-200 flex flex-col">
{{-- Thumbnail --}}
@if($count > 0)
<a href="{{ route('profile.playlist.show', $playlist->id) }}" class="block overflow-hidden">
@php
$pe = \App\Models\PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position', 'desc')->first();
@endphp
<img class="rounded-t-lg aspect-video" src="{{ $pe->episode->gallery->first()->thumbnail_url }}" alt="Hollywood Sign on The Hill" />
@else
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-20" alt="..." />
@endif
<img class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105"
src="{{ $pe->episode->gallery->first()->thumbnail_url }}"
alt="{{ $playlist->name }}"
loading="lazy" />
</a>
<div class="p-6" x-data="{ editing: false, name: '{{ $playlist->name }}', isPrivate: {{ $playlist->is_private ? 'true' : 'false' }} }">
<!-- Edit mode -->
@else
<div class="w-full aspect-video bg-gray-200 dark:bg-neutral-800 flex items-center justify-center">
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp"
class="w-full aspect-video object-cover opacity-30 dark:opacity-15"
alt="Empty playlist" />
</div>
@endif
{{-- Content --}}
<div class="p-4 flex-1 flex flex-col"
x-data="{ editing: false, name: '{{ $playlist->name }}', isPrivate: {{ $playlist->is_private ? 'true' : 'false' }} }">
{{-- Edit mode --}}
<template x-if="editing">
<div>
<form method="POST" action="{{ route('profile.playlist.update', $playlist->id) }}" class="space-y-3">
<div class="flex-1 flex flex-col">
<form method="POST" action="{{ route('profile.playlist.update', $playlist->id) }}" class="flex-1 flex flex-col">
@csrf
@method('PATCH')
<div class="space-y-3 flex-1">
<div>
<label class="mb-1 block text-xs font-medium text-neutral-600 dark:text-neutral-400">Name:</label>
<label class="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">Name</label>
<input
type="text"
name="name"
@@ -39,7 +48,7 @@
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-neutral-600 dark:text-neutral-400">Visibility:</label>
<label class="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">Visibility</label>
<select
name="is_private"
x-model="isPrivate"
@@ -49,11 +58,12 @@
<option value="1">Private</option>
</select>
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-lg bg-rose-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-rose-700">
</div>
<div class="flex gap-2 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<button type="submit" class="cursor-pointer rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-700 flex-1">
Save
</button>
<button type="button" @click="editing = false; name = '{{ $playlist->name }}'; isPrivate = {{ $playlist->is_private ? 'true' : 'false' }}" class="cursor-pointer rounded-lg border border-neutral-300 px-4 py-2 text-xs font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800">
<button type="button" @click="editing = false; name = '{{ $playlist->name }}'; isPrivate = {{ $playlist->is_private ? 'true' : 'false' }}" class="cursor-pointer rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-600 transition hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-200 dark:hover:bg-neutral-800 flex-1">
Cancel
</button>
</div>
@@ -61,52 +71,81 @@
</div>
</template>
<!-- Display mode -->
{{-- Display mode --}}
<template x-if="!editing">
<div>
<h5 class="mb-2 text-xl font-medium leading-tight text-neutral-800 dark:text-neutral-50">
<div class="flex-1 flex flex-col">
<div class="flex-1">
<h5 class="text-base font-semibold text-neutral-800 dark:text-neutral-50 truncate">
{{ $playlist->name }}
</h5>
<p class="mb-2 text-sm leading-tight text-neutral-800 dark:text-neutral-50">
{{ $count }} Episodes - {{ $playlist->is_private == 1 ? 'Private' : 'Public' }}
@if($count > 0)
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}" class="cursor-pointer float-right text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">{{ __('playlist.play') }}</a>
@endif
</p>
<div class="flex items-center gap-2 mt-1.5">
<span class="inline-flex items-center gap-1 pl-2 pr-2 pt-1 pb-1 rounded-full bg-neutral-100 dark:bg-neutral-700 px-2 py-0.5 text-[11px] font-medium text-neutral-600 dark:text-neutral-300">
<i class="fa-solid fa-film text-[9px]"></i>
{{ $count }} {{ Str::plural('ep', $count) }}
</span>
<span class="inline-flex items-center gap-1 pl-2 pr-2 pt-1 pb-1 rounded-full text-[11px] font-medium
{{ $playlist->is_private ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' }}">
<i class="fa-solid fa-{{ $playlist->is_private ? 'lock' : 'globe' }} text-[9px]"></i>
{{ $playlist->is_private ? 'Private' : 'Public' }}
</span>
</div>
</div>
<div class="flex items-center justify-between mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="flex items-center gap-3">
<a href="{{ route('profile.playlist.delete', $playlist->id) }}" class="inline-flex items-center cursor-pointer text-xs text-red-600" data-confirm-delete="true">Delete</a>
<button @click="editing = true" class="inline-flex items-center cursor-pointer text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Edit</button>
<a href="{{ route('profile.playlist.delete', $playlist->id) }}"
class="inline-flex items-center gap-1 text-[11px] text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
data-confirm-delete="true">
<i class="fa-solid fa-trash-can text-[10px]"></i>
Delete
</a>
<button @click="editing = true"
class="inline-flex items-center gap-1 text-[11px] text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors">
<i class="fa-solid fa-pen-to-square text-[10px]"></i>
Edit
</button>
</div>
@if($count > 0)
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
class="inline-flex items-center gap-1 rounded-lg bg-rose-600 px-3 py-1.5 text-[11px] font-semibold text-white hover:bg-rose-700 transition-colors shadow-sm shadow-rose-600/20">
<i class="fa-solid fa-play text-[9px]"></i>
Play
</a>
@endif
</div>
</div>
</template>
</div>
</div>
@endforeach
<!-- Add Another Playlist -->
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white/60 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-950/60 sm:shrink-0 sm:grow sm:basis-0">
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-40" alt="..." />
<div class="p-6">
<p class="text-black dark:text-white">
Create another Playlist
</p>
<a data-te-toggle="modal" data-te-target="#modalCreatePlaylist" data-te-ripple-init data-te-ripple-color="light" class="inline-flex items-center cursor-pointer px-4 py-2 mt-2 bg-rose-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
Create
</a>
{{-- Add New Playlist Card --}}
<button
data-te-toggle="modal"
data-te-target="#modalCreatePlaylist"
class="group rounded-xl border-2 border-dashed border-neutral-300 dark:border-neutral-700 bg-white/20 dark:bg-neutral-950/20 backdrop-blur hover:border-rose-400 dark:hover:border-rose-600 hover:bg-rose-50/50 dark:hover:bg-rose-950/20 transition-all duration-200 flex flex-col items-center justify-center p-8 min-h-[200px]">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400 group-hover:scale-110 transition-transform duration-200 mb-3">
<i class="fa-solid fa-plus text-xl"></i>
</div>
<p class="text-sm font-semibold text-neutral-600 dark:text-neutral-300">Create Playlist</p>
<p class="text-xs text-neutral-400 dark:text-neutral-500 mt-1">Organize your favorites</p>
</button>
</div>
@else
<!-- No Playlist Found -->
<div class="mx-3 mt-6 flex flex-col rounded-lg bg-white shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-700 sm:shrink-0 sm:grow sm:basis-0">
<img src="/images/hentai/sukebe-elf-tanbouki/gallery-ep-1-0.webp" class="rounded-t-lg opacity-50 dark:opacity-20" alt="..." />
<div class="p-6">
<p class="text-black dark:text-white">
No Playlist found!
</p>
<a data-te-toggle="modal" data-te-target="#modalCreatePlaylist" data-te-ripple-init data-te-ripple-color="light" class="inline-flex items-center cursor-pointer px-4 py-2 mt-2 bg-rose-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-rose-700 active:bg-rose-900 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
Create
</a>
{{-- Empty State --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
<i class="fa-solid fa-rectangle-list text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">No playlists yet</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 mb-6">Create your first playlist to organize your favorite episodes.</p>
<button
data-te-toggle="modal"
data-te-target="#modalCreatePlaylist"
class="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-rose-700 transition-colors shadow-sm shadow-rose-600/20">
<i class="fa-solid fa-plus text-xs"></i>
Create your first playlist
</button>
</div>
@endif
</div>
</div>
+21 -11
View File
@@ -1,14 +1,24 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-rectangle-list text-rose-500"></i>
{{ __('nav.playlists') }}
</h2>
<button
data-te-toggle="modal"
data-te-target="#modalCreatePlaylist"
class="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-rose-600/20 hover:bg-rose-700 transition-colors">
<i class="fa-solid fa-plus text-xs"></i>
New Playlist
</button>
</div>
{{-- Content --}}
@include('profile.partials.user-playlists')
</div>
</div>
{{-- Modal --}}
@include('modals.create-playlist')
</div>
</div>
</x-app-layout>
</x-profile-layout>
+111 -15
View File
@@ -1,30 +1,126 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 mb-14 flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mt-8 md:mt-0 space-y-6">
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-gear text-rose-500"></i>
Settings
</h2>
{{-- Settings Sections --}}
<div class="space-y-5">
{{-- Profile Information --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<i class="fa-solid fa-user-pen text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Profile Information</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Update your name, email and avatar</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-profile-information-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Passkeys --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400">
<i class="fa-solid fa-key text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Passkeys</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Manage WebAuthn passkeys for passwordless login</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
<livewire:passkeys />
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Password --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<i class="fa-solid fa-lock text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Update Password</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Keep your account secure</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-password-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Search Blacklist --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<i class="fa-solid fa-shield text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Search Blacklist</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Hide content with specific tags from search results</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-blacklist-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Website Design --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-neutral-200/60 dark:border-neutral-800/60">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<i class="fa-solid fa-object-group text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Website Design</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Customize your browsing experience</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.update-design-form')
</div>
<div class="p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
</div>
{{-- Danger Zone --}}
<div class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-red-200/60 dark:ring-red-800/30 overflow-hidden">
<div class="p-5 sm:p-6 border-b border-red-200/60 dark:border-red-800/30">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
<i class="fa-solid fa-triangle-exclamation text-sm"></i>
</div>
<div>
<h3 class="text-base font-semibold text-red-700 dark:text-red-400">Danger Zone</h3>
<p class="text-xs text-red-500 dark:text-red-400">Irreversible actions - proceed with caution</p>
</div>
</div>
</div>
<div class="p-5 sm:p-6">
@include('profile.partials.delete-user-form')
</div>
@include('profile.partials.delete-user-modal')
</div>
</div>
{{-- Delete Account Modal --}}
@include('profile.partials.delete-user-modal')
@vite(['resources/js/user-blacklist.js'])
</div>
</div>
</x-app-layout>
</x-profile-layout>
+10 -7
View File
@@ -1,9 +1,12 @@
<x-app-layout>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<x-profile-layout>
<div class="space-y-5">
{{-- Header --}}
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fa-solid fa-eye text-rose-500"></i>
{{ __('nav.watched') }}
</h2>
{{-- Content from Livewire --}}
@livewire('watched', ['user' => $user])
</div>
</div>
</x-app-layout>
</x-profile-layout>
@@ -8,6 +8,42 @@
@endif
<canvas id="ambientVideo" class="decoy"></canvas>
<div class="relative w-full aspect-[16/9]">
<video id="player" playsinline controls crossorigin class="absolute inset-0 w-full h-full"></video>
<div class="player-switcher" id="player-switcher">
<span class="player-switcher__label">Player</span>
<button type="button" class="player-switcher__btn" id="plyr-toggle-btn"
onclick="window.setPlayerPreference('plyr')" title="Switch to Plyr player">
<span class="player-switcher__dot"></span> Plyr
</button>
<button type="button" class="player-switcher__btn player-switcher__btn--active" id="hstream-toggle-btn"
onclick="window.setPlayerPreference('hstream')" title="Switch to HStream player">
<span class="player-switcher__dot"></span> HStream
</button>
</div>
<video id="player" playsinline crossorigin class="absolute inset-0 w-full h-full"></video>
</div>
<script>
(function() {
var pref = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
var hstreamBtn = document.getElementById('hstream-toggle-btn');
var plyrBtn = document.getElementById('plyr-toggle-btn');
if (pref === 'plyr') {
hstreamBtn.classList.remove('player-switcher__btn--active');
plyrBtn.classList.add('player-switcher__btn--active');
}
var switcher = document.getElementById('player-switcher');
var container = switcher.parentElement;
var hideTimer = null;
function showSwitcher() {
switcher.classList.add('player-switcher--visible');
clearTimeout(hideTimer);
hideTimer = setTimeout(function () {
switcher.classList.remove('player-switcher--visible');
}, 3000);
}
container.addEventListener('pointerdown', showSwitcher);
})();
</script>
</div>
+1 -2
View File
@@ -12,9 +12,8 @@ export default defineConfig({
'resources/js/app.js',
'resources/js/modals-playlist.js',
'resources/js/theme.js',
'resources/js/player-mobile.js',
'resources/js/player-data.js',
'resources/js/player.js',
'resources/js/player-plyr.js',
'resources/js/playlist.js',
'resources/js/upload.js',
'resources/js/user-blacklist.js',