Files
dupe/client/src/routes/Files.svelte
2025-10-29 12:26:25 +03:00

1850 lines
50 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 { refreshMovieCount } from "../stores/movieStore.js";
import { refreshTvShowCount } from "../stores/tvStore.js";
let files = [];
let showModal = false;
let selectedVideo = null;
let subtitleURL = null;
let subtitleLang = "en";
let subtitleLabel = "Custom Subtitles";
const VIEW_KEY = "filesViewMode";
let viewMode = "grid";
if (typeof window !== "undefined") {
const storedView = window.localStorage.getItem(VIEW_KEY);
if (storedView === "grid" || storedView === "list") {
viewMode = storedView;
}
}
let selectedItems = new Set();
let allSelected = false;
let pendingPlayTarget = null;
let activeMenu = null; // Aktif menünün dosya adını tutar
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
const playParam = params.get("play");
if (playParam) {
try {
pendingPlayTarget = decodeURIComponent(playParam);
} catch (err) {
pendingPlayTarget = playParam;
}
params.delete("play");
const search = params.toString();
const newUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
window.history.replaceState({}, "", newUrl);
}
}
// 🎬 Player kontrolleri
let videoEl;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let volume = 1;
let currentIndex;
let showImageModal = false;
let selectedImage = null;
// ✅ REACTIVE: selectedVideo güvenli kullanımlar
$: selectedName = selectedVideo?.name ?? "";
$: encName = encodeURIComponent(selectedName);
// ✅ Token'lı video URL'ini fonksiyonla üret (başta çağrılmasın)
function getVideoURL() {
if (!selectedName) return "";
const token = localStorage.getItem("token");
return `${API}/media/${encName}?token=${token}`;
}
// 📂 Dosyaları yükle (tokenlı)
async function loadFiles() {
const r = await apiFetch("/api/files");
if (!r.ok) return;
files = await r.json();
const existing = new Set(files.map((f) => f.name));
selectedItems = new Set(
[...selectedItems].filter((name) => existing.has(name)),
);
allSelected = files.length > 0 && selectedItems.size === files.length;
tryAutoPlay();
refreshMovieCount();
refreshTvShowCount();
}
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 formatTracker(value) {
if (!value) return "Bilinmiyor";
const raw = String(value).trim();
if (!raw) return "Bilinmiyor";
try {
const url = new URL(raw);
const host = url.hostname.replace(/^tracker\./i, "");
return host || raw;
} catch (err) {
const stripped = raw.replace(/^.*?:\/\//, "").replace(/\/.*$/, "");
return stripped.replace(/^tracker\./i, "") || raw;
}
}
function formatDateTime(value) {
if (!value) return "—";
const date = new Date(Number(value));
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString();
}
function formatVideoCodec(info) {
if (!info) return null;
const codec = info.codec ? info.codec.toUpperCase() : null;
const resolution =
info.resolution || (info.height ? `${info.height}p` : null);
return [codec, resolution].filter(Boolean).join(" · ");
}
function formatAudioCodec(info) {
if (!info) return null;
const codec = info.codec ? info.codec.toUpperCase() : null;
let channels = null;
if (info.channelLayout) channels = info.channelLayout.toUpperCase();
else if (info.channels) {
channels =
info.channels === 6
? "5.1"
: info.channels === 2
? "2.0"
: `${info.channels}`;
}
return [codec, channels].filter(Boolean).join(" · ");
}
function toggleView() {
viewMode = viewMode === "grid" ? "list" : "grid";
if (typeof window !== "undefined") {
window.localStorage.setItem(VIEW_KEY, viewMode);
}
}
function toggleSelection(file) {
const next = new Set(selectedItems);
if (next.has(file.name)) next.delete(file.name);
else next.add(file.name);
selectedItems = next;
allSelected = files.length > 0 && next.size === files.length;
}
function selectAll() {
if (allSelected) {
selectedItems = new Set();
allSelected = false;
} else {
selectedItems = new Set(files.map((f) => f.name));
allSelected = files.length > 0;
}
}
function handleFilesClick(event) {
if (selectedItems.size === 0) return;
const card = event.target.closest(".media-card");
const header = event.target.closest(".header-actions");
if (header) return;
if (card) return;
selectedItems = new Set();
allSelected = false;
}
function tryAutoPlay() {
if (!pendingPlayTarget || files.length === 0) return;
const normalizedTarget = pendingPlayTarget
.replace(/^\.?\//, "")
.replace(/\\/g, "/");
const candidate =
files.find((f) => {
const normalizedName = f.name
.replace(/^\.?\//, "")
.replace(/\\/g, "/");
return (
normalizedName === normalizedTarget ||
normalizedName.endsWith(normalizedTarget)
);
}) || null;
if (candidate) {
pendingPlayTarget = null;
openModal(candidate);
}
}
async function openModal(f) {
stopCurrentVideo();
videoEl = null;
isPlaying = false;
currentTime = 0;
duration = 0;
subtitleURL = null; // ← eklendi
const index = files.findIndex((file) => file.name === f.name);
currentIndex = index;
if (f.type?.startsWith("video/")) {
selectedImage = null;
showImageModal = false;
selectedVideo = f;
await tick(); // DOM güncellensin
showModal = true; // video {#key} ile yeniden mount edilecek
} else if (f.type?.startsWith("image/")) {
selectedVideo = null;
showModal = false;
selectedImage = f;
await tick();
showImageModal = true;
}
}
function stopCurrentVideo() {
if (videoEl) {
try {
videoEl.pause();
videoEl.src = "";
videoEl.load();
} catch (err) {
console.warn("Video stop error:", err.message);
}
}
}
async function showNext() {
if (files.length === 0) return;
stopCurrentVideo();
currentIndex = (currentIndex + 1) % files.length;
await openModal(files[currentIndex]); // ← await
}
async function showPrev() {
if (files.length === 0) return;
stopCurrentVideo();
currentIndex = (currentIndex - 1 + files.length) % files.length;
await openModal(files[currentIndex]); // ← await
}
function closeModal() {
stopCurrentVideo(); // 🔴 video tamamen durur
showModal = false;
selectedVideo = null;
subtitleURL = null;
isPlaying = false;
}
// 🎞️ Video kontrolleri
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(e) {
if (!videoEl) return;
const newTime = parseFloat(e.target.value);
if (Math.abs(videoEl.currentTime - newTime) > 0.2) {
videoEl.currentTime = newTime;
}
}
function changeVolume(e) {
if (!videoEl) return;
const val = parseFloat(e.target.value);
videoEl.volume = val;
e.target.style.setProperty("--fill", (val || 0) * 100);
}
function toggleFullscreen() {
if (!videoEl) return;
if (document.fullscreenElement) document.exitFullscreen();
else videoEl.requestFullscreen();
}
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(e) {
const file = e.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);
}
async function deleteSelectedFiles() {
if (selectedItems.size === 0) return;
if (!confirm(`${selectedItems.size} öğeyi silmek istediğine emin misin?`))
return;
const token = localStorage.getItem("token");
const names = [...selectedItems];
const failed = [];
for (const name of names) {
const file = files.find((f) => f.name === name);
if (!file) continue;
try {
const resp = await fetch(
`${API}/api/file?path=${encodeURIComponent(file.name)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
alert("Silme hatası: " + (data.error || resp.statusText));
failed.push(name);
continue;
}
files = files.filter((f) => f.name !== file.name);
const hash = file.name.split("/")[0];
await fetch(`${API}/api/torrents/${hash}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (err) {
console.warn("⚠️ Silme işlemi başarısız:", err);
failed.push(name);
}
}
selectedItems = new Set(failed);
allSelected = failed.length > 0 && failed.length === files.length;
await Promise.all([refreshMovieCount(), refreshTvShowCount()]);
}
// Menü fonksiyonları
function toggleMenu(fileName, event) {
event.stopPropagation();
activeMenu = activeMenu === fileName ? null : fileName;
}
function closeMenu() {
activeMenu = null;
}
async function downloadFile(file) {
const token = localStorage.getItem("token");
const link = document.createElement('a');
link.href = `${API}/downloads/${file.name}?token=${token}`;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
closeMenu();
}
function matchFile(file) {
// Eşleşme işlevi - buraya özel mantık eklenebilir
console.log("Eşleşme:", file.name);
closeMenu();
}
async function deleteFile(file) {
if (!confirm(`"${cleanFileName(file.name)}" dosyasını silmek istediğinizden emin misiniz?`))
return;
const token = localStorage.getItem("token");
try {
const resp = await fetch(
`${API}/api/file?path=${encodeURIComponent(file.name)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
alert("Silme hatası: " + (data.error || resp.statusText));
return;
}
files = files.filter((f) => f.name !== file.name);
const hash = file.name.split("/")[0];
await fetch(`${API}/api/torrents/${hash}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
await Promise.all([refreshMovieCount(), refreshTvShowCount()]);
} catch (err) {
console.warn("⚠️ Silme işlemi başarısız:", err);
alert("Silme işlemi başarısız oldu.");
}
closeMenu();
}
onMount(async () => {
await loadFiles(); // önce dosyaları getir
const token = localStorage.getItem("token");
const wsUrl = `${API.replace("http", "ws")}?token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "fileUpdate") {
console.log("📸 Yeni thumbnail bildirimi:", msg.path);
await loadFiles();
}
if (msg.type === "progress" && msg.torrents) {
for (const t of msg.torrents) {
const savePath = t.savePath || "";
const folderId = savePath.split("/").pop();
files = files.map((f) => {
const fileFolder = f.name.split("/")[0];
if (fileFolder === folderId) {
return t.progress < 1
? {
...f,
progressText: `${Math.floor(t.progress * 100)}%`,
}
: { ...f, progressText: null };
}
return f;
});
}
files = [...files];
}
} catch (err) {
console.warn("WebSocket mesajı çözümlenemedi:", err);
}
};
function handleKey(e) {
const active = document.activeElement;
const tag = active?.tagName;
const type = active?.type?.toLowerCase();
const isTextInput =
tag === "INPUT" &&
[
"text",
"search",
"email",
"password",
"number",
"url",
"tel"
].includes(type);
const isEditable =
(tag === "TEXTAREA" || isTextInput || active?.isContentEditable) ?? false;
if (e.metaKey && e.key && e.key.toLowerCase() === "backspace") {
if (isEditable) return;
if (selectedItems.size > 0) {
e.preventDefault();
deleteSelectedFiles();
}
return;
}
const isCmd = e.metaKey || e.ctrlKey;
if (isCmd && e.key.toLowerCase() === "a") {
e.preventDefault();
if (files.length > 0) {
selectedItems = new Set(files.map((f) => f.name));
allSelected = true;
}
return;
}
if (e.key === "Escape") {
if (showModal) closeModal();
if (showImageModal) showImageModal = false;
} else if (showModal || showImageModal) {
if (e.key === "ArrowRight") showNext();
if (e.key === "ArrowLeft") showPrev();
}
}
window.addEventListener("keydown", handleKey);
// Menüyü kapatmak için dışarı tıklama olayı
function handleClickOutside(event) {
if (activeMenu && !event.target.closest('.media-card')) {
activeMenu = null;
}
}
window.addEventListener("click", handleClickOutside);
return () => {
window.removeEventListener("keydown", handleKey);
window.removeEventListener("click", handleClickOutside);
};
});
</script>
<section class="files" on:click={handleFilesClick}>
<div class="files-header">
<div class="header-title">
<h2>Media Library</h2>
</div>
<div class="header-actions">
{#if files.length > 0 && selectedItems.size > 0}
<span class="selection-count">{selectedItems.size} dosya seçildi</span>
{/if}
{#if files.length > 0 && selectedItems.size > 0}
<button
class="select-all-btn"
type="button"
on:click|stopPropagation={selectAll}
aria-label={allSelected ? "Seçimi temizle" : "Tümünü seç"}
>
<i class="fa-solid fa-square-check"></i>
</button>
{/if}
<button
class="view-toggle"
class:list-active={viewMode === "list"}
type="button"
on:click|stopPropagation={toggleView}
aria-label={viewMode === "grid"
? "Liste görünümüne geç"
: "Izgara görünümüne geç"}
>
{#if viewMode === "grid"}
<i class="fa-solid fa-list"></i>
{:else}
<i class="fa-solid fa-border-all"></i>
{/if}
</button>
</div>
</div>
{#if files.length === 0}
<div class="empty">
<div style="font-size:42px"><i class="fa-solid fa-folder-open"></i></div>
<div style="font-weight:700">No media found</div>
</div>
{:else}
<div class="gallery" class:list-view={viewMode === "list"}>
{#each files as f}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="media-card"
class:list-view={viewMode === "list"}
class:is-selected={selectedItems.has(f.name)}
on:click={() => openModal(f)}
>
{#if f.thumbnail}
<img
src={`${API}${f.thumbnail}?token=${localStorage.getItem("token")}`}
alt={f.name}
class="thumb"
on:load={(e) => e.target.classList.add("loaded")}
/>
{:else}
<div class="thumb placeholder">
<i class="fa-regular fa-image"></i>
</div>
{/if}
<div class="info">
<div class="name">{cleanFileName(f.name)}</div>
<div class="size">
{#if f.progressText}
<span class="progress-text">{f.progressText}</span>
{:else}
{formatSize(f.size)}
{/if}
</div>
<div class="list-meta">
<div class="meta-line primary">
<span>{formatDateTime(f.added || f.completedAt)}</span>
<span class="meta-separator">|</span>
<span>{formatSize(f.size)}</span>
</div>
<div class="meta-line secondary">
{#if f.progressText}
<span class="status-badge">{f.progressText}</span>
<span class="meta-separator">|</span>
{/if}
Tracker:
<span class="tracker-name">
{formatTracker(f.tracker)}
</span>
</div>
{#if f.mediaInfo?.video || f.mediaInfo?.audio}
<div class="meta-line codecs">
{#if f.extension}
<span class="codec-chip file-type">
{#if f.type?.startsWith("image/")}
<i class="fa-solid fa-file-image"></i>
{:else}
<i class="fa-solid fa-file-video"></i>
{/if}
{f.extension.toUpperCase()}
</span>
{/if}
{#if f.mediaInfo?.video}
<span class="codec-chip">
<i class="fa-solid fa-film"></i>
{formatVideoCodec(f.mediaInfo.video)}
</span>
{/if}
{#if f.mediaInfo?.video && f.mediaInfo?.audio}
<span class="codec-separator">|</span>
{/if}
{#if f.mediaInfo?.audio}
<span class="codec-chip">
<i class="fa-solid fa-volume-high"></i>
{formatAudioCodec(f.mediaInfo.audio)}
</span>
{/if}
</div>
{/if}
</div>
</div>
<div class="media-type-icon">
{#if f.type?.startsWith("video/")}
<i class="fa-solid fa-film"></i>
{:else if f.type?.startsWith("image/")}
<i class="fa-solid fa-image"></i>
{/if}
</div>
<button
class="selection-toggle"
class:is-selected={selectedItems.has(f.name)}
type="button"
on:click|stopPropagation={() => toggleSelection(f)}
aria-label={selectedItems.has(f.name)
? "Seçimi kaldır"
: "Bu öğeyi seç"}
>
{#if selectedItems.has(f.name)}
<i class="fa-solid fa-circle-check"></i>
{:else}
<i class="fa-regular fa-circle"></i>
{/if}
</button>
<button
class="menu-toggle"
type="button"
on:click|stopPropagation={(e) => toggleMenu(f.name, e)}
aria-label="Menü"
>
<i class="fa-solid fa-ellipsis"></i>
</button>
{#if activeMenu === f.name}
<div class="dropdown-menu">
<button
class="menu-item"
on:click|stopPropagation={() => downloadFile(f)}
>
<i class="fa-solid fa-download"></i>
<span>İndir</span>
</button>
<button
class="menu-item"
on:click|stopPropagation={() => matchFile(f)}
>
<i class="fa-solid fa-link"></i>
<span>Eşleşme</span>
</button>
<div class="menu-divider"></div>
<button
class="menu-item delete"
on:click|stopPropagation={() => deleteFile(f)}
>
<i class="fa-solid fa-trash"></i>
<span>Sil</span>
</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</section>
{#if showModal && selectedVideo}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-overlay" on:click={closeModal}>
<button class="global-close-btn" on:click|stopPropagation={closeModal}
>✕</button
>
<button class="nav-btn left" on:click|stopPropagation={showPrev}>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="nav-btn right" on:click|stopPropagation={showNext}>
<i class="fa-solid fa-chevron-right"></i>
</button>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<div class="video-title">{cleanFileName(selectedName)}</div>
</div>
<div class="custom-player">
<!-- ✅ selectedVideo yokken boş src -->
<!-- svelte-ignore a11y-media-has-caption -->
{#key encName}
<!-- svelte-ignore a11y-media-has-caption -->
<video
bind:this={videoEl}
src={getVideoURL()}
class="video-element"
playsinline
on:timeupdate={updateProgress}
on:loadedmetadata={async () => {
// her yeni videoda statei sıfırla
isPlaying = false;
currentTime = 0;
updateDuration();
const slider = document.querySelector(".volume-slider");
if (slider) {
slider.value = volume;
slider.style.setProperty("--fill", slider.value * 100);
}
// 🎬 Otomatik oynatma (tarayıcı izin verirse)
try {
await videoEl.play();
isPlaying = true;
} catch (err) {
console.warn("Autoplay engellendi:", err?.message || err);
isPlaying = false;
}
}}
on:ended={() => (isPlaying = false)}
>
{#if subtitleURL}
<track
kind="subtitles"
src={subtitleURL}
srclang={subtitleLang}
label={subtitleLabel}
default
/>
{/if}
</video>
{/key}
<div class="controls">
<div class="top-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>
<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>
<!-- ✅ selectedVideo yokken '#' -->
<a
href={selectedName ? `${API}/downloads/${selectedName}` : "#"}
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}
{#if showImageModal && selectedImage}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="image-modal-overlay" on:click={() => (showImageModal = false)}>
<button
class="image-close-btn"
on:click|stopPropagation={() => (showImageModal = false)}>✕</button
>
<button class="nav-btn left" on:click|stopPropagation={showPrev}>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="nav-btn right" on:click|stopPropagation={showNext}>
<i class="fa-solid fa-chevron-right"></i>
</button>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="image-modal-content" on:click|stopPropagation>
<img
src={`${API}${selectedImage.url}?token=${localStorage.getItem("token")}`}
alt={selectedImage.name}
class="image-modal-img"
/>
</div>
</div>
{/if}
{#if selectedItems.size > 0}
<button class="floating-delete" type="button" on:click={deleteSelectedFiles}>
<i class="fa-solid fa-trash"></i>
</button>
{/if}
{#if showModal && selectedVideo}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-overlay" on:click={closeModal}>
<button class="global-close-btn" on:click|stopPropagation={closeModal}
>✕</button
>
<button class="nav-btn left" on:click|stopPropagation={showPrev}>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="nav-btn right" on:click|stopPropagation={showNext}>
<i class="fa-solid fa-chevron-right"></i>
</button>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<div class="video-title">{cleanFileName(selectedName)}</div>
</div>
<div class="custom-player">
<!-- ✅ selectedVideo yokken boş src -->
<!-- svelte-ignore a11y-media-has-caption -->
{#key encName}
<!-- svelte-ignore a11y-media-has-caption -->
<video
bind:this={videoEl}
src={getVideoURL()}
class="video-element"
playsinline
on:timeupdate={updateProgress}
on:loadedmetadata={async () => {
// her yeni videoda statei sıfırla
isPlaying = false;
currentTime = 0;
updateDuration();
const slider = document.querySelector(".volume-slider");
if (slider) {
slider.value = volume;
slider.style.setProperty("--fill", slider.value * 100);
}
// 🎬 Otomatik oynatma (tarayıcı izin verirse)
try {
await videoEl.play();
isPlaying = true;
} catch (err) {
console.warn("Autoplay engellendi:", err?.message || err);
isPlaying = false;
}
}}
on:ended={() => (isPlaying = false)}
>
{#if subtitleURL}
<track
kind="subtitles"
src={subtitleURL}
srclang={subtitleLang}
label={subtitleLabel}
default
/>
{/if}
</video>
{/key}
<div class="controls">
<div class="top-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>
<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>
<!-- ✅ selectedVideo yokken '#' -->
<a
href={selectedName ? `${API}/downloads/${selectedName}` : "#"}
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}
{#if showImageModal && selectedImage}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="image-modal-overlay" on:click={() => (showImageModal = false)}>
<button
class="image-close-btn"
on:click|stopPropagation={() => (showImageModal = false)}>✕</button
>
<button class="nav-btn left" on:click|stopPropagation={showPrev}>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="nav-btn right" on:click|stopPropagation={showNext}>
<i class="fa-solid fa-chevron-right"></i>
</button>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="image-modal-content" on:click|stopPropagation>
<img
src={`${API}${selectedImage.url}?token=${localStorage.getItem("token")}`}
alt={selectedImage.name}
class="image-modal-img"
/>
</div>
</div>
{/if}
<style>
.files-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
gap: 12px;
}
.header-title {
display: flex;
align-items: flex-end;
gap: 6px;
}
.selection-count {
font-size: 13px;
color: #6a6a6a;
font-weight: 500;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.select-all-btn {
background: #2e2e2e;
border: none;
color: #f5f5f5;
width: 36px;
height: 36px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
transition:
background 0.2s ease,
transform 0.2s ease;
}
.select-all-btn i {
font-size: 16px;
}
.view-toggle {
background: #2e2e2e;
border: none;
color: #f5f5f5;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
outline: none;
transition:
background 0.2s ease,
transform 0.2s ease;
}
.select-all-btn:hover,
.view-toggle:hover {
background: #3a3a3a;
}
.view-toggle.list-active {
background: #3f3f3f;
}
/* === GALERİ === */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.gallery.list-view {
display: flex;
flex-direction: column;
gap: 14px;
}
.media-card {
position: relative;
background: #f6f6f6;
border-radius: 10px;
overflow: hidden;
border: 1px solid #e2e2e2;
box-shadow: 0 1px 2px rgba(15, 15, 15, 0.04);
display: flex;
flex-direction: column;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
flex-direction 0.3s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
gap 0.3s cubic-bezier(0.4, 0, 0.2, 1),
min-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.media-card::after {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.08);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
}
.media-card:hover {
border-color: #d4d4d4;
background: #f1f1f1;
box-shadow: 0 2px 4px rgba(15, 15, 15, 0.06);
}
.media-card:hover::after {
opacity: 0.16;
}
.media-card.is-selected {
border-color: #2d965a;
background: #f4fbf7;
box-shadow:
0 0 0 1px rgba(45, 150, 90, 0.35),
0 4px 12px rgba(45, 150, 90, 0.12);
}
.media-card.is-selected::after {
opacity: 0.12;
}
.media-card.is-selected:hover::after {
opacity: 0.18;
}
.media-card.list-view {
flex-direction: row;
align-items: center;
padding: 12px 16px;
gap: 16px;
min-height: 96px;
}
.media-card.list-view .thumb {
width: 120px;
height: 72px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.media-card:hover {
transform: none;
}
.media-card.is-selected:hover {
transform: none;
}
.selection-toggle {
position: absolute;
top: 12px;
left: 12px;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.45);
color: #f5f5f5;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
outline: none;
transform: scale(0.88);
transition:
opacity 0.2s ease,
transform 0.2s ease,
background 0.2s ease;
cursor: pointer;
pointer-events: none;
z-index: 2;
}
.selection-toggle i {
font-size: 14px;
}
.media-card:hover .selection-toggle,
.media-card.is-selected .selection-toggle {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.selection-toggle.is-selected {
background: rgba(45, 150, 90, 0.85);
}
.selection-toggle.is-selected i {
color: #fff;
}
.media-card.list-view .selection-toggle {
top: 16px;
left: 16px;
}
.thumb {
width: 100%;
height: 150px;
object-fit: cover;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.thumb.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 42px;
background: #ddd;
}
.info {
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), gap 0.3s cubic-bezier(0.4, 0, 0.2, 1), flex 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.size {
font-size: 12px;
color: #666;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.media-card.list-view .info {
flex: 1;
padding: 0;
gap: 6px;
}
.media-card.list-view .name {
font-size: 15px;
}
.media-card.list-view .size {
display: none;
}
.list-meta {
display: none;
opacity: 0;
max-height: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.media-card.list-view .list-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #7a7a7a;
opacity: 1;
max-height: 200px;
}
.meta-line.primary {
display: flex;
align-items: center;
gap: 6px;
}
.meta-separator {
opacity: 0.65;
}
.meta-line.secondary .tracker-name {
margin-left: 4px;
color: #5a5a5a;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
.meta-line.codecs {
display: flex;
align-items: center;
gap: 12px;
color: #5a5a5a;
font-size: 13px;
}
.codec-chip {
display: inline-flex;
align-items: center;
gap: 6px;
color: #4e4e4e;
font-weight: 500;
}
.codec-chip.file-type {
color: #1f1f1f;
text-transform: uppercase;
}
.codec-chip i {
color: #ffc107;
font-size: 12px;
}
.codec-separator {
color: #7a7a7a;
font-weight: 500;
}
.status-badge {
color: #2f8a4d;
font-weight: 600;
}
.floating-delete {
position: fixed;
right: 28px;
bottom: 28px;
width: 52px;
height: 52px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.65);
color: #fefefe;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(8px);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
transition: background 0.2s ease;
z-index: 2500;
}
.floating-delete:hover {
background: #e53935;
}
.media-card.list-view .media-type-icon {
position: static;
color: rgba(0, 0, 0, 0.35);
font-size: 18px;
align-self: flex-start;
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
border: none;
color: white;
font-size: 28px;
cursor: pointer;
z-index: 2100;
width: 50px;
height: 60px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.2s ease,
transform 0.2s ease;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-50%) scale(1.05);
}
.nav-btn.left {
left: 15px;
}
.nav-btn.right {
right: 15px;
}
.media-card {
position: relative;
}
.media-type-icon {
position: absolute;
bottom: 6px;
right: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
pointer-events: none;
}
.media-type-icon i {
filter: drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3));
}
.progress-text {
color: #666; /* gri */
font-weight: 600;
font-size: 12px;
animation: pulse 1.2s infinite ease-in-out;
}
@keyframes pulse {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
/* === RESPONSIVE === */
@media (max-width: 768px) {
.floating-delete {
right: 20px;
bottom: 20px;
width: 48px;
height: 48px;
}
.gallery {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.media-card.list-view {
flex-direction: column;
align-items: flex-start;
}
.media-card.list-view .thumb {
width: 100%;
height: 160px;
}
.media-card.list-view .media-type-icon {
align-self: center;
}
}
@media (max-width: 480px) {
.floating-delete {
right: 16px;
bottom: 16px;
width: 44px;
height: 44px;
}
.gallery {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
}
.files-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.select-all-btn {
background: #2e2e2e;
border: none;
color: #f5f5f5;
width: 36px;
height: 36px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
transition:
background 0.2s ease,
transform 0.2s ease;
}
.select-all-btn i {
font-size: 16px;
}
.view-toggle {
background: #2e2e2e;
border: none;
color: #f5f5f5;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
outline: none;
transition:
background 0.2s ease,
transform 0.2s ease;
}
.select-all-btn:hover,
.view-toggle:hover {
background: #3a3a3a;
transform: translateY(-1px);
}
.view-toggle.list-active {
background: #3f3f3f;
}
/* === GALERİ === */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.gallery.list-view {
display: flex;
flex-direction: column;
gap: 14px;
}
.media-card {
position: relative;
background: #f5f5f5;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
cursor: pointer;
}
.media-card::after {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.18);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
}
.media-card:hover::after {
opacity: 0.22;
}
.media-card.is-selected {
transform: translateY(-6px) scale(0.965);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22);
}
.media-card.is-selected::after {
opacity: 0.32;
}
.media-card.is-selected:hover::after {
opacity: 0.35;
}
.media-card.list-view {
flex-direction: row;
align-items: center;
padding: 12px 16px;
gap: 16px;
min-height: 96px;
}
.media-card.list-view .thumb {
width: 120px;
height: 72px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.media-card:hover {
transform: translateY(-4px) scale(0.98);
}
.media-card.is-selected:hover {
transform: translateY(-6px) scale(0.965);
}
.selection-toggle {
position: absolute;
top: 12px;
left: 12px;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.45);
color: #f5f5f5;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
outline: none;
transform: scale(0.88);
transition:
opacity 0.2s ease,
transform 0.2s ease,
background 0.2s ease;
cursor: pointer;
pointer-events: none;
z-index: 2;
}
.selection-toggle i {
font-size: 14px;
}
.media-card:hover .selection-toggle,
.media-card.is-selected .selection-toggle {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.selection-toggle.is-selected {
background: rgba(45, 150, 90, 0.85);
}
.selection-toggle.is-selected i {
color: #fff;
}
.media-card.list-view .selection-toggle {
top: 16px;
left: 16px;
}
.thumb {
width: 100%;
height: 150px;
object-fit: cover;
}
.thumb.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 42px;
background: #ddd;
}
.info {
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.size {
font-size: 12px;
color: #666;
}
.media-card.list-view .info {
flex: 1;
padding: 0;
gap: 6px;
}
.media-card.list-view .name {
font-size: 15px;
}
.media-card.list-view .size {
display: none;
}
.list-meta {
display: none;
}
.media-card.list-view .list-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #7a7a7a;
}
.meta-line.primary {
display: flex;
align-items: center;
gap: 6px;
}
.meta-separator {
opacity: 0.65;
}
.meta-line.secondary .tracker-name {
margin-left: 4px;
color: #5a5a5a;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
.status-badge {
color: #2f8a4d;
font-weight: 600;
}
.media-card.list-view .media-type-icon {
position: static;
color: rgba(0, 0, 0, 0.35);
font-size: 18px;
align-self: flex-start;
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
border: none;
color: white;
font-size: 28px;
cursor: pointer;
z-index: 2100;
width: 50px;
height: 60px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.2s ease,
transform 0.2s ease;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-50%) scale(1.05);
}
.nav-btn.left {
left: 15px;
}
.nav-btn.right {
right: 15px;
}
.media-card {
position: relative;
}
.media-type-icon {
position: absolute;
bottom: 6px;
right: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
pointer-events: none;
}
.media-type-icon i {
filter: drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3));
}
.progress-text {
color: #666; /* gri */
font-weight: 600;
font-size: 12px;
animation: pulse 1.2s infinite ease-in-out;
}
@keyframes pulse {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
/* === RESPONSIVE === */
@media (max-width: 768px) {
.gallery {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.media-card.list-view {
flex-direction: column;
align-items: flex-start;
}
.media-card.list-view .thumb {
width: 100%;
height: 160px;
}
.media-card.list-view .media-type-icon {
align-self: center;
}
}
@media (max-width: 480px) {
.gallery {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
}
/* Menü düğmesi ve dropdown stilleri */
.menu-toggle {
position: absolute;
top: 12px;
right: 12px;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.45);
color: #f5f5f5;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
outline: none;
transform: scale(0.88);
transition:
opacity 0.2s ease,
transform 0.2s ease,
background 0.2s ease;
cursor: pointer;
pointer-events: none;
z-index: 2;
}
.menu-toggle i {
font-size: 14px;
}
.media-card:hover .menu-toggle,
.media-card.is-selected .menu-toggle {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.menu-toggle:hover {
background: rgba(0, 0, 0, 0.65);
}
.media-card.list-view .menu-toggle {
top: 16px;
right: 16px;
}
.dropdown-menu {
position: absolute;
top: 52px;
right: 12px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
min-width: 160px;
z-index: 100;
overflow: hidden;
animation: fadeIn 0.2s ease;
}
.media-card.list-view .dropdown-menu {
top: 56px;
right: 16px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
border: none;
background: transparent;
color: #333;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.menu-item:hover {
background-color: #f5f5f5;
}
.menu-item.delete {
color: #e53935;
}
.menu-item.delete:hover {
background-color: #ffebee;
}
.menu-item i {
font-size: 14px;
width: 16px;
text-align: center;
}
.menu-divider {
height: 1px;
background-color: #e0e0e0;
margin: 4px 0;
}
</style>