Files
dupe/client/src/routes/Anime.svelte
wisecolt 41c602104e feat(anime): anime yönetimi ve arayüzü ekle
Kullanıcı arayüzünde Anime sekmesi ve oynatıcı entegrasyonu eklendi.
Sunucu tarafında Anime için özel bir veri yapısı ve API uç noktaları oluşturuldu.

- Anime içerikleri için `_anime` klasöründe ayrı metadata saklama alanı eklendi.
- Kök dizindeki (root) dosyaların çöpe taşınması ve geri yüklenmesi için
  'root-trash' sistemi tanımlandı.
- TVDB sorgularında Anime için İngilizce dil tercihi uygulandı.
- Mail.ru indirmelerinde anime kapsamı (scope) desteği eklendi.
2026-01-28 21:48:18 +03:00

1871 lines
46 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount, tick } from "svelte";
import { API, apiFetch } from "../utils/api.js";
import { cleanFileName } from "../utils/filename.js";
import { animeCount } from "../stores/animeStore.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/anime");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const list = await resp.json();
shows = Array.isArray(list) ? list.map(normalizeShow) : [];
animeCount.set(shows.length);
} catch (err) {
error = err?.message || "Anime alınamadı.";
shows = [];
animeCount.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/anime/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/anime/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("anime");
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="anime">
<div class="section-accent"></div>
<div class="tv-header">
<h2>Anime</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>
.anime {
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>