Somewhat functional playback (not really)
This commit is contained in:
parent
59bedf73da
commit
d088472acb
11 changed files with 319 additions and 26 deletions
12
src/app.pcss
12
src/app.pcss
|
@ -50,6 +50,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
h1 {
|
||||||
|
@apply text-2xl;
|
||||||
|
@apply font-bold;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
@apply text-xl;
|
||||||
|
@apply font-bold;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
@apply text-lg;
|
||||||
|
@apply font-bold;
|
||||||
|
}
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
1
src/lib/AudioState.svelte.ts
Normal file
1
src/lib/AudioState.svelte.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export let audioStore: HTMLAudioElement = $state(new Audio());
|
10
src/lib/components/custom/LoadingSpinner.svelte
Normal file
10
src/lib/components/custom/LoadingSpinner.svelte
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<script>
|
||||||
|
import {Circle2} from "svelte-loading-spinners";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Circle2
|
||||||
|
size="40" unit="px"
|
||||||
|
colorOuter="#0892B4" durationOuter="1s"
|
||||||
|
colorCenter="white" durationCenter="3s"
|
||||||
|
colorInner="#9cc1c9" durationInner="2s"
|
||||||
|
/>
|
29
src/lib/components/custom/PlayerFrame.svelte
Normal file
29
src/lib/components/custom/PlayerFrame.svelte
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {Button} from "$lib/components/ui/button";
|
||||||
|
import {onMount} from "svelte";
|
||||||
|
let { audio, pause, play } = $props();
|
||||||
|
let mounted = $state(false);
|
||||||
|
|
||||||
|
$inspect(audio);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
{#if mounted}
|
||||||
|
<p>Paused: {audio.paused}</p>
|
||||||
|
<p>Volume: {audio.volume}</p>
|
||||||
|
<p>Duration: {audio.duration}</p>
|
||||||
|
{#if audio.paused}
|
||||||
|
<Button onclick={play}>Play</Button>
|
||||||
|
{:else}
|
||||||
|
<Button onclick={pause}>Pause</Button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p>Nothing going on here</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
47
src/lib/components/custom/QueueFrame.svelte
Normal file
47
src/lib/components/custom/QueueFrame.svelte
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {Separator} from "$lib/components/ui/separator";
|
||||||
|
import {Button} from "$lib/components/ui/button";
|
||||||
|
import {timeFormat} from "$lib/time-format";
|
||||||
|
import LoadingSpinner from "$lib/components/custom/LoadingSpinner.svelte";
|
||||||
|
|
||||||
|
let { queue = [], fetchQueue, saveQueue, removeSongFromQueue, playSong } = $props();
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function innerFetchQueue() {
|
||||||
|
loading = true;
|
||||||
|
await fetchQueue()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet playerQueue()}
|
||||||
|
{#each queue as song, idx}
|
||||||
|
<p onclick={() => playSong(idx)}>{song.artist} - {song.title} ({timeFormat(song.duration)})<Button onclick={() => removeSongFromQueue(idx)}>X</Button></p>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<Button onclick={() => queue.push(queue[0])}>Add</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="border border-2 col-span-1 flex flex-col gap-1 p-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<h1>Current Queue</h1>
|
||||||
|
<div class="flex-grow"></div>
|
||||||
|
<Button variant="outline" class="h-8 w-12" onclick={innerFetchQueue}>Fetch</Button>
|
||||||
|
<Button variant="outline" class="h-8 w-12" onclick={saveQueue}>Upload</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{#if loading}
|
||||||
|
<div class="relative flex flex-col gap-1">
|
||||||
|
<div class="absolute w-full h-full z-40 bg-opacity-60 bg-background flex flex-col items-center justify-center gap-2">
|
||||||
|
<h2>Fetching Play Queue</h2>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{@render playerQueue()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render playerQueue()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -6,13 +6,40 @@ export module OpenSubsonic {
|
||||||
export let password = "";
|
export let password = "";
|
||||||
let token = "";
|
let token = "";
|
||||||
let salt = "";
|
let salt = "";
|
||||||
export let base = "https://navidrome.neshweb.net";
|
export let base = "https://music.neshweb.net";
|
||||||
const apiVer = "1.16.1"; // Version supported by Navidrome. Variable for easier updating
|
const apiVer = "1.16.1"; // Version supported by Navidrome. Variable for easier updating
|
||||||
const clientName = "Lytter";
|
const clientName = "Lytter";
|
||||||
|
|
||||||
export function getApiPath(path: string) {
|
export async function get(path: string, parameters: {parameter: string, value: string}[] = []) {
|
||||||
|
const apiPath = getApiUrl(path, parameters);
|
||||||
|
const res = (await fetch(apiPath));
|
||||||
|
if (res.ok) {
|
||||||
|
switch (res.headers.get("content-type")) {
|
||||||
|
case("application/json"): {
|
||||||
|
return (await res.json())["subsonic-response"];
|
||||||
|
}
|
||||||
|
case("audio/mp4"): {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiUrl(path: string, parameters: {parameter: string, value: string}[] = []) {
|
||||||
|
let apiPath = generateBasePath(path);
|
||||||
|
parameters.forEach(({parameter, value}) => {
|
||||||
|
apiPath = apiPath + `&${parameter}=${value}`;
|
||||||
|
})
|
||||||
|
|
||||||
|
return apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateBasePath = (path: string) => {
|
||||||
if (username === "") {
|
if (username === "") {
|
||||||
let cookie = Cookies.get("subsonicUsername");
|
const cookie = Cookies.get("subsonicUsername");
|
||||||
if (typeof cookie !== "undefined") {
|
if (typeof cookie !== "undefined") {
|
||||||
username = cookie;
|
username = cookie;
|
||||||
}
|
}
|
||||||
|
@ -34,13 +61,13 @@ export module OpenSubsonic {
|
||||||
return `${base}/rest/${path}?u=${username}&s=${salt}&t=${token}&v=${apiVer}&c=${clientName}&f=json`;
|
return `${base}/rest/${path}?u=${username}&s=${salt}&t=${token}&v=${apiVer}&c=${clientName}&f=json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateToken() {
|
const generateToken = () => {
|
||||||
salt = "staticfornow";
|
salt = "staticfornow";
|
||||||
token = Md5.hashStr(password + salt);
|
token = Md5.hashStr(password + salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeCredentials() {
|
export function storeCredentials() {
|
||||||
let options = {
|
const options = {
|
||||||
expires: 7,
|
expires: 7,
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
|
|
7
src/lib/time-format.ts
Normal file
7
src/lib/time-format.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export function timeFormat(time: number) {
|
||||||
|
const minutes = (time / 60).toFixed(0).toString();
|
||||||
|
const seconds = time % 60;
|
||||||
|
const secondsString = (seconds < 10) ? '0' + seconds.toString() : seconds.toString()
|
||||||
|
|
||||||
|
return minutes + ":" + secondsString;
|
||||||
|
}
|
|
@ -2,4 +2,9 @@
|
||||||
import "../app.pcss";
|
import "../app.pcss";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="border border-2 p-1">
|
||||||
|
<h1>Navbar</h1>
|
||||||
|
</div>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
</div>
|
|
@ -1,2 +1,159 @@
|
||||||
<h1>Welcome to SvelteKit</h1>
|
<svelte:options runes={true} />
|
||||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {OpenSubsonic} from "$lib/opensubsonic";
|
||||||
|
import {onMount} from "svelte";
|
||||||
|
import QueueFrame from "$lib/components/custom/QueueFrame.svelte";
|
||||||
|
import PlayerFrame from "$lib/components/custom/PlayerFrame.svelte";
|
||||||
|
|
||||||
|
//let audioSource: HTMLAudioElement = $state(new Audio());
|
||||||
|
//let paused: boolean = $derived(audioSource.paused);
|
||||||
|
//let volume: number = $derived(audioSource.volume);
|
||||||
|
|
||||||
|
|
||||||
|
let source: HTMLAudioElement = $state();
|
||||||
|
|
||||||
|
let audio = $state({
|
||||||
|
get paused() {
|
||||||
|
if (source) {
|
||||||
|
return source.paused;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get volume() {
|
||||||
|
if (source) {
|
||||||
|
return source.volume;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set volume(volume) {
|
||||||
|
source.volume = volume;
|
||||||
|
},
|
||||||
|
|
||||||
|
get duration() {
|
||||||
|
if (source) {
|
||||||
|
return source.duration;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get currentTime() {
|
||||||
|
if (source) {
|
||||||
|
return source.currentTime;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let queue: Array<unknown> = $state([]);
|
||||||
|
|
||||||
|
async function fetchQueue() {
|
||||||
|
const data = await OpenSubsonic.get("getPlayQueue");
|
||||||
|
if (data) {
|
||||||
|
queue = [];
|
||||||
|
queue = queue.concat(data.playQueue.entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQueue() {
|
||||||
|
let songs = [];
|
||||||
|
|
||||||
|
queue.forEach((song, idx) => {
|
||||||
|
if (idx === 0) {
|
||||||
|
songs.push({parameter: "current", value: song.id})
|
||||||
|
songs.push({parameter: "id", value: song.id})
|
||||||
|
// Add Progress within current song
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
songs.push({parameter: "id", value: song.id})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = await OpenSubsonic.get("savePlayQueue", songs);
|
||||||
|
if (data) {
|
||||||
|
await fetchQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSongFromQueue(idx: number) {
|
||||||
|
if (idx > -1) {
|
||||||
|
queue.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSong(songIndex: number) {
|
||||||
|
console.log(queue[songIndex].title);
|
||||||
|
const chosenSong = queue[songIndex];
|
||||||
|
let parameters = [
|
||||||
|
{ parameter: "id", value: chosenSong.id },
|
||||||
|
//{ parameter: "maxBitRate", value: } // TODO
|
||||||
|
//{ parameter: "format", value: } // TODO
|
||||||
|
//{ parameter: "timeOffset", value: } // TODO? Only Video related
|
||||||
|
//{ parameter: "size", value: } // TODO? Only Video related
|
||||||
|
{ parameter: "estimateContentLength", value: "true" },
|
||||||
|
//{ parameter: "converted", value: } // TODO? Only Video related
|
||||||
|
];
|
||||||
|
let url = OpenSubsonic.getApiUrl("stream", parameters);
|
||||||
|
|
||||||
|
await pause();
|
||||||
|
source = new Audio(url);
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
await source.play().catch(() => {});
|
||||||
|
await new Promise(resolve => {setTimeout(resolve, 50)});
|
||||||
|
audio.volume = 0.2;
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
async function pause() {
|
||||||
|
source.pause();
|
||||||
|
await new Promise(resolve => {setTimeout(resolve, 50)});
|
||||||
|
forceUpdate();
|
||||||
|
//audio.paused = audio.source.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceUpdate() {
|
||||||
|
let tmp = audio;
|
||||||
|
audio = {};
|
||||||
|
audio = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchQueue();
|
||||||
|
source = new Audio();
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="border border-2 flex-1 grid grid-cols-5">
|
||||||
|
<div class="border border-2 col-span-1">
|
||||||
|
<h1>Left Sidebar</h1>
|
||||||
|
</div>
|
||||||
|
<div class="border border-2 col-span-3">
|
||||||
|
<h1>Center</h1>
|
||||||
|
<button onclick={() => audio.volume += 0.1}>Louder</button>
|
||||||
|
<button onclick={() => audio.volume -= 0.1}>Quieter</button>
|
||||||
|
{#if typeof audio !== "undefined"}
|
||||||
|
<p>{audio.currentTime}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<QueueFrame {queue} {fetchQueue} {saveQueue} {removeSongFromQueue} {playSong} />
|
||||||
|
</div>
|
||||||
|
<div class="border border-2 min-h-24 h-24">
|
||||||
|
{#if typeof audio !== "undefined"}
|
||||||
|
<PlayerFrame
|
||||||
|
audio={audio}
|
||||||
|
pause={pause}
|
||||||
|
play={play}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -11,10 +11,9 @@
|
||||||
superForm,
|
superForm,
|
||||||
} from "sveltekit-superforms";
|
} from "sveltekit-superforms";
|
||||||
import { zodClient } from "sveltekit-superforms/adapters";
|
import { zodClient } from "sveltekit-superforms/adapters";
|
||||||
import {Circle2} from "svelte-loading-spinners";
|
|
||||||
import {Md5} from "ts-md5";
|
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
import {goto} from "$app/navigation";
|
import {goto} from "$app/navigation";
|
||||||
|
import LoadingSpinner from "$lib/components/custom/LoadingSpinner.svelte";
|
||||||
|
|
||||||
let { data }: SuperValidated<Infer<FormSchema>> = $props<{ data: SuperValidated<Infer<FormSchema>> }>();
|
let { data }: SuperValidated<Infer<FormSchema>> = $props<{ data: SuperValidated<Infer<FormSchema>> }>();
|
||||||
|
|
||||||
|
@ -23,10 +22,9 @@
|
||||||
onUpdated() {
|
onUpdated() {
|
||||||
OpenSubsonic.username = previousForm.get("username");
|
OpenSubsonic.username = previousForm.get("username");
|
||||||
OpenSubsonic.password = previousForm.get("password");
|
OpenSubsonic.password = previousForm.get("password");
|
||||||
let path = OpenSubsonic.getApiPath("ping");
|
OpenSubsonic.get("ping").then((data) => {
|
||||||
fetch(path).then(async (data) => {
|
if (data) {
|
||||||
let res = (await data.json())["subsonic-response"];
|
if (data.status === "ok") {
|
||||||
if (res.status === "ok") {
|
|
||||||
OpenSubsonic.storeCredentials();
|
OpenSubsonic.storeCredentials();
|
||||||
loading = false;
|
loading = false;
|
||||||
let route = Cookies.get("preLoginRoute")
|
let route = Cookies.get("preLoginRoute")
|
||||||
|
@ -37,6 +35,11 @@
|
||||||
error = true;
|
error = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
error = true;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSubmit({ formData }) {
|
onSubmit({ formData }) {
|
||||||
|
@ -69,12 +72,7 @@
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
<div class="flex flex-row justify-center pt-2">
|
<div class="flex flex-row justify-center pt-2">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<Circle2
|
<LoadingSpinner />
|
||||||
size="40" unit="px"
|
|
||||||
colorOuter="#0892B4" durationOuter="1s"
|
|
||||||
colorCenter="white" durationCenter="3s"
|
|
||||||
colorInner="#9cc1c9" durationInner="2s"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Form.Button>Login</Form.Button>
|
<Form.Button>Login</Form.Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -2186,7 +2186,7 @@ svelte-preprocess@^5.1.3:
|
||||||
sorcery "^0.11.0"
|
sorcery "^0.11.0"
|
||||||
strip-indent "^3.0.0"
|
strip-indent "^3.0.0"
|
||||||
|
|
||||||
svelte@^5.0.0-next:
|
svelte@^5.0.0-next.110:
|
||||||
version "5.0.0-next.110"
|
version "5.0.0-next.110"
|
||||||
resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.0.0-next.110.tgz#90ccb7500fc257b21f311f28bd82110585c2f58c"
|
resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.0.0-next.110.tgz#90ccb7500fc257b21f311f28bd82110585c2f58c"
|
||||||
integrity sha512-RDeoTJtI7HRx1VPROJ2qeibL14OPTQGmNrjM8Lug/N4hJXtolmA7USKggcHL3RXl1loS5Zb1R0IV7stfS7GhQg==
|
integrity sha512-RDeoTJtI7HRx1VPROJ2qeibL14OPTQGmNrjM8Lug/N4hJXtolmA7USKggcHL3RXl1loS5Zb1R0IV7stfS7GhQg==
|
||||||
|
|
Loading…
Reference in a new issue