feat(music): mini player ekle

Müzik çalar durumunu yönetmek için global store oluştur.
Özel bir mini player bileşeni ile çalma listesi ve kontrolleri ekle.
Müzik çaların uygulama genelinde kalıcı olmasını sağla.
This commit is contained in:
2026-01-18 01:51:15 +03:00
parent c945458a81
commit d5d9184872
4 changed files with 510 additions and 153 deletions

View File

@@ -3,6 +3,7 @@
import { onMount } from "svelte";
import Sidebar from "./components/Sidebar.svelte";
import Topbar from "./components/Topbar.svelte";
import MiniPlayer from "./components/MiniPlayer.svelte";
import Files from "./routes/Files.svelte";
import Transfers from "./routes/Transfers.svelte";
import Trash from "./routes/Trash.svelte";
@@ -156,6 +157,8 @@
<Route path="/trash" component={Trash} />
</div>
<MiniPlayer />
<!-- Sidebar dışına tıklayınca kapanma -->
{#if menuOpen}
<div

View File

@@ -0,0 +1,246 @@
<script>
import { musicPlayer, togglePlay, playNext, playPrevious, stopPlayback } from "../stores/musicPlayerStore.js";
import { API } from "../utils/api.js";
import { cleanFileName } from "../utils/filename.js";
function thumbnailURL(item) {
if (!item?.thumbnail) return null;
const token = localStorage.getItem("token");
const separator = item.thumbnail.includes("?") ? "&" : "?";
return `${API}${item.thumbnail}${separator}token=${token}`;
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, "0")}`;
}
function remainingTime(current, total) {
if (!Number.isFinite(total) || total <= 0) return "-0:00";
const remaining = Math.max(total - (current || 0), 0);
return `-${formatTime(remaining)}`;
}
function sourceLabel(item) {
if (item?.tracker === "youtube" || item?.thumbnail) return "YouTube";
return "Music";
}
</script>
{#if $musicPlayer.currentTrack}
<div class="mini-player" aria-label="Mini music player">
<div class="mini-top">
<div class="mini-user-meta">
<div class="mini-user-name">
{cleanFileName($musicPlayer.currentTrack.title)}
</div>
<div class="mini-user-handle">{sourceLabel($musicPlayer.currentTrack)}</div>
</div>
<div class="mini-actions">
<button class="mini-icon-btn" title="Paylaş">
<i class="fa-solid fa-arrow-up-from-bracket"></i>
</button>
<button class="mini-icon-btn" title="Beğen">
<i class="fa-regular fa-heart"></i>
</button>
</div>
</div>
<div class="mini-cover">
{#if thumbnailURL($musicPlayer.currentTrack)}
<img
src={thumbnailURL($musicPlayer.currentTrack)}
alt={$musicPlayer.currentTrack.title}
/>
{:else}
<div class="mini-cover-placeholder">
<i class="fa-solid fa-music"></i>
</div>
{/if}
</div>
<div class="mini-progress">
<span class="mini-time">{formatTime($musicPlayer.currentTime)}</span>
<div class="mini-bar">
<div
class="mini-bar-fill"
style="width: {($musicPlayer.currentTime / $musicPlayer.duration) * 100 || 0}%"
></div>
</div>
<span class="mini-time">
{remainingTime($musicPlayer.currentTime, $musicPlayer.duration)}
</span>
</div>
<div class="mini-controls">
<button class="mini-btn" on:click={playPrevious} title="Önceki">
<i class="fa-solid fa-backward-step"></i>
</button>
<button class="mini-btn main" on:click={togglePlay} title="Oynat/Durdur">
{#if $musicPlayer.isPlaying}
<i class="fa-solid fa-pause"></i>
{:else}
<i class="fa-solid fa-play"></i>
{/if}
</button>
<button class="mini-btn" on:click={playNext} title="Sonraki">
<i class="fa-solid fa-forward-step"></i>
</button>
<button class="mini-btn ghost" on:click={stopPlayback} title="Kapat">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
{/if}
<style>
.mini-player {
position: fixed;
right: 24px;
bottom: 24px;
width: 240px;
padding: 16px;
background: rgba(12, 12, 12, 0.72);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 26px;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
color: #f6f6f6;
backdrop-filter: blur(14px);
z-index: 50;
}
.mini-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mini-user-meta {
min-width: 0;
text-align: left;
}
.mini-user-name {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-user-handle {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
}
.mini-actions {
display: flex;
gap: 8px;
}
.mini-icon-btn {
width: 32px;
height: 32px;
border-radius: 12px;
border: none;
background: rgba(255, 255, 255, 0.12);
color: #f6f6f6;
cursor: pointer;
}
.mini-cover {
margin: 14px auto 12px;
width: 160px;
height: 160px;
border-radius: 20px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 18px 30px rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
display: grid;
place-items: center;
transform: translateY(-6px);
}
.mini-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mini-cover-placeholder {
color: rgba(255, 255, 255, 0.45);
font-size: 40px;
}
.mini-progress {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
margin: 8px 0 14px;
}
.mini-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
}
.mini-bar {
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
overflow: hidden;
}
.mini-bar-fill {
height: 100%;
background: var(--yellow);
border-radius: 999px;
}
.mini-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.mini-btn {
width: 34px;
height: 34px;
border-radius: 999px;
border: none;
background: rgba(255, 255, 255, 0.14);
color: #f6f6f6;
cursor: pointer;
}
.mini-btn.main {
width: 44px;
height: 44px;
border-radius: 999px;
background: var(--yellow);
color: #1e1e1e;
}
.mini-btn.ghost {
background: rgba(255, 255, 255, 0.06);
}
@media (max-width: 820px) {
.mini-player {
right: 16px;
bottom: 16px;
width: 220px;
}
.mini-cover {
width: 140px;
height: 140px;
}
}
</style>

View File

@@ -1,24 +1,25 @@
<script>
import { onMount, tick } from "svelte";
import { onMount } from "svelte";
import { API, apiFetch, withToken } from "../utils/api.js";
import { musicCount } from "../stores/musicStore.js";
import { cleanFileName } from "../utils/filename.js";
import {
musicPlayer,
setQueue,
playTrack as playFromStore,
togglePlay,
playNext,
playPrevious,
seekToPercent,
setVolume,
toggleMute,
stopPlayback
} from "../stores/musicPlayerStore.js";
let items = [];
let loading = true;
let error = null;
// Player state
let currentTrack = null;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let volume = 0.8;
let isMuted = false;
let previousVolume = 0.8;
let audioEl;
let progressInterval;
// View mode
const VIEW_MODE_STORAGE_KEY = "musicViewMode";
let viewMode = "list"; // "list" or "grid"
@@ -48,6 +49,7 @@
const list = await resp.json();
items = Array.isArray(list) ? list : [];
musicCount.set(items.length);
setQueue(items);
} catch (err) {
console.error("Music load error:", err);
items = [];
@@ -90,113 +92,23 @@
}
// Player functions
async function playTrack(item, index) {
if (currentTrack?.id === item.id) {
function playTrack(item, index) {
if (!item) return;
const current = $musicPlayer.currentTrack;
if (current?.id === item.id) {
togglePlay();
return;
}
currentTrack = { ...item, index };
currentTime = 0;
duration = item.mediaInfo?.format?.duration || 0;
isPlaying = true;
await tick();
if (!audioEl) return;
audioEl.src = streamURL(item);
audioEl.play().catch((err) => {
console.error("Play error:", err);
isPlaying = false;
});
}
function togglePlay() {
if (!audioEl) return;
if (isPlaying) {
audioEl.pause();
} else {
audioEl.play().catch((err) => {
console.error("Play error:", err);
});
}
isPlaying = !isPlaying;
}
function playNext() {
if (!currentTrack || items.length === 0) return;
const currentIndex = items.findIndex((i) => i.id === currentTrack.id);
const nextIndex = (currentIndex + 1) % items.length;
playTrack(items[nextIndex], nextIndex);
}
function playPrevious() {
if (!currentTrack || items.length === 0) return;
const currentIndex = items.findIndex((i) => i.id === currentTrack.id);
const prevIndex = (currentIndex - 1 + items.length) % items.length;
playTrack(items[prevIndex], prevIndex);
playFromStore(item, index, items);
}
function seek(e) {
if (!audioEl || !duration) return;
const percent = parseFloat(e.target.value);
currentTime = (percent / 100) * duration;
audioEl.currentTime = currentTime;
seekToPercent(percent);
}
function setVolume(e) {
volume = parseFloat(e.target.value);
if (audioEl) {
audioEl.volume = volume;
}
isMuted = volume === 0;
}
function toggleMute() {
if (isMuted) {
volume = previousVolume;
isMuted = false;
} else {
previousVolume = volume;
volume = 0;
isMuted = true;
}
if (audioEl) {
audioEl.volume = volume;
}
}
function handleTimeUpdate() {
if (audioEl) {
currentTime = audioEl.currentTime;
duration = audioEl.duration || 0;
}
}
function handleLoadedMetadata() {
if (audioEl) {
duration = audioEl.duration || 0;
}
}
function handleEnded() {
playNext();
}
function handlePlay() {
isPlaying = true;
}
function handlePause() {
isPlaying = false;
}
function startProgressInterval() {
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
if (isPlaying && audioEl) {
currentTime = audioEl.currentTime;
}
}, 100);
function setPlayerVolume(e) {
setVolume(e.target.value);
}
function setViewMode(mode) {
@@ -207,15 +119,6 @@
onMount(() => {
loadViewMode();
loadMusic();
startProgressInterval();
return () => {
if (progressInterval) clearInterval(progressInterval);
if (audioEl) {
audioEl.pause();
audioEl.src = "";
}
};
});
</script>
@@ -281,14 +184,14 @@
</div>
{#each items as item, idx (item.id)}
<div
class="music-row {currentTrack?.id === item.id ? 'playing' : ''}"
class="music-row {$musicPlayer.currentTrack?.id === item.id ? 'playing' : ''}"
on:click={() => playTrack(item, idx)}
on:dblclick={() => {
if (currentTrack?.id === item.id) togglePlay();
if ($musicPlayer.currentTrack?.id === item.id) togglePlay();
}}
>
<div class="row-index">
{#if currentTrack?.id === item.id && isPlaying}
{#if $musicPlayer.currentTrack?.id === item.id && $musicPlayer.isPlaying}
<div class="playing-indicator">
<span class="bar bar-1"></span>
<span class="bar bar-2"></span>
@@ -340,7 +243,7 @@
<div class="music-grid">
{#each items as item, idx (item.id)}
<div
class="music-card {currentTrack?.id === item.id ? 'playing' : ''}"
class="music-card {$musicPlayer.currentTrack?.id === item.id ? 'playing' : ''}"
on:click={() => playTrack(item, idx)}
>
<div class="card-thumb">
@@ -356,7 +259,7 @@
<i class="fa-solid fa-play"></i>
</button>
</div>
{#if currentTrack?.id === item.id && isPlaying}
{#if $musicPlayer.currentTrack?.id === item.id && $musicPlayer.isPlaying}
<div class="playing-badge">
<i class="fa-solid fa-volume-high"></i>
</div>
@@ -378,23 +281,17 @@
</div>
<!-- Bottom Player Bar -->
{#if currentTrack}
{#if $musicPlayer.currentTrack}
<div class="player-bar">
<audio
bind:this={audioEl}
on:timeupdate={handleTimeUpdate}
on:loadedmetadata={handleLoadedMetadata}
on:ended={handleEnded}
on:play={handlePlay}
on:pause={handlePause}
></audio>
<div class="player-content">
<!-- Track Info -->
<div class="player-track">
<div class="track-thumb">
{#if thumbnailURL(currentTrack)}
<img src={thumbnailURL(currentTrack)} alt={currentTrack.title} />
{#if thumbnailURL($musicPlayer.currentTrack)}
<img
src={thumbnailURL($musicPlayer.currentTrack)}
alt={$musicPlayer.currentTrack.title}
/>
{:else}
<div class="thumb-placeholder">
<i class="fa-solid fa-music"></i>
@@ -402,11 +299,15 @@
{/if}
</div>
<div class="track-info">
<div class="track-name">{cleanFileName(currentTrack.title)}</div>
<div class="track-source">{sourceLabel(currentTrack)}</div>
<div class="track-name">
{cleanFileName($musicPlayer.currentTrack.title)}
</div>
<div class="track-source">
{sourceLabel($musicPlayer.currentTrack)}
</div>
</div>
<button class="like-btn" title="Beğen">
<i class="fa-regular fa-heart"></i>
<button class="like-btn" title="Kapat" on:click={stopPlayback}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
@@ -418,9 +319,9 @@
<button
class="control-btn play-pause-btn"
on:click={togglePlay}
title={isPlaying ? "Durdaklat" : "Oynat"}
title={$musicPlayer.isPlaying ? "Durdaklat" : "Oynat"}
>
{#if isPlaying}
{#if $musicPlayer.isPlaying}
<i class="fa-solid fa-pause"></i>
{:else}
<i class="fa-solid fa-play"></i>
@@ -433,18 +334,22 @@
<!-- Progress -->
<div class="player-progress">
<span class="time-current">{formatTime(currentTime)}</span>
<span class="time-current">
{formatTime($musicPlayer.currentTime)}
</span>
<div class="progress-container">
<input
type="range"
min="0"
max="100"
value={(currentTime / duration) * 100 || 0}
value={
($musicPlayer.currentTime / $musicPlayer.duration) * 100 || 0
}
on:input={seek}
class="progress-slider"
/>
</div>
<span class="time-total">{formatTime(duration)}</span>
<span class="time-total">{formatTime($musicPlayer.duration)}</span>
</div>
<!-- Volume & Extra -->
@@ -455,11 +360,11 @@
on:click={toggleMute}
title="Ses"
>
{#if isMuted}
{#if $musicPlayer.isMuted}
<i class="fa-solid fa-volume-xmark"></i>
{:else if volume < 0.3}
{:else if $musicPlayer.volume < 0.3}
<i class="fa-solid fa-volume-off"></i>
{:else if volume < 0.7}
{:else if $musicPlayer.volume < 0.7}
<i class="fa-solid fa-volume-low"></i>
{:else}
<i class="fa-solid fa-volume-high"></i>
@@ -471,17 +376,17 @@
min="0"
max="1"
step="0.01"
bind:value={volume}
on:input={setVolume}
value={$musicPlayer.volume}
on:input={setPlayerVolume}
class="volume-slider"
style="--fill: {volume * 100}%"
style="--fill: {$musicPlayer.volume * 100}%"
/>
</div>
</div>
<a
class="control-btn download-btn"
href={streamURL(currentTrack)}
download={currentTrack.title}
href={streamURL($musicPlayer.currentTrack)}
download={$musicPlayer.currentTrack.title}
title="İndir"
>
<i class="fa-solid fa-download"></i>

View File

@@ -0,0 +1,203 @@
import { writable, get } from "svelte/store";
import { API, withToken } from "../utils/api.js";
const INITIAL_STATE = {
currentTrack: null,
currentIndex: -1,
queue: [],
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 0.8,
isMuted: false
};
const { subscribe, set, update } = writable({ ...INITIAL_STATE });
let audio = null;
let previousVolume = INITIAL_STATE.volume;
function ensureAudio() {
if (audio || typeof Audio === "undefined") return;
audio = new Audio();
audio.preload = "metadata";
audio.volume = INITIAL_STATE.volume;
audio.addEventListener("timeupdate", () => {
update((state) => ({
...state,
currentTime: audio.currentTime || 0,
duration: Number.isFinite(audio.duration) ? audio.duration : state.duration
}));
});
audio.addEventListener("loadedmetadata", () => {
update((state) => ({
...state,
duration: Number.isFinite(audio.duration) ? audio.duration : 0
}));
});
audio.addEventListener("play", () => {
update((state) => ({ ...state, isPlaying: true }));
});
audio.addEventListener("pause", () => {
update((state) => ({ ...state, isPlaying: false }));
});
audio.addEventListener("ended", () => {
playNext();
});
}
function buildStreamURL(item) {
const base = `${API}/stream/${item.infoHash}?index=${item.fileIndex || 0}`;
return withToken(base);
}
function setQueue(items = []) {
update((state) => ({ ...state, queue: Array.isArray(items) ? items : [] }));
}
function playTrack(item, index, items = null) {
if (!item) return;
ensureAudio();
if (items) setQueue(items);
const state = get({ subscribe });
const nextIndex =
Number.isFinite(index) && index >= 0
? index
: state.queue.findIndex((entry) => entry.id === item.id);
update((prev) => ({
...prev,
currentTrack: item,
currentIndex: nextIndex,
currentTime: 0,
duration: item.mediaInfo?.format?.duration || 0
}));
if (!audio) return;
audio.src = buildStreamURL(item);
audio
.play()
.catch(() => update((prev) => ({ ...prev, isPlaying: false })));
}
function playByIndex(index) {
ensureAudio();
const state = get({ subscribe });
if (!state.queue.length || index < 0 || index >= state.queue.length) return;
const item = state.queue[index];
update((prev) => ({
...prev,
currentTrack: item,
currentIndex: index,
currentTime: 0,
duration: item.mediaInfo?.format?.duration || 0
}));
if (!audio) return;
audio.src = buildStreamURL(item);
audio
.play()
.catch(() => update((prev) => ({ ...prev, isPlaying: false })));
}
function togglePlay() {
ensureAudio();
const state = get({ subscribe });
if (!audio || !state.currentTrack) return;
if (state.isPlaying) {
audio.pause();
} else {
audio
.play()
.catch(() => update((prev) => ({ ...prev, isPlaying: false })));
}
}
function playNext() {
const state = get({ subscribe });
if (!state.queue.length) return;
const nextIndex =
state.currentIndex >= 0
? (state.currentIndex + 1) % state.queue.length
: 0;
playByIndex(nextIndex);
}
function playPrevious() {
const state = get({ subscribe });
if (!state.queue.length) return;
const prevIndex =
state.currentIndex > 0
? state.currentIndex - 1
: state.queue.length - 1;
playByIndex(prevIndex);
}
function seekToPercent(percent) {
ensureAudio();
if (!audio) return;
const state = get({ subscribe });
if (!state.duration) return;
const nextTime = (Number(percent) / 100) * state.duration;
audio.currentTime = nextTime;
update((prev) => ({ ...prev, currentTime: nextTime }));
}
function setVolume(value) {
ensureAudio();
const volume = Math.min(Math.max(Number(value) || 0, 0), 1);
if (audio) audio.volume = volume;
update((prev) => ({
...prev,
volume,
isMuted: volume === 0
}));
}
function toggleMute() {
ensureAudio();
const state = get({ subscribe });
if (!audio) return;
if (state.isMuted || state.volume === 0) {
const nextVolume = previousVolume || 0.8;
audio.volume = nextVolume;
update((prev) => ({
...prev,
volume: nextVolume,
isMuted: false
}));
} else {
previousVolume = state.volume;
audio.volume = 0;
update((prev) => ({
...prev,
volume: 0,
isMuted: true
}));
}
}
function stopPlayback() {
if (audio) {
audio.pause();
audio.src = "";
}
set({ ...INITIAL_STATE });
}
export const musicPlayer = { subscribe };
export {
setQueue,
playTrack,
togglePlay,
playNext,
playPrevious,
seekToPercent,
setVolume,
toggleMute,
stopPlayback
};