mirror of
https://github.com/JuLi0n21/pwa-player.git
synced 2026-04-19 15:30:05 +00:00
some other small improvements
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Song, CollectionPreview } from "../script/types";
|
import type { Song } from "../script/types";
|
||||||
import { useAudio } from "@/composables/useAudio";
|
import { useAudio } from "@/composables/useAudio";
|
||||||
import { ref } from "vue";
|
|
||||||
import { RouterLink } from "vue-router";
|
|
||||||
import { useUser } from "@/composables/useUser";
|
import { useUser } from "@/composables/useUser";
|
||||||
|
|
||||||
const audioStore = useAudio();
|
const audioStore = useAudio();
|
||||||
const userStore = useUser();
|
const userStore = useUser();
|
||||||
|
|
||||||
@@ -13,48 +12,72 @@ const props = defineProps<{
|
|||||||
search: string;
|
search: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function update(hash: string) {
|
function playSong(hash: string) {
|
||||||
audioStore.setSong(props.songs[props.songs.findIndex((s) => s.hash == hash)]);
|
const selected = props.songs.find((s) => s.hash === hash);
|
||||||
|
if (selected) {
|
||||||
|
audioStore.setSong(selected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightText(text: string, searchterm: string) {
|
function highlightText(text: string, searchterm: string) {
|
||||||
if (!searchterm) return text;
|
if (!searchterm) return text;
|
||||||
const regex = new RegExp(`(${searchterm})`, "gi");
|
const regex = new RegExp(`(${searchterm})`, "gi");
|
||||||
return text.replace(regex, '<span style="color: yellow;">$1</span>');
|
return text.replace(regex, '<span class="font-bold text-yellow-400">$1</span>');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="border rounded-lg w-full h-full overflow-scroll overflow-x-hidden text-xs bordercolor bg">
|
<div class="w-full h-full text-xs">
|
||||||
<div v-if="props.artist && props.artist.length > 0" class="border bordercolor">
|
|
||||||
<h2 class="text-2xl action">Artists</h2>
|
<div v-if="props.artist && props.artist.length > 0" class="mb-4">
|
||||||
<ul>
|
<h2 class="px-4 py-2 font-semibold text-lg uppercase tracking-wider action">Artists</h2>
|
||||||
<li v-for="(artist, index) in props.artist" :key="index" class="rounded-lg">
|
<ul class="space-y-1 px-2">
|
||||||
<RouterLink class="flex" :to="'/search?a=' + artist" v-html="highlightText(artist, props.search)">
|
<li v-for="(artist, index) in props.artist" :key="index">
|
||||||
|
<RouterLink
|
||||||
|
class="flex items-center bg-white/5 hover:bg-white/10 p-3 border border-transparent rounded-xl transition-colors hover:bordercolor"
|
||||||
|
:to="'/search?a=' + artist"
|
||||||
|
>
|
||||||
|
<div class="flex justify-center items-center bg-yellow-500/20 mr-3 rounded-full w-10 h-10">
|
||||||
|
<i class="text-yellow-500 fa-solid fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-base info" v-html="highlightText(artist, props.search)"></span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.songs && props.songs.length > 0" class="border bordercolor">
|
|
||||||
<h2 class="text-2xl action">Songs</h2>
|
<div v-if="props.songs && props.songs.length > 0">
|
||||||
<ul>
|
<h2 class="px-4 py-2 font-semibold text-lg uppercase tracking-wider action">Songs</h2>
|
||||||
<li v-for="(song, index) in props.songs" :key="index" class="rounded-lg">
|
<ul class="space-y-1 px-2 pb-20"> <li v-for="(song, index) in props.songs" :key="index">
|
||||||
<button @click="update(song.hash)" class="flex">
|
<button
|
||||||
|
@click="playSong(song.hash)"
|
||||||
|
class="flex items-center p-2 rounded-xl w-full text-left transition-colors"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
:src="
|
:src="song.previewimage
|
||||||
encodeURI(
|
? `${userStore.cloudflareUrl.value}${song.previewimage}?h=120&w=120`
|
||||||
`${userStore.cloudflareUrl.value}/${song.previewimage ? song.previewimage + '?h=120&w=120' : '/default-bg.png'}`,
|
: '/default-bg.png'"
|
||||||
)
|
class="rounded-lg w-12 h-12 object-cover shrink-0"
|
||||||
"
|
loading="lazy"
|
||||||
class="w-12 h-12"
|
|
||||||
/>
|
/>
|
||||||
<p class="ml-2 overflow-hidden text-ellipsis text-nowrap">
|
<div class="flex-1 ml-3 overflow-hidden">
|
||||||
<span v-html="highlightText(song.name, search)"></span> -
|
<p class="text-base truncate info">
|
||||||
<span v-html="highlightText(song.artist, props.search)"></span>
|
<span v-html="highlightText(song.name, search)"></span>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="opacity-60 text-sm truncate action">
|
||||||
|
<span v-html="highlightText(song.artist, props.search)"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<i class="opacity-0 group-hover:opacity-100 ml-2 pr-2 transition-opacity fa-solid fa-play"></i>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.text-yellow-400) {
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
47
frontend/src/components/ActiveSearchSkeleton.vue
Normal file
47
frontend/src/components/ActiveSearchSkeleton.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full text-xs animate-pulse">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="my-2 ml-4 rounded w-24 h-6 action"></div>
|
||||||
|
<ul class="space-y-1 px-2">
|
||||||
|
<li v-for="i in 2" :key="'art-skel-' + i">
|
||||||
|
<div class="flex items-center opacity-50 p-3 border rounded-xl bordercolor">
|
||||||
|
<div class="mr-3 rounded-full w-10 h-10"></div>
|
||||||
|
<div class="rounded w-32 h-4 info"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="my-2 ml-4 rounded w-24 h-6 action"></div>
|
||||||
|
<ul class="space-y-1 px-2">
|
||||||
|
<li v-for="i in 6" :key="'song-skel-' + i">
|
||||||
|
<div class="flex items-center p-2 rounded-xl w-full">
|
||||||
|
<div class="rounded-lg w-12 h-12 shrink-0"></div>
|
||||||
|
|
||||||
|
<div class="flex-1 space-y-2 ml-3">
|
||||||
|
<div class="rounded w-3/4 h-4 info"></div>
|
||||||
|
<div class="rounded w-1/2 h-3 b action"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.info { background-color: currentColor; opacity: 0.1; }
|
||||||
|
.action { background-color: currentColor; opacity: 0.05; }
|
||||||
|
</style>
|
||||||
@@ -85,12 +85,20 @@ onBeforeUnmount(() => {
|
|||||||
<hr />
|
<hr />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex-col flex-1 justify-start overflow-y-scroll coll-container">
|
<div class="gap-2 grid grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 p-2 overflow-y-auto collection-container song-item-wrapper coll-container">
|
||||||
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<SongItemSkeleton v-for="i in 10" :key="'skeleton-' + i" />
|
<SongItemSkeleton v-for="i in 10" :key="'skeleton-' + i" />
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.song-item-wrapper {
|
||||||
|
content-visibility: auto;
|
||||||
|
|
||||||
|
contain-intrinsic-size: 96px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,11 +9,11 @@ const props = defineProps<{ collection: CollectionPreview }>();
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink :to="'/collection/' + props.collection.index" class="w-full">
|
<RouterLink :to="'/collection/' + props.collection.index" class="w-full">
|
||||||
<div class="flex items-center border rounded-lg h-24 overflow-hidden bordercolor"> <img
|
<div class="flex items-center border rounded-lg h-24 overflow-hidden bordercolor"> <img
|
||||||
class="m-2 rounded-lg w-20 h-20"
|
class="p-1 rounded-lg w-24 h-24"
|
||||||
:src="encodeURI(`${userStore.cloudflareUrl.value}${props.collection.previewimage}`)"
|
:src="encodeURI(`${userStore.cloudflareUrl.value}${props.collection.previewimage}`)"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col overflow-hidden text-left">
|
<div class="flex flex-col overflow-hidden text-left">
|
||||||
<h3 class="overflow-hidden text-ellipsis whitespace-nowrap info">{{ props.collection.name }}</h3>
|
<h3 class="overflow-hidden text-ellipsis whitespace-nowrap info">{{ props.collection.name }}</h3>
|
||||||
<h5 class="text-sm info">{{ props.collection.length }} Songs</h5>
|
<h5 class="text-sm info">{{ props.collection.length }} Songs</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center bg-white/5 border rounded-lg h-24 animate-pulse bordercolor">
|
<div class="flex items-center bg-white/5 border rounded-lg h-24 animate-pulse bordercolor">
|
||||||
<div class="bg-white/10 m-2 rounded-lg w-20 h-20 shrink-0"></div>
|
<div class="bg-white/10 p-1 rounded-lg w-24 h-24 shrink-0"></div>
|
||||||
<div class="flex flex-col flex-1 justify-center gap-2 p-2 overflow-hidden">
|
<div class="flex flex-col flex-1 justify-center gap-2 p-2 overflow-hidden">
|
||||||
<div class="bg-white/10 rounded w-3/4 h-5"></div>
|
<div class="bg-white/10 rounded w-3/4 h-5"></div>
|
||||||
<div class="bg-white/5 rounded w-1/4 h-3"></div>
|
<div class="bg-white/5 rounded w-1/4 h-3"></div>
|
||||||
|
|||||||
@@ -18,32 +18,35 @@ function updateSong() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div @click="updateSong" :style="{ borderColor: border }" class="flex m-1 border rounded-lg md:text-xl bordercolor">
|
<div
|
||||||
|
@click="updateSong"
|
||||||
|
:style="{ borderColor: border }"
|
||||||
|
class="flex items-center m-1 border rounded-lg h-16 md:h-24 overflow-hidden md:text-xl cursor-pointer bordercolor"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
class="m-1 rounded-lg w-14 md:w-24 h-14 md:h-24"
|
class="p-1 rounded-lg w-14 md:w-24 h-16 md:h-24 object-cover shrink-0"
|
||||||
:src="
|
:src="props.song?.previewimage
|
||||||
props.song?.previewimage
|
? encodeURI(`${userStore.cloudflareUrl.value}${props.song.previewimage}?h=56&w=56`)
|
||||||
? encodeURI(`${userStore.cloudflareUrl.value}${props.song.previewimage}?h=56&w=56`)
|
: '/default-bg.png'"
|
||||||
: '/default-bg.png'
|
|
||||||
"
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col overflow-hidden text-left">
|
|
||||||
|
<div class="flex flex-col flex-1 justify-center overflow-hidden text-left">
|
||||||
<p :style="{ color: info }" class="overflow-hidden text-base text-ellipsis text-nowrap info">
|
<p :style="{ color: info }" class="overflow-hidden text-base text-ellipsis text-nowrap info">
|
||||||
<slot name="songName">{{ props.song?.name ? props.song?.name : "Unknown Title" }}</slot>
|
<slot name="songName">{{ props.song?.name || "Unknown Title" }}</slot>
|
||||||
</p>
|
</p>
|
||||||
<h5 :style="{ color: action }" class="overflow-hidden text-sm text-base text-ellipsis text-nowrap action">
|
<h5 :style="{ color: action }" class="overflow-hidden text-sm text-ellipsis text-nowrap action">
|
||||||
<slot name="artist">{{ props.song?.artist ? props.song.artist : "Unknown Artist" }}</slot>
|
<slot name="artist">{{ props.song?.artist || "Unknown Artist" }}</slot>
|
||||||
</h5>
|
</h5>
|
||||||
<h5 :style="{ color: action }" class="text-sm action">
|
<h5 :style="{ color: action }" class="text-sm action">
|
||||||
<slot name="length"
|
<slot name="length">
|
||||||
>{{ Math.floor(props.song?.length / 60000 || 0) }}:{{
|
{{ Math.floor(props.song?.length / 60000 || 0) }}:{{
|
||||||
Math.floor((props.song?.length ?? 0 / 1000) % 60)
|
Math.floor((props.song?.length / 1000 || 0) % 60)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, "0")
|
.padStart(2, "0")
|
||||||
}}</slot
|
}}
|
||||||
>
|
</slot>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex bg-white/5 m-1 border rounded-lg md:text-xl animate-pulse bordercolor">
|
<div class="flex bg-white/5 m-1 border rounded-lg h-16 md:h-24 md:text-xl animate-pulse bordercolor">
|
||||||
<div class="flex-shrink-0 bg-white/10 m-1 rounded-lg w-14 md:w-24 h-14 md:h-24"></div>
|
<div class="bg-white/10 rounded-lg w-14 md:w-24 h-16 md:h-24 shrink-0"></div>
|
||||||
<div class="flex flex-col flex-1 justify-center gap-2 px-2 overflow-hidden text-left">
|
<div class="flex flex-col flex-1 justify-center gap-2 px-2 overflow-hidden text-left">
|
||||||
<div class="bg-white/10 rounded w-3/4 h-4 info"></div>
|
<div class="bg-white/10 rounded w-3/4 h-4 info"></div>
|
||||||
<div class="bg-white/5 rounded w-1/2 h-3 action"></div>
|
<div class="bg-white/5 rounded w-1/2 h-3 action"></div>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ const { musicApi } = useApi();
|
|||||||
const api = musicApi.value;
|
const api = musicApi.value;
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const parentRef = ref<HTMLElement | null>(null);
|
||||||
const collections = ref<CollectionPreview[]>([]);
|
const collections = ref<CollectionPreview[]>([]);
|
||||||
const limit = ref(12);
|
const limit = ref(12 * 4);
|
||||||
const offset = ref(0);
|
const offset = ref(0);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
@@ -23,16 +24,22 @@ const fetchCollections = async () => {
|
|||||||
const newItems = response.data.collections || [];
|
const newItems = response.data.collections || [];
|
||||||
|
|
||||||
if (newItems.length > 0) {
|
if (newItems.length > 0) {
|
||||||
let mapped = mapApiToCollectionPreview(newItems, offset.value);
|
const mapped = mapApiToCollectionPreview(newItems, offset.value);
|
||||||
collections.value = [...collections.value, ...mapped];
|
collections.value = [...collections.value, ...mapped];
|
||||||
offset.value += limit.value;
|
offset.value += limit.value;
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
const container = containerRef.value;
|
const container = containerRef.value;
|
||||||
if (container && container.scrollHeight <= container.clientHeight) {
|
if (container) {
|
||||||
isLoading.value = false;
|
const noScrollbarYet = container.scrollHeight <= container.clientHeight + 0;
|
||||||
await fetchCollections();
|
|
||||||
|
const mightBeMoreData = newItems.length === limit.value;
|
||||||
|
|
||||||
|
if (noScrollbarYet && mightBeMoreData) {
|
||||||
|
isLoading.value = false;
|
||||||
|
return await fetchCollections();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -58,10 +65,12 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="flex flex-col flex-1 h-full overflow-y-hidden text-center">
|
<main class="flex flex-col w-full h-full overflow-hidden">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="gap-2 grid grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 p-2 overflow-y-auto collection-container"
|
class="flex-1 content-start gap-2 grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 p-2 overflow-y-auto"
|
||||||
|
style="min-height: 0;"
|
||||||
>
|
>
|
||||||
<CollectionListItem
|
<CollectionListItem
|
||||||
v-for="(collection, index) in collections"
|
v-for="(collection, index) in collections"
|
||||||
@@ -74,4 +83,17 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.collection-grid {
|
||||||
|
/* Force the grid to at least fill the available height */
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure the wrapper doesn't cause items to shrink */
|
||||||
|
.song-item-wrapper {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 96px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,10 +15,7 @@ const borderColor = ref("");
|
|||||||
|
|
||||||
const loginStatus = ref("Login");
|
const loginStatus = ref("Login");
|
||||||
|
|
||||||
function update() {
|
const isHealthy = ref<boolean | null>(null);
|
||||||
var input = document.getElementById("url-input") as HTMLInputElement;
|
|
||||||
userStore.cloudflareUrl.value = input.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(bg: string | null, main: string | null, info: string | null, border: string | null) {
|
function save(bg: string | null, main: string | null, info: string | null, border: string | null) {
|
||||||
document.documentElement.style.setProperty("--background-color", bg ?? bgColor.value);
|
document.documentElement.style.setProperty("--background-color", bg ?? bgColor.value);
|
||||||
@@ -47,8 +44,41 @@ async function getMe() {
|
|||||||
userStore.cloudflareUrl.value = data.endpoint;
|
userStore.cloudflareUrl.value = data.endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pasteFromClipboard() {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
userStore.cloudflareUrl.value = text;
|
||||||
|
await checkHealth(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to read clipboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
await navigator.clipboard.writeText(userStore.cloudflareUrl.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHealth(url: string) {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}/ping`);
|
||||||
|
isHealthy.value = response.ok;
|
||||||
|
} catch (e) {
|
||||||
|
isHealthy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(e: Event) {
|
||||||
|
const val = (e.target as HTMLInputElement).value;
|
||||||
|
userStore.cloudflareUrl.value = val;
|
||||||
|
checkHealth(val);
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
reset();
|
reset();
|
||||||
|
if (userStore.cloudflareUrl.value) {
|
||||||
|
checkHealth(userStore.cloudflareUrl.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -68,8 +98,8 @@ function reset() {
|
|||||||
<header>
|
<header>
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<nav class="flex justify-start space-x-1 mx-1 my-2">
|
<nav class="flex justify-start space-x-1 mx-1 my-2">
|
||||||
<RouterLink class="shadow-xl backdrop--light p-1 rounded-full" to="/"
|
<RouterLink class="shadow-xl backdrop--light p-1 rounded-full" to="/">
|
||||||
><i class="fa-arrow-left fa-solid"></i>
|
<i class="fa-arrow-left fa-solid"></i>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -77,31 +107,37 @@ function reset() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex flex-col flex-1 h-full overflow-y-scroll">
|
<main class="flex flex-col flex-1 h-full overflow-y-scroll">
|
||||||
<input @change="update" type="text" id="url-input" :value="userStore.user.value?.endpoint" disabled />
|
<div class="flex flex-col gap-2 p-4">
|
||||||
<br />
|
<div class="flex gap-1 overflow-hidden">
|
||||||
<button v-if="!userStore.user.value" @click="getMe" class="p-0.5 border rounded-lg bordercolor">
|
<input
|
||||||
{{ loginStatus }}
|
@change="update"
|
||||||
</button>
|
type="text"
|
||||||
<div v-if="userStore.user.value" class="flex justify-between p-5">
|
id="url-input"
|
||||||
<img :src="userStore.user.value.avatar_url" class="w-1/3" />
|
:value="userStore.cloudflareUrl.value"
|
||||||
<div>
|
class="flex-1 bg-white/5 p-2 border-y border-l rounded-l-lg outline-none bordercolor"
|
||||||
<p>{{ userStore.user.value.name }}</p>
|
placeholder="https://..."
|
||||||
<p>
|
/>
|
||||||
{{ userStore.user.value.endpoint == "" ? "Not Connected" : "Connected" }}
|
<button @click="copyToClipboard" class="bg-white/5 hover:bg-white/10 p-2 border bordercolor" title="Copy URL">
|
||||||
</p>
|
<i class="fa-solid fa-copy"></i>
|
||||||
<p>
|
</button>
|
||||||
Sharing:
|
<button @click="pasteFromClipboard" class="bg-white/5 hover:bg-white/10 p-2 border rounded-r-lg text-yellow-500 bordercolor" title="Paste URL">
|
||||||
<button
|
<i class="fa-solid fa-paste"></i>
|
||||||
@click="userStore.user.value.share = !userStore.user.value.share"
|
</button>
|
||||||
class="p-0.5 border rounded-lg bordercolor"
|
<div
|
||||||
>
|
v-if="isHealthy !== null"
|
||||||
{{ userStore.user.value.share }}
|
class="self-center rounded-full w-2 h-2"
|
||||||
</button>
|
:class="isHealthy ? 'bg-green-500 shadow-[0_0_8px_green]' : 'bg-red-500 shadow-[0_0_8px_red]'"
|
||||||
</p>
|
>
|
||||||
<button @click="getMe" class="p-0.5 border rounded-lg bordercolor">Refresh</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<button v-if="!userStore.user.value" @click="getMe" class="mx-4 p-0.5 border rounded-lg bordercolor">
|
||||||
|
{{ loginStatus }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="flex flex-col justify-around p-10 w-full">
|
<div class="flex flex-col justify-around p-10 w-full">
|
||||||
<div class="flex flex-1 justify-between">
|
<div class="flex flex-1 justify-between">
|
||||||
<p>Background:</p>
|
<p>Background:</p>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="flex-1 gap-2 grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 p-1 overflow-y-scroll song-container"
|
class="flex-1 content-start gap-2 grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 p-1 overflow-y-scroll coll-container"
|
||||||
>
|
>
|
||||||
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
||||||
|
|
||||||
@@ -73,3 +73,10 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.song-item-wrapper {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 96px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,53 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { mapApiToSongs, mapToSong, type Song } from "../script/types";
|
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import ActiveSearchList from "../components/ActiveSearchList.vue";
|
|
||||||
import SongItem from "../components/SongItem.vue";
|
|
||||||
import { useAudio } from "@/composables/useAudio";
|
import { useAudio } from "@/composables/useAudio";
|
||||||
import { useUser } from "@/composables/useUser";
|
import { useUser } from "@/composables/useUser";
|
||||||
import { useApi } from "@/composables/useApi";
|
import { useApi } from "@/composables/useApi";
|
||||||
|
import { mapApiToSongs, type Song } from "../script/types";
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import ActiveSearchList from "../components/ActiveSearchList.vue";
|
||||||
|
import SongItem from "../components/SongItem.vue";
|
||||||
|
import SongItemSkeleton from "../components/SongItemSkeleton.vue";
|
||||||
|
import ActiveSearchSkeleton from "@/components/ActiveSearchSkeleton.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const audioStore = useAudio();
|
const audioStore = useAudio();
|
||||||
const userStore = useUser();
|
const userStore = useUser();
|
||||||
const { musicApi } = useApi();
|
const { musicApi } = useApi();
|
||||||
const api = musicApi.value;
|
const api = musicApi.value;
|
||||||
|
|
||||||
const activesongs = ref<Song[]>([]);
|
// State
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
|
const activesongs = ref<Song[]>([]);
|
||||||
const artists = ref<string[]>([]);
|
const artists = ref<string[]>([]);
|
||||||
|
const searchInput = ref((route.query.s as string) || "");
|
||||||
|
const isLoading = ref(false);
|
||||||
const showSearch = ref(false);
|
const showSearch = ref(false);
|
||||||
const searchTerm = ref("");
|
|
||||||
|
|
||||||
async function fetchActiveSearch(term: string) {
|
async function fetchActiveSearch(term: string) {
|
||||||
const response = await api.musicBackendSearch(term);
|
if (!term.trim()) return emptySearch();
|
||||||
|
|
||||||
const songData = mapApiToSongs(response.data.songs ?? []);
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
activesongs.value = songData;
|
const response = await api.musicBackendSearch(term);
|
||||||
|
const songData = mapApiToSongs(response.data.songs ?? []);
|
||||||
if (response.data.artist) artists.value = [response.data.artist];
|
|
||||||
audioStore.setCollection(songData);
|
activesongs.value = songData;
|
||||||
showSearch.value = true;
|
artists.value = response.data.artist ? [response.data.artist] : [];
|
||||||
searchTerm.value = term;
|
|
||||||
router.replace({ query: { s: term } });
|
audioStore.setCollection(songData);
|
||||||
|
showSearch.value = true;
|
||||||
|
router.replace({ query: { ...route.query, s: term } });
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full song list for a specific Artist
|
||||||
|
*/
|
||||||
async function fetchSearchArtist(artist: string) {
|
async function fetchSearchArtist(artist: string) {
|
||||||
const response = await api.musicBackendArtist(artist);
|
isLoading.value = true;
|
||||||
|
showSearch.value = false; // Hide recommendations overlay when a choice is made
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.musicBackendArtist(artist);
|
||||||
|
const data = mapApiToSongs(response.data.songs || []);
|
||||||
|
|
||||||
const data = mapApiToSongs(response.data.songs || []);
|
songs.value = data.map((song: Song) => ({
|
||||||
|
...song,
|
||||||
data.forEach((song: Song) => {
|
previewimage: `${userStore.cloudflareUrl.value}/api/v1/images/${song.previewimage}`,
|
||||||
song.previewimage = `${userStore.cloudflareUrl.value}/api/v1/images/${song.previewimage}`;
|
url: `${userStore.cloudflareUrl.value}/api/v1/audio/${song.url}`
|
||||||
song.url = `${userStore.cloudflareUrl.value}/api/v1/audio/${song.url}`;
|
}));
|
||||||
});
|
|
||||||
|
router.replace({ query: { ...route.query, a: artist } });
|
||||||
songs.value = data;
|
} finally {
|
||||||
showSearch.value = false;
|
isLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emptySearch() {
|
async function emptySearch() {
|
||||||
@@ -55,75 +74,95 @@ async function emptySearch() {
|
|||||||
artists.value = [];
|
artists.value = [];
|
||||||
songs.value = [];
|
songs.value = [];
|
||||||
showSearch.value = false;
|
showSearch.value = false;
|
||||||
searchTerm.value = "";
|
|
||||||
router.replace({ query: {} });
|
|
||||||
searchInput.value = "";
|
searchInput.value = "";
|
||||||
|
router.replace({ query: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
let debounceTimeout: any;
|
||||||
if (route.query.a) {
|
watch(searchInput, (val) => {
|
||||||
await fetchSearchArtist(route.query.a as string);
|
clearTimeout(debounceTimeout);
|
||||||
}
|
if (val && val.trim() !== "") {
|
||||||
if (route.query.s) {
|
debounceTimeout = setTimeout(() => fetchActiveSearch(val), 300);
|
||||||
await fetchActiveSearch(route.query.s as string);
|
} else {
|
||||||
|
emptySearch();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(() => route.query.a, (newArtist) => {
|
||||||
() => route.query.a,
|
if (newArtist) fetchSearchArtist(newArtist as string);
|
||||||
async (newArtist) => {
|
});
|
||||||
if (newArtist) {
|
|
||||||
await fetchSearchArtist(newArtist as string);
|
|
||||||
} else {
|
|
||||||
songs.value = [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchInput = ref(searchTerm.value);
|
onMounted(() => {
|
||||||
|
if (route.query.a) fetchSearchArtist(route.query.a as string);
|
||||||
watch(searchInput, async (val) => {
|
else if (route.query.s) fetchActiveSearch(route.query.s as string);
|
||||||
if (val && val.trim() !== "") {
|
|
||||||
await fetchActiveSearch(val);
|
|
||||||
} else {
|
|
||||||
showSearch.value = false;
|
|
||||||
activesongs.value = [];
|
|
||||||
artists.value = [];
|
|
||||||
router.replace({ query: {} });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<header class="top-0 z-30 sticky bg-black/10 backdrop-blur-md">
|
||||||
<div class="wrapper">
|
<div class="p-2 wrapper">
|
||||||
<nav class="flex justify-start space-x-1 mx-1 my-2">
|
<nav class="relative flex items-center h-10">
|
||||||
<RouterLink class="shadow-xl backdrop--light p-1 rounded-full" to="/">
|
<RouterLink class="z-10 bg-white/5 shadow-xl p-2 rounded-full" to="/">
|
||||||
<i class="fa-arrow-left fa-solid"></i>
|
<i class="fa-arrow-left fa-solid"></i>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<h1 class="right-0 left-0 absolute text-center">Search</h1>
|
<h1 class="absolute inset-0 flex justify-center items-center font-bold text-xl">Search</h1>
|
||||||
</nav>
|
</nav>
|
||||||
<hr />
|
<hr class="opacity-10 mt-2" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex flex-col flex-1 w-full h-full">
|
<main class="flex flex-col flex-1 w-full h-full overflow-hidden">
|
||||||
<div class="relative">
|
<div class="relative p-2">
|
||||||
<input
|
<input
|
||||||
v-model="searchInput"
|
v-model="searchInput"
|
||||||
placeholder="Type to Search..."
|
placeholder="Type to Search..."
|
||||||
class="flex-1 bg-yellow-300 bg-opacity-20 p-2 border rounded-lg w-full max-h-12 accent-pink-800 search bordercolor"
|
class="flex-1 bg-white/5 p-4 border rounded-xl outline-none ring-yellow-500/50 focus:ring-2 w-full h-14 transition-all bordercolor"
|
||||||
/>
|
/>
|
||||||
<div class="top-4 right-4 absolute flex flex-col justify-center cursor-pointer" @click="emptySearch">
|
<div
|
||||||
<i class="opacity-50 far fa-times-circle"></i>
|
v-if="searchInput"
|
||||||
|
class="top-1/2 right-6 absolute opacity-50 hover:opacity-100 -translate-y-1/2 cursor-pointer"
|
||||||
|
@click="emptySearch"
|
||||||
|
>
|
||||||
|
<i class="text-xl far fa-times-circle"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative flex flex-col w-full h-full overflow-y-scroll">
|
<div class="relative flex-1 overflow-y-auto">
|
||||||
<div v-if="showSearch" class="z-20 absolute w-full text-center search-recommendations">
|
|
||||||
<ActiveSearchList :songs="activesongs" :artist="artists" :search="searchTerm" />
|
<div
|
||||||
|
v-if="showSearch && (activesongs.length || artists.length || isLoading)"
|
||||||
|
class="z-20 absolute backdrop-blur-xl w-full min-h-full"
|
||||||
|
>
|
||||||
|
<ActiveSearchSkeleton v-if="isLoading" />
|
||||||
|
|
||||||
|
<ActiveSearchList
|
||||||
|
v-else
|
||||||
|
:songs="activesongs"
|
||||||
|
:artist="artists"
|
||||||
|
:search="searchInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<SongItem
|
||||||
|
v-for="(song, index) in songs"
|
||||||
|
:key="song.hash || index"
|
||||||
|
:song="song"
|
||||||
|
class="song-render-node"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="!isLoading && songs.length === 0 && !showSearch" class="col-span-full opacity-30 py-20 text-center">
|
||||||
|
<i class="mb-4 text-6xl fa-solid fa-music"></i>
|
||||||
|
<p>Find your favorite music</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.song-render-node {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 96px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -207,6 +207,10 @@ func runGrpcAndGateway(s *Server, port string) error {
|
|||||||
mux.HandleFunc("/callback/", s.callback)
|
mux.HandleFunc("/callback/", s.callback)
|
||||||
mux.HandleFunc("/api/v1/audio/{filepath}", s.songFile)
|
mux.HandleFunc("/api/v1/audio/{filepath}", s.songFile)
|
||||||
mux.HandleFunc("/api/v1/image/{filepath}", s.imageFile)
|
mux.HandleFunc("/api/v1/image/{filepath}", s.imageFile)
|
||||||
|
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("pong"))
|
||||||
|
})
|
||||||
|
|
||||||
fileServer := http.FileServer(http.Dir("gen/swagger"))
|
fileServer := http.FileServer(http.Dir("gen/swagger"))
|
||||||
mux.Handle("/swagger/", http.StripPrefix("/swagger/", fileServer))
|
mux.Handle("/swagger/", http.StripPrefix("/swagger/", fileServer))
|
||||||
|
|||||||
Reference in New Issue
Block a user