diff --git a/client/src/App.svelte b/client/src/App.svelte index d7621be..1992d9b 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -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 @@ + + {#if menuOpen}
+ 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"; + } + + +{#if $musicPlayer.currentTrack} +
+
+
+
+ {cleanFileName($musicPlayer.currentTrack.title)} +
+
{sourceLabel($musicPlayer.currentTrack)}
+
+ +
+ + +
+
+ +
+ {#if thumbnailURL($musicPlayer.currentTrack)} + {$musicPlayer.currentTrack.title} + {:else} +
+ +
+ {/if} +
+ +
+ {formatTime($musicPlayer.currentTime)} +
+
+
+ + {remainingTime($musicPlayer.currentTime, $musicPlayer.duration)} + +
+ +
+ + + + +
+
+{/if} + + diff --git a/client/src/routes/Music.svelte b/client/src/routes/Music.svelte index 4063516..dadcb7a 100644 --- a/client/src/routes/Music.svelte +++ b/client/src/routes/Music.svelte @@ -1,24 +1,25 @@ @@ -281,14 +184,14 @@
{#each items as item, idx (item.id)}
playTrack(item, idx)} on:dblclick={() => { - if (currentTrack?.id === item.id) togglePlay(); + if ($musicPlayer.currentTrack?.id === item.id) togglePlay(); }} >
- {#if currentTrack?.id === item.id && isPlaying} + {#if $musicPlayer.currentTrack?.id === item.id && $musicPlayer.isPlaying}
@@ -340,7 +243,7 @@
{#each items as item, idx (item.id)}
playTrack(item, idx)} >
@@ -356,7 +259,7 @@
- {#if currentTrack?.id === item.id && isPlaying} + {#if $musicPlayer.currentTrack?.id === item.id && $musicPlayer.isPlaying}
@@ -378,23 +281,17 @@
- {#if currentTrack} + {#if $musicPlayer.currentTrack}
- -
- {#if thumbnailURL(currentTrack)} - {currentTrack.title} + {#if thumbnailURL($musicPlayer.currentTrack)} + {$musicPlayer.currentTrack.title} {:else}
@@ -402,11 +299,15 @@ {/if}
-
{cleanFileName(currentTrack.title)}
-
{sourceLabel(currentTrack)}
+
+ {cleanFileName($musicPlayer.currentTrack.title)} +
+
+ {sourceLabel($musicPlayer.currentTrack)} +
-
@@ -418,9 +319,9 @@
diff --git a/client/src/stores/musicPlayerStore.js b/client/src/stores/musicPlayerStore.js new file mode 100644 index 0000000..b727642 --- /dev/null +++ b/client/src/stores/musicPlayerStore.js @@ -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 +};