ensureSeriesData fonksiyonuna, veri bulunamadığında candidateKeys listesini kullanarak alternatif dosya yollarının kontrol edilmesi ve ilgili metadatanın yüklenmesi sağlandı.
1871 lines
46 KiB
Svelte
1871 lines
46 KiB
Svelte
<script>
|
||
import { onMount, tick } from "svelte";
|
||
import { API, apiFetch } from "../utils/api.js";
|
||
import { cleanFileName } from "../utils/filename.js";
|
||
import { tvShowCount } from "../stores/tvStore.js";
|
||
import {
|
||
activeSearchTerm,
|
||
setSearchScope
|
||
} from "../stores/searchStore.js";
|
||
|
||
let shows = [];
|
||
let loading = true;
|
||
let refreshing = false;
|
||
let rescanning = false;
|
||
let error = null;
|
||
|
||
let selectedShow = null;
|
||
let selectedSeason = null;
|
||
let selectedEpisode = null;
|
||
|
||
let playerItems = [];
|
||
let showPlayerModal = false;
|
||
let selectedVideo = null;
|
||
let videoEl;
|
||
let isPlaying = false;
|
||
let currentTime = 0;
|
||
let duration = 0;
|
||
let volume = 1;
|
||
let subtitleURL = null;
|
||
let subtitleLabel = "Custom Subtitles";
|
||
let currentIndex = -1;
|
||
let seasonPlaylist = [];
|
||
let seasonPlaylistIndex = -1;
|
||
let canPlayPrev = false;
|
||
let canPlayNext = false;
|
||
let searchTerm = "";
|
||
let hasSearch = false;
|
||
let filteredShows = [];
|
||
|
||
function runtimeToText(runtime) {
|
||
if (!runtime || Number.isNaN(runtime)) return null;
|
||
const minutes = Math.round(Number(runtime));
|
||
const hours = Math.floor(minutes / 60);
|
||
const remain = minutes % 60;
|
||
if (hours && remain) return `${hours}h ${remain}m`;
|
||
if (hours) return `${hours}h`;
|
||
return `${remain}m`;
|
||
}
|
||
|
||
function formatVideoInfo(video) {
|
||
if (!video) return null;
|
||
const codec = video.codec ? video.codec.toUpperCase() : null;
|
||
const resolution =
|
||
video.resolution || (video.height ? `${video.height}p` : null);
|
||
const hdr = video.colorSpace?.toUpperCase().includes("HDR")
|
||
? "HDR"
|
||
: null;
|
||
return [codec, resolution, hdr].filter(Boolean).join(" • ") || null;
|
||
}
|
||
|
||
function formatAudioInfo(audio) {
|
||
if (!audio) return null;
|
||
const codec = audio.codec ? audio.codec.toUpperCase() : null;
|
||
let channels = null;
|
||
if (audio.channelLayout) channels = audio.channelLayout.toUpperCase();
|
||
else if (audio.channels) {
|
||
channels =
|
||
audio.channels === 6
|
||
? "5.1"
|
||
: audio.channels === 2
|
||
? "2.0"
|
||
: `${audio.channels}`;
|
||
}
|
||
return [codec, channels].filter(Boolean).join(" • ") || null;
|
||
}
|
||
|
||
function assetUrl(pathname) {
|
||
if (!pathname) return null;
|
||
const token = localStorage.getItem("token");
|
||
return `${API}${pathname}?token=${token}`;
|
||
}
|
||
|
||
function posterUrl(show) {
|
||
return assetUrl(show?.poster);
|
||
}
|
||
|
||
function backdropUrl(show) {
|
||
return assetUrl(show?.backdrop);
|
||
}
|
||
|
||
function stillUrl(episode) {
|
||
return assetUrl(episode?.still);
|
||
}
|
||
|
||
function formatAirDate(value) {
|
||
if (!value) return null;
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
function formatEpisodeCode(episode) {
|
||
if (!episode) return "";
|
||
if (episode.code) return episode.code;
|
||
const season = String(episode.seasonNumber || 0).padStart(2, "0");
|
||
const ep = String(episode.episodeNumber || 0).padStart(2, "0");
|
||
return `S${season}E${ep}`;
|
||
}
|
||
|
||
function updateSeasonPlaylist() {
|
||
if (!selectedVideo || !playerItems.length) {
|
||
seasonPlaylist = [];
|
||
seasonPlaylistIndex = -1;
|
||
canPlayPrev = false;
|
||
canPlayNext = false;
|
||
return;
|
||
}
|
||
const currentSeason = selectedVideo.episode?.seasonNumber;
|
||
const showId = selectedVideo.show?.id || selectedVideo.show?.title;
|
||
seasonPlaylist = playerItems.filter(
|
||
(item) =>
|
||
(item.show?.id || item.show?.title) === showId &&
|
||
item.episode?.seasonNumber === currentSeason
|
||
);
|
||
seasonPlaylistIndex = seasonPlaylist.findIndex(
|
||
(item) =>
|
||
item.episode?.videoPath === selectedVideo.episode?.videoPath &&
|
||
(item.show?.id || item.show?.title) === showId
|
||
);
|
||
canPlayPrev = seasonPlaylistIndex > 0;
|
||
canPlayNext =
|
||
seasonPlaylistIndex !== -1 &&
|
||
seasonPlaylistIndex < seasonPlaylist.length - 1;
|
||
}
|
||
|
||
function normalizeShow(show) {
|
||
const seasons = Array.isArray(show.seasons)
|
||
? show.seasons
|
||
.map((season) => {
|
||
const seasonNumber = Number(
|
||
season.seasonNumber ?? season.number ?? 0
|
||
);
|
||
const name = season.name || `Season ${seasonNumber}`;
|
||
const episodes = Array.isArray(season.episodes)
|
||
? season.episodes
|
||
.map((episode) => {
|
||
const episodeNumber = Number(
|
||
episode.episodeNumber ?? episode.number ?? 0
|
||
);
|
||
const seasonNo =
|
||
Number(episode.seasonNumber ?? seasonNumber) || seasonNumber;
|
||
const videoPath = episode.videoPath
|
||
? episode.videoPath.replace(/\\/g, "/")
|
||
: episode.file
|
||
? `${show.folder}/${episode.file}`.replace(/\\/g, "/")
|
||
: null;
|
||
return {
|
||
...episode,
|
||
episodeNumber,
|
||
seasonNumber: seasonNo,
|
||
videoPath
|
||
};
|
||
})
|
||
.filter((episode) => episode.videoPath)
|
||
.sort((a, b) => a.episodeNumber - b.episodeNumber)
|
||
: [];
|
||
return {
|
||
...season,
|
||
seasonNumber,
|
||
name,
|
||
episodes
|
||
};
|
||
})
|
||
.filter((season) => season.episodes.length)
|
||
.sort((a, b) => a.seasonNumber - b.seasonNumber)
|
||
: [];
|
||
return {
|
||
...show,
|
||
seasons
|
||
};
|
||
}
|
||
|
||
async function loadShows() {
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/tvshows");
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const list = await resp.json();
|
||
shows = Array.isArray(list) ? list.map(normalizeShow) : [];
|
||
tvShowCount.set(shows.length);
|
||
} catch (err) {
|
||
error = err?.message || "TV dizileri alınamadı.";
|
||
shows = [];
|
||
tvShowCount.set(0);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
$: searchTerm = $activeSearchTerm;
|
||
$: hasSearch = searchTerm.trim().length > 0;
|
||
$: filteredShows = (() => {
|
||
const query = searchTerm.trim().toLowerCase();
|
||
if (!query) return shows;
|
||
return shows.filter((show) => {
|
||
const fields = [
|
||
show.title,
|
||
show.originalTitle,
|
||
show.metadata?.matched_title,
|
||
show.metadata?.original_name,
|
||
show.folder
|
||
];
|
||
return fields
|
||
.filter(Boolean)
|
||
.some((value) => String(value).toLowerCase().includes(query));
|
||
});
|
||
})();
|
||
|
||
async function refreshShows() {
|
||
try {
|
||
refreshing = true;
|
||
const resp = await apiFetch("/api/tvshows/refresh", { method: "POST" });
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
await loadShows();
|
||
} catch (err) {
|
||
console.error("TV metadata refresh error:", err);
|
||
error = err?.message || "Metadata yenilenemedi.";
|
||
} finally {
|
||
refreshing = false;
|
||
}
|
||
}
|
||
|
||
async function rescanShows() {
|
||
try {
|
||
rescanning = true;
|
||
const resp = await apiFetch("/api/tvshows/rescan", { method: "POST" });
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
await loadShows();
|
||
} catch (err) {
|
||
console.error("TV metadata rescan error:", err);
|
||
error = err?.message || "Tam tarama sırasında bir sorun oluştu.";
|
||
} finally {
|
||
rescanning = false;
|
||
}
|
||
}
|
||
|
||
function openShow(show) {
|
||
if (!show) return;
|
||
selectedShow = show;
|
||
selectedSeason = show.seasons?.[0] || null;
|
||
selectedEpisode = selectedSeason?.episodes?.[0] || null;
|
||
}
|
||
|
||
function closeShow() {
|
||
selectedShow = null;
|
||
selectedSeason = null;
|
||
selectedEpisode = null;
|
||
}
|
||
|
||
function selectSeasonNumber(seasonNumber) {
|
||
if (!selectedShow) return;
|
||
const season = selectedShow.seasons.find(
|
||
(s) => s.seasonNumber === seasonNumber
|
||
);
|
||
if (!season) return;
|
||
selectedSeason = season;
|
||
selectedEpisode = season.episodes?.[0] || null;
|
||
}
|
||
|
||
function selectEpisode(episode) {
|
||
if (!episode) return;
|
||
selectedEpisode = episode;
|
||
}
|
||
|
||
function episodeRuntime(episode) {
|
||
if (!episode) return null;
|
||
if (episode.runtime) return runtimeToText(episode.runtime);
|
||
const seconds = Number(episode.mediaInfo?.format?.duration);
|
||
if (seconds) return runtimeToText(Math.round(seconds / 60));
|
||
return null;
|
||
}
|
||
|
||
$: selectedRuntime = episodeRuntime(selectedEpisode);
|
||
$: selectedVideoInfo = selectedEpisode?.mediaInfo
|
||
? formatVideoInfo(selectedEpisode.mediaInfo.video)
|
||
: null;
|
||
$: selectedAudioInfo = selectedEpisode?.mediaInfo
|
||
? formatAudioInfo(selectedEpisode.mediaInfo.audio)
|
||
: null;
|
||
$: selectedAirDate = formatAirDate(selectedEpisode?.aired);
|
||
|
||
function mapEpisodeToPlayerItem(show, episode) {
|
||
if (!show || !episode?.videoPath) return null;
|
||
const name = (episode.videoPath || "").replace(/^\/+/, "");
|
||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||
const inferredType = ext ? `video/${ext}` : "video/mp4";
|
||
const size =
|
||
Number(episode.fileSize) ||
|
||
Number(episode.mediaInfo?.format?.size) ||
|
||
null;
|
||
return {
|
||
name,
|
||
type: inferredType.startsWith("video/") ? inferredType : "video/mp4",
|
||
size,
|
||
show,
|
||
episode
|
||
};
|
||
}
|
||
|
||
$: playerItems = shows
|
||
.flatMap((show) =>
|
||
show.seasons.flatMap((season) =>
|
||
season.episodes.map((episode) => mapEpisodeToPlayerItem(show, episode))
|
||
)
|
||
)
|
||
.filter(Boolean);
|
||
|
||
const encodePathSegments = (value) =>
|
||
value
|
||
? value
|
||
.split(/[\\/]/)
|
||
.map((segment) => encodeURIComponent(segment))
|
||
.join("/")
|
||
: "";
|
||
|
||
$: selectedName = selectedVideo?.name ?? "";
|
||
$: encName = encodePathSegments(selectedName);
|
||
$: downloadHref = encName
|
||
? `${API}/downloads/${encName}?token=${localStorage.getItem("token") || ""}`
|
||
: "#";
|
||
$: selectedLabel = selectedVideo?.episode
|
||
? `${selectedVideo.show.title} · ${formatEpisodeCode(
|
||
selectedVideo.episode
|
||
)}${
|
||
selectedVideo.episode.title
|
||
? ` — ${selectedVideo.episode.title}`
|
||
: ""
|
||
}`
|
||
: cleanFileName(selectedName);
|
||
|
||
$: if (showPlayerModal && selectedVideo) {
|
||
const idx = playerItems.findIndex(
|
||
(item) =>
|
||
item.episode.videoPath === selectedVideo.episode.videoPath
|
||
);
|
||
if (idx === -1) {
|
||
closePlayer();
|
||
} else if (idx !== currentIndex) {
|
||
currentIndex = idx;
|
||
selectedVideo = playerItems[idx];
|
||
}
|
||
updateSeasonPlaylist();
|
||
}
|
||
|
||
async function handlePlayEpisode(episode) {
|
||
if (!episode) return;
|
||
await openPlayerForEpisode(episode);
|
||
}
|
||
|
||
async function openPlayerForEpisode(episode) {
|
||
if (!playerItems.length || !episode?.videoPath) return;
|
||
const idx = playerItems.findIndex(
|
||
(item) => item.episode.videoPath === episode.videoPath
|
||
);
|
||
if (idx === -1) return;
|
||
await openVideoAtIndex(idx);
|
||
}
|
||
|
||
async function openVideoAtIndex(index) {
|
||
if (!playerItems.length) return;
|
||
const safeIndex =
|
||
((index % playerItems.length) + playerItems.length) %
|
||
playerItems.length;
|
||
stopCurrentVideo();
|
||
currentIndex = safeIndex;
|
||
selectedVideo = playerItems[safeIndex];
|
||
subtitleURL = null;
|
||
await tick();
|
||
showPlayerModal = true;
|
||
isPlaying = false;
|
||
currentTime = 0;
|
||
duration = 0;
|
||
updateSeasonPlaylist();
|
||
|
||
if (selectedShow && selectedVideo) {
|
||
const targetSeason = selectedShow.seasons?.find(
|
||
(season) =>
|
||
season.seasonNumber === selectedVideo.episode?.seasonNumber
|
||
);
|
||
if (targetSeason) {
|
||
const targetEpisode = targetSeason.episodes?.find(
|
||
(episode) =>
|
||
episode.videoPath === selectedVideo.episode?.videoPath
|
||
);
|
||
if (targetEpisode) {
|
||
selectedSeason = targetSeason;
|
||
selectedEpisode = targetEpisode;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Video element'in yüklendiğinden emin ol
|
||
await tick();
|
||
setTimeout(() => {
|
||
if (videoEl) {
|
||
console.log("Video element found after timeout:", videoEl);
|
||
console.log("Video src:", videoEl.src);
|
||
console.log("Video readyState:", videoEl.readyState);
|
||
} else {
|
||
console.error("Video element not found after timeout");
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function stopCurrentVideo() {
|
||
if (videoEl) {
|
||
try {
|
||
videoEl.pause();
|
||
videoEl.src = "";
|
||
videoEl.load();
|
||
} catch (err) {
|
||
console.warn("Video stop error:", err?.message || err);
|
||
}
|
||
}
|
||
}
|
||
|
||
function closePlayer() {
|
||
stopCurrentVideo();
|
||
showPlayerModal = false;
|
||
selectedVideo = null;
|
||
subtitleURL = null;
|
||
isPlaying = false;
|
||
seasonPlaylist = [];
|
||
seasonPlaylistIndex = -1;
|
||
canPlayPrev = false;
|
||
canPlayNext = false;
|
||
}
|
||
|
||
function getVideoURL() {
|
||
if (!selectedName) return "";
|
||
const token = localStorage.getItem("token");
|
||
const encoded = encodePathSegments(selectedName);
|
||
if (!encoded) return "";
|
||
return `${API}/media/${encoded}?token=${token}`;
|
||
}
|
||
|
||
function playEpisodeFromCard(episode) {
|
||
if (!episode) return;
|
||
selectEpisode(episode);
|
||
handlePlayEpisode(episode);
|
||
}
|
||
|
||
function openSeasonNeighbor(offset) {
|
||
updateSeasonPlaylist();
|
||
if (!seasonPlaylist.length) return;
|
||
const target = seasonPlaylist[seasonPlaylistIndex + offset];
|
||
if (!target) return;
|
||
const idx = playerItems.findIndex(
|
||
(item) =>
|
||
item.episode?.videoPath === target.episode?.videoPath &&
|
||
(item.show?.id || item.show?.title) ===
|
||
(target.show?.id || target.show?.title)
|
||
);
|
||
if (idx !== -1) {
|
||
openVideoAtIndex(idx);
|
||
}
|
||
}
|
||
|
||
function playPrevEpisode() {
|
||
if (!canPlayPrev) return;
|
||
openSeasonNeighbor(-1);
|
||
}
|
||
|
||
function playNextEpisode() {
|
||
if (!canPlayNext) return;
|
||
openSeasonNeighbor(1);
|
||
}
|
||
|
||
function handleSeasonButtonKey(event, seasonNumber) {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
selectSeasonNumber(seasonNumber);
|
||
}
|
||
}
|
||
|
||
async function togglePlay() {
|
||
if (!videoEl) return;
|
||
if (videoEl.paused) {
|
||
try {
|
||
await videoEl.play();
|
||
isPlaying = true;
|
||
} catch (err) {
|
||
console.warn("Play rejected:", err?.message || err);
|
||
isPlaying = false;
|
||
}
|
||
} else {
|
||
videoEl.pause();
|
||
isPlaying = false;
|
||
}
|
||
}
|
||
|
||
function updateProgress() {
|
||
currentTime = videoEl?.currentTime || 0;
|
||
}
|
||
|
||
function updateDuration() {
|
||
duration = videoEl?.duration || 0;
|
||
}
|
||
|
||
function seekVideo(event) {
|
||
if (!videoEl) return;
|
||
const value = Number(event.target.value);
|
||
videoEl.currentTime = value;
|
||
currentTime = value;
|
||
}
|
||
|
||
function changeVolume(event) {
|
||
const value = Number(event.target.value);
|
||
volume = value;
|
||
if (videoEl) videoEl.volume = value;
|
||
event.target.style.setProperty("--fill", value * 100);
|
||
}
|
||
|
||
function toggleFullscreen() {
|
||
if (!videoEl) return;
|
||
if (document.fullscreenElement) document.exitFullscreen();
|
||
else videoEl.requestFullscreen();
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (!bytes) return "0 MB";
|
||
if (bytes < 1e6) return (bytes / 1e3).toFixed(1) + " KB";
|
||
if (bytes < 1e9) return (bytes / 1e6).toFixed(1) + " MB";
|
||
return (bytes / 1e9).toFixed(2) + " GB";
|
||
}
|
||
|
||
function formatTime(seconds) {
|
||
const m = Math.floor(seconds / 60)
|
||
.toString()
|
||
.padStart(2, "0");
|
||
const s = Math.floor(seconds % 60)
|
||
.toString()
|
||
.padStart(2, "0");
|
||
return `${m}:${s}`;
|
||
}
|
||
|
||
function handleSubtitleUpload(event) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
const ext = file.name.split(".").pop().toLowerCase();
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
const decoder = new TextDecoder("utf-8");
|
||
const content =
|
||
typeof ev.target.result === "string"
|
||
? ev.target.result
|
||
: decoder.decode(ev.target.result);
|
||
if (ext === "srt") {
|
||
const vttText =
|
||
"\uFEFFWEBVTT\n\n" + content.replace(/\r+/g, "").replace(/,/g, ".");
|
||
const blob = new Blob([vttText], { type: "text/vtt;charset=utf-8" });
|
||
subtitleURL = URL.createObjectURL(blob);
|
||
} else if (ext === "vtt") {
|
||
const blob = new Blob([content], { type: "text/vtt;charset=utf-8" });
|
||
subtitleURL = URL.createObjectURL(blob);
|
||
} else {
|
||
alert("Yalnızca .srt veya .vtt dosyaları destekleniyor.");
|
||
}
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
}
|
||
|
||
const TEXT_INPUT_TYPES = new Set([
|
||
"text",
|
||
"search",
|
||
"email",
|
||
"password",
|
||
"number",
|
||
"url",
|
||
"tel"
|
||
]);
|
||
|
||
function isEditableTarget(target) {
|
||
if (!target) return false;
|
||
const tag = target.tagName;
|
||
const type = target.type?.toLowerCase();
|
||
if (tag === "TEXTAREA") return true;
|
||
if (tag === "INPUT" && TEXT_INPUT_TYPES.has(type)) return true;
|
||
return Boolean(target.isContentEditable);
|
||
}
|
||
|
||
$: if (videoEl) {
|
||
videoEl.volume = volume;
|
||
}
|
||
|
||
onMount(() => {
|
||
setSearchScope("tv");
|
||
loadShows();
|
||
const handleKey = (event) => {
|
||
if (!showPlayerModal) return;
|
||
if (isEditableTarget(event.target)) return;
|
||
|
||
const isCmd = event.metaKey || event.ctrlKey;
|
||
if (isCmd && event.key.toLowerCase() === "a") {
|
||
// Text input'larda çalıştırma
|
||
if (isEditableTarget(event.target)) return;
|
||
event.preventDefault();
|
||
// TvShows sayfasında tümünü seçme işlevi yok, sadece engelleme yapıyoruz
|
||
return;
|
||
}
|
||
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
closePlayer();
|
||
} else if (event.key === " " || event.key === "Spacebar") {
|
||
event.preventDefault();
|
||
togglePlay();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleKey);
|
||
return () => window.removeEventListener("keydown", handleKey);
|
||
});
|
||
</script>
|
||
|
||
<section class="tv-shows">
|
||
<div class="section-accent"></div>
|
||
<div class="tv-header">
|
||
<h2>Tv Shows</h2>
|
||
<div class="header-actions">
|
||
<button
|
||
class="refresh-btn"
|
||
disabled={loading || refreshing || rescanning}
|
||
on:click={rescanShows}
|
||
>
|
||
{rescanning ? "Rebuilding…" : "Full Rescan"}
|
||
</button>
|
||
<button
|
||
class="refresh-btn"
|
||
disabled={loading || refreshing || rescanning}
|
||
on:click={refreshShows}
|
||
>
|
||
{refreshing ? "Refreshing…" : "Refresh Metadata"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{#if loading}
|
||
<div class="state-placeholder">Loading shows…</div>
|
||
{:else if error}
|
||
<div class="state-placeholder error">{error}</div>
|
||
{:else if shows.length === 0}
|
||
<div class="state-placeholder">No TV metadata found yet.</div>
|
||
{:else if hasSearch && filteredShows.length === 0}
|
||
<div class="state-placeholder">Aramanıza uyan dizi bulunamadı.</div>
|
||
{:else}
|
||
<div class="tv-grid">
|
||
{#each filteredShows as show}
|
||
<div class="tv-card" on:click={() => openShow(show)}>
|
||
{#if show.poster}
|
||
<div class="poster-wrapper">
|
||
<img
|
||
src={posterUrl(show)}
|
||
alt={show.title}
|
||
loading="lazy"
|
||
class="poster-img"
|
||
/>
|
||
</div>
|
||
{:else}
|
||
<div class="poster-wrapper no-poster">
|
||
<i class="fa-regular fa-image"></i>
|
||
</div>
|
||
{/if}
|
||
<div class="tv-meta">
|
||
<div class="tv-title">{show.title}</div>
|
||
{#if show.year}
|
||
<div class="tv-year">{show.year}</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
{#if selectedShow}
|
||
<div class="tv-overlay" on:click={closeShow}>
|
||
<div
|
||
class="tv-overlay-backdrop"
|
||
style={`background-image: ${
|
||
backdropUrl(selectedShow)
|
||
? `linear-gradient(180deg, rgba(8, 8, 8, 0.28) 0%, rgba(12, 12, 12, 0.48) 60%, rgba(10, 10, 10, 0.58) 100%), url(${backdropUrl(selectedShow)})`
|
||
: "linear-gradient(180deg, rgba(12,12,12,0.45) 0%, rgba(12,12,12,0.55) 100%)"
|
||
};`}
|
||
></div>
|
||
<div class="tv-overlay-content" on:click|stopPropagation>
|
||
<button class="detail-close" on:click={closeShow} aria-label="Close">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
<div class="detail-body">
|
||
<div class="detail-poster">
|
||
{#if selectedShow.poster}
|
||
<img
|
||
src={posterUrl(selectedShow)}
|
||
alt={selectedShow.title}
|
||
class="detail-poster-img"
|
||
/>
|
||
{:else}
|
||
<div class="detail-poster-placeholder">
|
||
<i class="fa-regular fa-image"></i>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<div class="detail-info">
|
||
<h3 class="detail-title">{selectedShow.title}</h3>
|
||
<div class="detail-submeta">
|
||
{#if selectedShow.year}
|
||
<span>{selectedShow.year}</span>
|
||
{/if}
|
||
{#if selectedShow.status}
|
||
<span>• {selectedShow.status}</span>
|
||
{/if}
|
||
{#if selectedSeason}
|
||
<span>
|
||
•
|
||
{selectedSeason.name || `Season ${selectedSeason.seasonNumber}`}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
{#if selectedShow.genres?.length}
|
||
<div class="detail-genres">
|
||
{selectedShow.genres.join(" • ")}
|
||
</div>
|
||
{/if}
|
||
<div class="detail-overview">
|
||
{selectedShow.overview || "No synopsis found."}
|
||
</div>
|
||
{#if selectedRuntime || selectedVideoInfo || selectedAudioInfo || selectedAirDate}
|
||
<div class="detail-tech">
|
||
{#if selectedRuntime}
|
||
<div class="detail-tech-item">
|
||
<i class="fa-regular fa-clock"></i>
|
||
<span>{selectedRuntime}</span>
|
||
</div>
|
||
{/if}
|
||
{#if selectedAirDate}
|
||
<div class="detail-tech-item">
|
||
<i class="fa-regular fa-calendar"></i>
|
||
<span>{selectedAirDate}</span>
|
||
</div>
|
||
{/if}
|
||
{#if selectedVideoInfo}
|
||
<div class="detail-tech-item">
|
||
<i class="fa-solid fa-film"></i>
|
||
<span>{selectedVideoInfo}</span>
|
||
</div>
|
||
{/if}
|
||
{#if selectedAudioInfo}
|
||
<div class="detail-tech-item">
|
||
<i class="fa-solid fa-volume-high"></i>
|
||
<span>{selectedAudioInfo}</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
<!-- Bölüm oynatma kartlar üzerinden sağlanır -->
|
||
</div>
|
||
</div>
|
||
|
||
{#if selectedShow.seasons?.length}
|
||
<div class="season-picker" role="tablist" aria-label="Seasons">
|
||
{#each selectedShow.seasons as season}
|
||
<button
|
||
class:selected={selectedSeason?.seasonNumber === season.seasonNumber}
|
||
on:click={() => selectSeasonNumber(season.seasonNumber)}
|
||
on:keydown={(event) =>
|
||
handleSeasonButtonKey(event, season.seasonNumber)}
|
||
role="tab"
|
||
aria-selected={selectedSeason?.seasonNumber === season.seasonNumber}
|
||
>
|
||
<span class="season-label">
|
||
{season.name || `Season ${season.seasonNumber}`}
|
||
</span>
|
||
{#if season.episodeCount}
|
||
<span class="season-count">{season.episodeCount} eps</span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="episode-list">
|
||
{#if selectedSeason?.episodes?.length}
|
||
{#each selectedSeason.episodes as episode}
|
||
<div
|
||
class="episode-card"
|
||
class:active={selectedEpisode === episode}
|
||
on:click={() => playEpisodeFromCard(episode)}
|
||
>
|
||
<div class="episode-still">
|
||
{#if episode.still}
|
||
<img
|
||
src={stillUrl(episode)}
|
||
alt={`${selectedShow.title} ${formatEpisodeCode(episode)}`}
|
||
loading="lazy"
|
||
/>
|
||
{:else}
|
||
<div class="episode-still-placeholder">
|
||
<i class="fa-regular fa-image"></i>
|
||
</div>
|
||
{/if}
|
||
<div class="episode-still-overlay">
|
||
<i class="fa-regular fa-circle-play"></i>
|
||
</div>
|
||
</div>
|
||
<div class="episode-info">
|
||
<div class="episode-title">
|
||
{formatEpisodeCode(episode)} · {episode.title || "Untitled"}
|
||
</div>
|
||
<div class="episode-meta">
|
||
{#if episodeRuntime(episode)}
|
||
<span>
|
||
<i class="fa-regular fa-clock"></i>
|
||
{episodeRuntime(episode)}
|
||
</span>
|
||
{/if}
|
||
{#if formatAirDate(episode.aired)}
|
||
<span>
|
||
<i class="fa-regular fa-calendar"></i>
|
||
{formatAirDate(episode.aired)}
|
||
</span>
|
||
{/if}
|
||
{#if formatVideoInfo(episode.mediaInfo?.video)}
|
||
<span>
|
||
<i class="fa-solid fa-film"></i>
|
||
{formatVideoInfo(episode.mediaInfo.video)}
|
||
</span>
|
||
{/if}
|
||
{#if formatAudioInfo(episode.mediaInfo?.audio)}
|
||
<span>
|
||
<i class="fa-solid fa-volume-high"></i>
|
||
{formatAudioInfo(episode.mediaInfo.audio)}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
<div class="episode-overview">
|
||
{episode.overview || "No overview available."}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
{:else}
|
||
<div class="episode-placeholder">No episodes found for this season.</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if showPlayerModal && selectedVideo}
|
||
<div class="modal-overlay" on:click={closePlayer}>
|
||
<button class="global-close-btn" on:click|stopPropagation={closePlayer}>
|
||
✕
|
||
</button>
|
||
<div class="modal-content" on:click|stopPropagation>
|
||
<div class="modal-header">
|
||
<div class="video-title">{selectedLabel}</div>
|
||
<div class="video-meta">
|
||
<span>{formatSize(selectedVideo.size || 0)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="custom-player">
|
||
{#key encName}
|
||
<video
|
||
bind:this={videoEl}
|
||
src={getVideoURL()}
|
||
class="video-element"
|
||
playsinline
|
||
controls={false}
|
||
preload="metadata"
|
||
crossorigin="anonymous"
|
||
type={selectedVideo?.type || "video/mp4"}
|
||
on:timeupdate={updateProgress}
|
||
on:loadedmetadata={async () => {
|
||
console.log("Video metadata loaded");
|
||
isPlaying = false;
|
||
currentTime = 0;
|
||
updateDuration();
|
||
const slider = document.querySelector(".volume-slider");
|
||
if (slider) {
|
||
slider.value = volume;
|
||
slider.style.setProperty("--fill", slider.value * 100);
|
||
}
|
||
try {
|
||
await videoEl.play();
|
||
isPlaying = true;
|
||
} catch (err) {
|
||
console.warn("Autoplay engellendi:", err?.message || err);
|
||
isPlaying = false;
|
||
}
|
||
}}
|
||
on:loadeddata={() => {
|
||
console.log("Video data loaded");
|
||
}}
|
||
on:canplay={() => {
|
||
console.log("Video can play");
|
||
}}
|
||
on:error={(e) => {
|
||
console.error("Video error:", e);
|
||
}}
|
||
on:ended={() => (isPlaying = false)}
|
||
on:loadstart={() => {
|
||
console.log("Video load start");
|
||
}}
|
||
on:canplaythrough={() => {
|
||
console.log("Video can play through");
|
||
}}
|
||
on:stalled={() => {
|
||
console.log("Video stalled");
|
||
}}
|
||
on:suspend={() => {
|
||
console.log("Video suspended");
|
||
}}
|
||
on:abort={() => {
|
||
console.log("Video aborted");
|
||
}}
|
||
on:emptied={() => {
|
||
console.log("Video emptied");
|
||
}}
|
||
on:waiting={() => {
|
||
console.log("Video waiting");
|
||
}}
|
||
>
|
||
{#if subtitleURL}
|
||
<track
|
||
kind="subtitles"
|
||
src={subtitleURL}
|
||
label={subtitleLabel}
|
||
default
|
||
/>
|
||
{/if}
|
||
</video>
|
||
{/key}
|
||
<div class="controls">
|
||
<div class="top-controls">
|
||
<div class="left-controls">
|
||
<button class="control-btn" on:click={togglePlay}>
|
||
{#if isPlaying}
|
||
<i class="fa-solid fa-pause"></i>
|
||
{:else}
|
||
<i class="fa-solid fa-play"></i>
|
||
{/if}
|
||
</button>
|
||
<button
|
||
class="control-btn"
|
||
on:click={playPrevEpisode}
|
||
disabled={!canPlayPrev}
|
||
title="Previous episode"
|
||
aria-label="Previous episode"
|
||
>
|
||
<i class="fa-solid fa-backward-step"></i>
|
||
</button>
|
||
<button
|
||
class="control-btn"
|
||
on:click={playNextEpisode}
|
||
disabled={!canPlayNext}
|
||
title="Next episode"
|
||
aria-label="Next episode"
|
||
>
|
||
<i class="fa-solid fa-forward-step"></i>
|
||
</button>
|
||
</div>
|
||
<div class="right-controls">
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
bind:value={volume}
|
||
on:input={changeVolume}
|
||
class="volume-slider"
|
||
/>
|
||
<button class="control-btn" on:click={toggleFullscreen}>
|
||
<i class="fa-solid fa-expand"></i>
|
||
</button>
|
||
<a
|
||
href={downloadHref}
|
||
download={selectedName || undefined}
|
||
class="control-btn"
|
||
title="Download"
|
||
>
|
||
<i class="fa-solid fa-download"></i>
|
||
</a>
|
||
<label class="control-btn subtitle-icon" title="Add subtitles">
|
||
<i class="fa-solid fa-closed-captioning"></i>
|
||
<input
|
||
type="file"
|
||
accept=".srt,.vtt"
|
||
on:change={handleSubtitleUpload}
|
||
style="display: none"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="bottom-controls">
|
||
<span class="time">
|
||
{formatTime(currentTime)} / {formatTime(duration)}
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={duration}
|
||
step="0.1"
|
||
bind:value={currentTime}
|
||
on:input={seekVideo}
|
||
class="progress-slider"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.tv-shows {
|
||
padding: 20px 26px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
|
||
.section-accent {
|
||
height: 2px;
|
||
width: calc(100% + 52px);
|
||
background: var(--yellow, #f5b333);
|
||
border-radius: 999px;
|
||
margin: -20px -26px 18px;
|
||
}
|
||
|
||
.tv-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.tv-header h2 {
|
||
font-size: 26px;
|
||
margin: 0;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.refresh-btn {
|
||
background: #2e2e2e;
|
||
color: #f5f5f5;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 8px 14px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
.refresh-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: default;
|
||
}
|
||
|
||
.refresh-btn:not(:disabled):hover {
|
||
background: #3a3a3a;
|
||
}
|
||
|
||
.state-placeholder {
|
||
padding: 40px 0;
|
||
color: #7a7a7a;
|
||
text-align: center;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.state-placeholder.error {
|
||
color: #d9534f;
|
||
}
|
||
|
||
.tv-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
gap: 18px;
|
||
}
|
||
|
||
.tv-card {
|
||
background: #f7f7f7;
|
||
border-radius: 12px;
|
||
padding: 12px 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
cursor: pointer;
|
||
border: 1px solid #e1e1e1;
|
||
transition:
|
||
border-color 0.2s ease,
|
||
transform 0.2s ease,
|
||
background 0.2s ease;
|
||
}
|
||
|
||
.tv-card:hover {
|
||
border-color: #cfcfcf;
|
||
background: #f0f0f0;
|
||
}
|
||
|
||
.poster-wrapper {
|
||
width: 100%;
|
||
aspect-ratio: 2 / 3;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: #ececec;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.poster-wrapper.no-poster {
|
||
color: #9a9a9a;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.poster-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.tv-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tv-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1c1c1c;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.tv-year {
|
||
font-size: 13px;
|
||
color: #777;
|
||
}
|
||
|
||
.tv-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 4000;
|
||
background: rgba(10, 10, 10, 0.32);
|
||
backdrop-filter: blur(4px);
|
||
padding: 52px 28px;
|
||
}
|
||
|
||
.tv-overlay-backdrop {
|
||
position: absolute;
|
||
inset: 0;
|
||
background-size: cover;
|
||
background-position: center;
|
||
filter: blur(12px);
|
||
opacity: 1;
|
||
}
|
||
|
||
.tv-overlay-content {
|
||
position: relative;
|
||
width: min(1040px, 94vw);
|
||
max-height: 95vh;
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
background: rgba(12, 12, 12, 0.5);
|
||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
padding: 28px;
|
||
color: #f7f7f7;
|
||
}
|
||
|
||
.detail-close {
|
||
position: absolute;
|
||
top: 18px;
|
||
right: 18px;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
color: #f5f5f5;
|
||
border: none;
|
||
border-radius: 50%;
|
||
width: 36px;
|
||
height: 36px;
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
.detail-close:hover {
|
||
background: rgba(0, 0, 0, 0.65);
|
||
}
|
||
|
||
.detail-body {
|
||
display: flex;
|
||
gap: 24px;
|
||
}
|
||
|
||
.detail-poster {
|
||
flex: 0 0 183px;
|
||
}
|
||
|
||
.detail-poster-img {
|
||
width: 100%;
|
||
border-radius: 14px;
|
||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.detail-poster-placeholder {
|
||
width: 100%;
|
||
aspect-ratio: 2 / 3;
|
||
border-radius: 14px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 32px;
|
||
color: rgba(255, 255, 255, 0.35);
|
||
}
|
||
|
||
.detail-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.detail-title {
|
||
margin: 0;
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detail-submeta {
|
||
display: flex;
|
||
gap: 8px;
|
||
font-size: 15px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.detail-genres {
|
||
font-size: 14px;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
|
||
.detail-overview {
|
||
font-size: 15px;
|
||
line-height: 1.6;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
}
|
||
|
||
.detail-tech {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-tech-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
padding: 6px 12px;
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.season-picker {
|
||
display: flex;
|
||
gap: 12px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
padding: 12px;
|
||
scrollbar-width: none; /* Scroll bar'ı tamamen gizle */
|
||
-ms-overflow-style: none; /* IE ve Edge için */
|
||
background: rgba(0, 0, 0, 0.25);
|
||
border-radius: 14px;
|
||
min-height: 80px;
|
||
}
|
||
|
||
/* Scroll bar'ı webkit tarayıcılar için gizle */
|
||
.season-picker::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.season-picker::-webkit-scrollbar-track {
|
||
display: none;
|
||
}
|
||
|
||
.season-picker::-webkit-scrollbar-thumb {
|
||
display: none;
|
||
}
|
||
|
||
.season-picker button {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: #f5f5f5;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 14px;
|
||
padding: 12px 16px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease, border-color 0.2s ease;
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 30px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.season-picker button.selected {
|
||
background: #f5b333;
|
||
color: #101010;
|
||
border-color: rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.season-label {
|
||
font-weight: 600;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
color: inherit;
|
||
}
|
||
|
||
.season-count {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
}
|
||
|
||
.season-picker::-webkit-scrollbar {
|
||
height: 6px;
|
||
}
|
||
|
||
.season-picker::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.episode-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
max-height: 520px;
|
||
overflow-y: auto;
|
||
padding-right: 6px;
|
||
padding-left: 2px;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
border-radius: 16px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(255, 255, 255, 0.3) rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.episode-list::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.episode-list::-webkit-scrollbar-track {
|
||
background: rgba(0, 0, 0, 0.1);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.episode-list::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.episode-list::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.episode-card {
|
||
display: flex;
|
||
gap: 16px;
|
||
padding: 12px;
|
||
border-radius: 16px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.2s ease,
|
||
background 0.2s ease,
|
||
transform 0.2s ease;
|
||
}
|
||
|
||
.episode-card:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.episode-card.active {
|
||
border-color: rgba(245, 179, 51, 0.75);
|
||
background: rgba(245, 179, 51, 0.12);
|
||
}
|
||
|
||
.episode-still {
|
||
position: relative;
|
||
flex: 0 0 180px;
|
||
aspect-ratio: 16 / 9;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
.episode-still img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.episode-still-placeholder {
|
||
color: rgba(255, 255, 255, 0.4);
|
||
font-size: 26px;
|
||
}
|
||
|
||
.episode-still-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
background: rgba(0, 0, 0, 0.25);
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.episode-card:hover .episode-still-overlay,
|
||
.episode-card.active .episode-still-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
.episode-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.episode-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.episode-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
|
||
.episode-meta span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.episode-overview {
|
||
font-size: 14px;
|
||
color: rgba(255, 255, 255, 0.78);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.episode-placeholder {
|
||
text-align: center;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
padding: 20px;
|
||
}
|
||
|
||
/* 🎞️ Film oynatıcı ile aynı düzen */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
backdrop-filter: blur(4px);
|
||
background: rgba(0, 0, 0, 0.35);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 6000;
|
||
}
|
||
|
||
.modal-content {
|
||
width: 70%;
|
||
height: 70%;
|
||
background: #1a1a1a;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #2a2a2a;
|
||
padding: 10px 16px;
|
||
color: #fff;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.video-title {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.video-meta {
|
||
font-size: 13px;
|
||
color: #ccc;
|
||
}
|
||
|
||
.custom-player {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
min-height: 0;
|
||
background: #000;
|
||
}
|
||
|
||
.video-element {
|
||
flex: 1 1 auto;
|
||
width: 100%;
|
||
height: auto;
|
||
max-height: 100%;
|
||
min-height: 0;
|
||
object-fit: contain;
|
||
background: #000;
|
||
border: none;
|
||
outline: none;
|
||
}
|
||
|
||
.video-element:focus {
|
||
outline: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
.controls {
|
||
background: #1c1c1c;
|
||
padding: 10px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
border-top: 1px solid #333;
|
||
}
|
||
|
||
.top-controls {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.left-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.control-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #fff;
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.control-btn[disabled] {
|
||
opacity: 0.35;
|
||
cursor: default;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.right-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.volume-slider {
|
||
-webkit-appearance: none;
|
||
width: 100px;
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
background: linear-gradient(
|
||
to right,
|
||
#ff3b30 calc(var(--fill, 100%) * 1%),
|
||
rgba(255, 255, 255, 0.3) calc(var(--fill, 100%) * 1%)
|
||
);
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
.volume-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
margin-top: -4px;
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.volume-slider::-webkit-slider-thumb:hover {
|
||
transform: scale(1.3);
|
||
}
|
||
|
||
.bottom-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.progress-slider {
|
||
flex: 1;
|
||
cursor: pointer;
|
||
accent-color: #27ae60;
|
||
}
|
||
|
||
.time {
|
||
color: #ccc;
|
||
font-size: 13px;
|
||
min-width: 90px;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
@media (max-width: 860px) {
|
||
.detail-body {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.detail-poster {
|
||
align-self: center;
|
||
}
|
||
|
||
.episode-card {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.episode-still {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
/* Responsive design for smaller screens */
|
||
@media (max-width: 1200px) {
|
||
.tv-overlay-content {
|
||
width: min(920px, 92vw);
|
||
padding: 24px;
|
||
gap: 20px;
|
||
}
|
||
|
||
.detail-poster {
|
||
flex: 0 0 72px;
|
||
}
|
||
|
||
.episode-list {
|
||
max-height: 440px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.tv-overlay {
|
||
padding: 20px 16px;
|
||
}
|
||
|
||
.tv-overlay-content {
|
||
width: min(100%, 96vw);
|
||
max-height: 95vh;
|
||
padding: 20px;
|
||
gap: 16px;
|
||
}
|
||
|
||
.detail-body {
|
||
gap: 16px;
|
||
}
|
||
|
||
.detail-poster {
|
||
flex: 0 0 58px;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 24px;
|
||
}
|
||
|
||
.detail-submeta {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.detail-overview {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.episode-list {
|
||
max-height: 360px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.episode-card {
|
||
padding: 10px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.episode-still {
|
||
flex: 0 0 140px;
|
||
}
|
||
|
||
.episode-title {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.episode-meta {
|
||
font-size: 12px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.episode-overview {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.season-picker {
|
||
gap: 8px;
|
||
padding: 12px;
|
||
min-height: 70px;
|
||
}
|
||
|
||
.season-picker button {
|
||
padding: 6px 12px;
|
||
min-height: 34px;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.tv-overlay {
|
||
padding: 16px 12px;
|
||
}
|
||
|
||
.tv-overlay-content {
|
||
width: 100%;
|
||
max-height: 98vh;
|
||
padding: 16px;
|
||
gap: 14px;
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.detail-body {
|
||
gap: 14px;
|
||
}
|
||
|
||
.detail-poster {
|
||
flex: 0 0 43px;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.detail-submeta {
|
||
font-size: 13px;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.detail-overview {
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.detail-tech {
|
||
gap: 8px;
|
||
}
|
||
|
||
.detail-tech-item {
|
||
padding: 4px 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.episode-list {
|
||
max-height: 320px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.episode-card {
|
||
padding: 8px;
|
||
gap: 10px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.episode-still {
|
||
flex: 0 0 100px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.episode-title {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.episode-meta {
|
||
font-size: 11px;
|
||
gap: 6px;
|
||
}
|
||
|
||
.episode-overview {
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.season-picker {
|
||
gap: 6px;
|
||
padding: 10px;
|
||
min-height: 64px;
|
||
}
|
||
|
||
.season-picker button {
|
||
padding: 4px 10px;
|
||
min-height: 18px;
|
||
font-size: 12px;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.season-label {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.season-count {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
</style>
|