minor improvements

This commit is contained in:
2026-03-12 19:20:46 +01:00
parent 7c61a3ca57
commit 8a2ec44a32
34 changed files with 787 additions and 785 deletions

3
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"printWidth": 120
}

View File

@@ -9,12 +9,8 @@
"name": "MusicBackend" "name": "MusicBackend"
} }
], ],
"consumes": [ "consumes": ["application/json"],
"application/json" "produces": ["application/json"],
],
"produces": [
"application/json"
],
"paths": { "paths": {
"/api/v1/artist/{artist}": { "/api/v1/artist/{artist}": {
"get": { "get": {
@@ -41,9 +37,7 @@
"type": "string" "type": "string"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/api/v1/collections": { "/api/v1/collections": {
@@ -92,9 +86,7 @@
"format": "int32" "format": "int32"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/api/v1/favorites": { "/api/v1/favorites": {
@@ -136,9 +128,7 @@
"format": "int32" "format": "int32"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/api/v1/recent": { "/api/v1/recent": {
@@ -174,9 +164,7 @@
"format": "int32" "format": "int32"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/api/v1/search": { "/api/v1/search": {
@@ -218,9 +206,7 @@
"format": "int32" "format": "int32"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/api/v1/search/artists": { "/api/v1/search/artists": {
@@ -262,9 +248,7 @@
"format": "int32" "format": "int32"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/api/v1/search/collections": { "/api/v1/search/collections": {
@@ -306,9 +290,7 @@
"format": "int32" "format": "int32"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/api/v1/song/{hash}": { "/api/v1/song/{hash}": {
@@ -336,9 +318,7 @@
"type": "string" "type": "string"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
}, },
"/ping": { "/ping": {
@@ -366,9 +346,7 @@
"type": "string" "type": "string"
} }
], ],
"tags": [ "tags": ["MusicBackend"]
"MusicBackend"
]
} }
} }
}, },

View File

@@ -1,21 +1,23 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8"> <link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" <link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg==" integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg=="
crossorigin="anonymous" referrerpolicy="no-referrer" /> crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<title>osu! music player, not affiliated with the osu! trademark</title> <title>osu! music player, not affiliated with the osu! trademark</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"axios": "^1.9.0", "axios": "^1.9.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"prettier": "^3.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
@@ -3641,6 +3642,20 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true "dev": true
}, },
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proxy-agent": { "node_modules/proxy-agent": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",

View File

@@ -9,11 +9,13 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"format": "prettier --write .",
"generate": "openapi-generator-cli generate -i api-specs/osu_music.swagger.json -g typescript-axios -o ./src/generated" "generate": "openapi-generator-cli generate -i api-specs/osu_music.swagger.json -g typescript-axios -o ./src/generated"
}, },
"dependencies": { "dependencies": {
"axios": "^1.9.0", "axios": "^1.9.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"prettier": "^3.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },

View File

@@ -1,43 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router' import { RouterLink, RouterView } from "vue-router";
import NowPlaying from '@/components/NowPlaying.vue' import NowPlaying from "@/components/NowPlaying.vue";
import NowPlayingView from '@/views/NowPlayingView.vue' import NowPlayingView from "@/views/NowPlayingView.vue";
import MenuView from '@/views/MenuView.vue' import MenuView from "@/views/MenuView.vue";
import HistoryView from '@/views/HistoryView.vue' import HistoryView from "@/views/HistoryView.vue";
import Footer from '@/components/Footer.vue' import Footer from "@/components/Footer.vue";
import { ref, onMounted, watch, onUnmounted } from 'vue' import { ref, onMounted, watch, onUnmounted } from "vue";
import { useRoute } from 'vue-router' import { useRoute } from "vue-router";
import { isMobile, isPc } from './script/utils.ts' import { isMobile, isPc } from "./script/utils.ts";
import { useAudio } from './composables/useAudio.ts'
const showNowPlaying = ref(true); const showNowPlaying = ref(true);
const route = useRoute(); const route = useRoute();
const audioRef = ref<HTMLAudioElement | null>(null)
const audio = useAudio(audioRef);
watch(route, async (to) => { watch(route, async (to) => {
if (route.path.startsWith("/nowplaying")) { if (route.path.startsWith("/nowplaying")) {
showNowPlaying.value = false; showNowPlaying.value = false;
} else { } else {
showNowPlaying.value = true; showNowPlaying.value = true;
} }
/* });
if (route.path.startsWith("/menu")) {
headerStore.hide();
} else {
headerStore.show();
}
*/
})
function loadColors() { function loadColors() {
document.documentElement.style.setProperty("--background-color", localStorage.getItem("bgColor") || "#1c1719");
document.documentElement.style.setProperty("--action-color", localStorage.getItem("actionColor") || "#eab308");
document.documentElement.style.setProperty('--background-color', localStorage.getItem('bgColor') || '#1c1719'); document.documentElement.style.setProperty("--information-color", localStorage.getItem("infoColor") || "#ec4899");
document.documentElement.style.setProperty('--action-color', localStorage.getItem('actionColor') || '#eab308'); document.documentElement.style.setProperty("--border-color", localStorage.getItem("borderColor") || "#ec4899");
document.documentElement.style.setProperty('--information-color', localStorage.getItem('infoColor') || '#ec4899');
document.documentElement.style.setProperty('--border-color', localStorage.getItem('borderColor') || '#ec4899');
} }
loadColors(); loadColors();
@@ -50,29 +38,27 @@ const screenInfo = ref({
const checkScreenSize = () => { const checkScreenSize = () => {
screenInfo.value.isSmall = isMobile(); screenInfo.value.isSmall = isMobile();
screenInfo.value.isMedium = isPc(); screenInfo.value.isMedium = isPc();
}; };
onMounted(() => { onMounted(() => {
checkScreenSize(); checkScreenSize();
window.addEventListener('resize', checkScreenSize); window.addEventListener("resize", checkScreenSize);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', checkScreenSize); window.removeEventListener("resize", checkScreenSize);
}); });
</script> </script>
<template> <template>
<div v-if="screenInfo.isSmall" class="flex flex-col h-screen max-h-screen wrapper info text-xl"> <div v-if="screenInfo.isSmall" class="flex flex-col h-screen max-h-screen text-xl wrapper info">
<RouterView /> <RouterView />
<NowPlaying v-show="showNowPlaying" /> <NowPlaying v-show="showNowPlaying" />
<Footer /> <Footer />
</div> </div>
<div v-else class="flex flex-col h-screen max-h-screen wrapper info text-xl"> <div v-else class="flex flex-col h-screen max-h-screen text-xl wrapper info">
<main class="flex flex-1 w-full h-full overflow-y-hidden"> <main class="flex flex-1 w-full h-full overflow-y-hidden">
<aside class="bg-primary p-4 w-1/12 overflow-y-scroll">
<aside class="w-1/12 bg-primary p-4 overflow-y-scroll">
<HistoryView /> <HistoryView />
</aside> </aside>
@@ -80,7 +66,7 @@ onUnmounted(() => {
<RouterView /> <RouterView />
</section> </section>
<section class="w-1/5 overflow-y-scroll flex flex-col"> <section class="flex flex-col w-1/5 overflow-y-scroll">
<NowPlayingView /> <NowPlayingView />
</section> </section>
</main> </main>

View File

@@ -1,19 +1,27 @@
.v-enter-from, .v-leave-to .v-enter-from,
{ .v-leave-to {
animation: fadeIn 0.5s; animation: fadeIn 0.5s;
} }
.v-enter-to, .v-leave-from { .v-enter-to,
animation: fadeOut 0.5s; .v-leave-from {
animation: fadeOut 0.5s;
} }
@keyframes fadeIn { @keyframes fadeIn {
0% { opacity: 0; } 0% {
100% { opacity: 1; } opacity: 0;
}
100% {
opacity: 1;
}
} }
@keyframes fadeOut { @keyframes fadeOut {
0% { opacity: 1; } 0% {
100% { opacity: 0; } opacity: 1;
}
100% {
opacity: 0;
}
} }

View File

@@ -1,11 +1,11 @@
body { body {
background-color: var(--background-color); background-color: var(--background-color);
margin:0; margin: 0;
padding: 0; padding: 0;
} }
* .{ * . {
min-height: 0px; min-height: 0px;
} }
.backdrop--light { .backdrop--light {
@@ -21,10 +21,10 @@ min-height: 0px;
} }
.backdrop--medium--dark { .backdrop--medium--dark {
background-color: rgba(56, 46, 50, 1) background-color: rgba(56, 46, 50, 1);
} }
.backdrop--dark { .backdrop--dark {
background-color: rgba(28, 23, 25, 1) background-color: rgba(28, 23, 25, 1);
} }
.router-link-active { .router-link-active {
@@ -43,25 +43,25 @@ min-height: 0px;
} }
.bg { .bg {
background-color: var(--background-color) background-color: var(--background-color);
} }
.action { .action {
color: var(--action-color) color: var(--action-color);
} }
.info { .info {
color: var(--information-color) color: var(--information-color);
} }
.info:hover { .info:hover {
color: var(--information-color) color: var(--information-color);
} }
searcheven { searcheven {
background-color: lighten(var(--background-color),10%); background-color: lighten(var(--background-color), 10%);
} }
searchodd { searchodd {
background-color: lighten(var(--background-color),15%); background-color: lighten(var(--background-color), 15%);
} }

View File

@@ -1,4 +1,4 @@
@import './base.css'; @import "./base.css";
a, a,
.green { .green {

View File

@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Song, CollectionPreview } from '../script/types' import type { Song, CollectionPreview } from "../script/types";
import { useAudio } from '@/composables/useAudio'; import { useAudio } from "@/composables/useAudio";
import { ref } from 'vue'; import { ref } from "vue";
import { RouterLink } from 'vue-router'; import { RouterLink } from "vue-router";
const audioStore = useAudio() const audioStore = useAudio();
const props = defineProps<{ const props = defineProps<{
songs: Song[]; songs: Song[];
@@ -13,20 +13,18 @@ const props = defineProps<{
}>(); }>();
function update(hash: string) { function update(hash: string) {
audioStore.setSong(props.songs[props.songs.findIndex((s) => s.hash == hash)]);
audioStore.setSong(props.songs.at(props.songs.findIndex(s => s.hash == hash)))
} }
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 style="color: yellow;">$1</span>');
} }
</script> </script>
<template> <template>
<div class="h-full w-full overflow-scroll overflow-x-hidden border bordercolor rounded-lg text-xs bg"> <div class="border rounded-lg w-full h-full overflow-scroll overflow-x-hidden text-xs bordercolor bg">
<div v-if="props.artist && props.artist.length > 0" class="border bordercolor"> <div v-if="props.artist && props.artist.length > 0" class="border bordercolor">
<h2 class="text-2xl action">Artists</h2> <h2 class="text-2xl action">Artists</h2>
<ul> <ul>
@@ -41,15 +39,14 @@ function highlightText(text: string, searchterm: string) {
<ul> <ul>
<li v-for="(song, index) in props.songs" :key="index" class="rounded-lg"> <li v-for="(song, index) in props.songs" :key="index" class="rounded-lg">
<button @click="update(song.hash)" class="flex"> <button @click="update(song.hash)" class="flex">
<img :src="encodeURI(song.previewimage + '?h=120&w=120')" class="h-12 w-12"></img> <img :src="encodeURI(song.previewimage + '?h=120&w=120')" class="w-12 h-12" />
<p class="text-nowrap text-ellipsis overflow-hidden ml-2"> <p class="ml-2 overflow-hidden text-ellipsis text-nowrap">
<span v-html="highlightText(song.name, search)"></span> - <span <span v-html="highlightText(song.name, search)"></span> -
v-html="highlightText(song.artist, props.search)"></span> <span v-html="highlightText(song.artist, props.search)"></span>
</p> </p>
</button> </button>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,97 +1,90 @@
<script setup lang="ts"> <script setup lang="ts">
import SongItem from '../components/SongItem.vue' import SongItem from "../components/SongItem.vue";
import { type Song, mapApiToCollection } from '../script/types' import { type Song, mapApiToCollection } from "../script/types";
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from "vue";
import { useRoute } from 'vue-router' import { useRoute } from "vue-router";
import CollectionListItem from '../components/CollectionListItem.vue' import CollectionListItem from "../components/CollectionListItem.vue";
import { useAudio } from '@/composables/useAudio' import { useAudio } from "@/composables/useAudio";
import { useApi } from '@/composables/useApi' import { useApi } from "@/composables/useApi";
const route = useRoute() const route = useRoute();
const audioStore = useAudio() const audioStore = useAudio();
const { musicApi } = useApi() const { musicApi } = useApi();
const songs = ref<Song[]>([]) const songs = ref<Song[]>([]);
const name = ref('name') const name = ref("name");
const limit = 50 const limit = 50;
let offset = ref(0) let offset = ref(0);
let loading = ref(false) let loading = ref(false);
let hasMore = ref(true) let hasMore = ref(true);
const fetchMoreSongs = async () => { const fetchMoreSongs = async () => {
if (loading.value || !hasMore.value) return if (loading.value || !hasMore.value) return;
loading.value = true loading.value = true;
try { try {
const response = await musicApi().musicBackendCollections( const response = await musicApi.value.musicBackendCollections("", route.params.id, limit, offset.value);
"",
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
limit,
offset.value
)
const col = mapApiToCollection(response.data) const col = mapApiToCollection(response.data);
if (offset.value === 0) { if (offset.value === 0) {
name.value = col.name name.value = col.name;
songs.value = col.songs songs.value = col.songs;
} else { } else {
songs.value.push(...col.songs) songs.value.push(...col.songs);
} }
audioStore.setCollection(songs.value) audioStore.setCollection(songs.value);
if (col.songs.length < limit) { if (col.songs.length < limit) {
hasMore.value = false hasMore.value = false;
} }
offset.value += limit offset.value += limit;
} catch (error) { } catch (error) {
console.error('Error fetching songs:', error) console.error("Error fetching songs:", error);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
const onScroll = (e: Event) => { const onScroll = (e: Event) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
const scrollTop = target.scrollTop const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight const clientHeight = target.clientHeight;
if (scrollTop + clientHeight >= scrollHeight * 0.9) { if (scrollTop + clientHeight >= scrollHeight * 0.9) {
fetchMoreSongs() fetchMoreSongs();
} }
} };
onMounted(() => { onMounted(() => {
fetchMoreSongs() fetchMoreSongs();
const container = document.querySelector('.coll-container') const container = document.querySelector(".coll-container");
container?.addEventListener('scroll', onScroll) container?.addEventListener("scroll", onScroll);
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
const container = document.querySelector('.coll-container') const container = document.querySelector(".coll-container");
container?.removeEventListener('scroll', onScroll) container?.removeEventListener("scroll", onScroll);
}) });
</script> </script>
<template> <template>
<main class="flex-1 text-center flex flex-col h-full overflow-y-hidden"> <main class="flex flex-col flex-1 h-full overflow-y-hidden text-center">
<header v-show="true"> <header v-show="true">
<div class="wrapper"> <div class="wrapper">
<nav class="flex justify-start my-2 mx-1 space-x-1 overflow-y-scroll flex-nowrap text-nowrap"> <nav class="flex flex-nowrap justify-start space-x-1 mx-1 my-2 overflow-y-scroll text-nowrap">
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/menu/collections"><i class="fa-solid fa-arrow-left"></i> <RouterLink class="shadow-xl backdrop--light p-1 rounded-full" to="/menu/collections"
><i class="fa-arrow-left fa-solid"></i>
</RouterLink> </RouterLink>
<p class="p-1 rounded-full backdrop--light shadow-xl">{{ name }}</p> <p class="shadow-xl backdrop--light p-1 rounded-full">{{ name }}</p>
</nav> </nav>
<hr> <hr />
</div> </div>
</header> </header>
<div <div class="flex-col flex-1 justify-start overflow-y-scroll coll-container">
class="flex-1 flex-col overflow-y-scroll coll-container justify-start"
>
<SongItem v-for="(song, index) in songs" :key="index" :song="song" /> <SongItem v-for="(song, index) in songs" :key="index" :song="song" />
</div> </div>
</main> </main>

View File

@@ -1,20 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CollectionPreview } from '@/script/types'; import type { CollectionPreview } from "@/script/types";
const props = defineProps<{ collection: CollectionPreview }>(); const props = defineProps<{ collection: CollectionPreview }>();
</script> </script>
<template> <template>
<RouterLink :to="'/collection/' + props.collection.index">
<RouterLink :to="'/collection/' + props.collection.index +1"> <div class="flex border rounded-lg bordercolor">
<div class=" border bordercolor rounded-lg flex"> <img class="m-2 rounded-lg w-20 h-20" :src="props.collection.previewimage" loading="lazy" />
<img class="h-20 w-20 m-2 rounded-lg" :src="props.collection.previewimage" loading="lazy" />
<div class="flex flex-col"> <div class="flex flex-col">
<h3 class="self-start info">{{ props.collection.name }}</h3> <h3 class="self-start info">{{ props.collection.name }}</h3>
<h5 class="self-start info text-sm">{{ props.collection.length }} Songs </h5> <h5 class="self-start text-sm info">{{ props.collection.length }} Songs</h5>
</div> </div>
</div> </div>
</RouterLink> </RouterLink>
</template> </template>

View File

@@ -1,26 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { useUser } from '@/composables/useUser'; import { useUser } from "@/composables/useUser";
const userStore = useUser(); const userStore = useUser();
</script> </script>
<template> <template>
<hr> <hr />
<nav class="flex justify-around my-2 text-sm md:text-2xl"> <nav class="flex justify-around my-2 text-sm md:text-2xl">
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl hover:text-pink-500" to="/me"> <RouterLink class="shadow-xl backdrop--light p-1 rounded-full hover:text-pink-500" to="/me">
<img :src="userStore.user.value?.avatar_url || 'https://osu.ppy.sh/images/layout/avatar-guest.png'" <img
class="md:h-12 h-6 rounded-full"> :src="userStore.user.value?.avatar_url ?? 'https://osu.ppy.sh/images/layout/avatar-guest.png'"
class="rounded-full h-6 md:h-12"
/>
</RouterLink> </RouterLink>
<RouterLink class="flex flex-col justify-center p-2 rounded-full backdrop--light shadow-xl info" to="/menu"><i <RouterLink class="flex flex-col justify-center shadow-xl backdrop--light p-2 rounded-full info" to="/menu"
class="fa-solid fa-house"></i> ><i class="fa-solid fa-house"></i>
</RouterLink> </RouterLink>
<RouterLink class="flex flex-col justify-center p-2 rounded-full backdrop--light shadow-xl info" to="/search"> <RouterLink class="flex flex-col justify-center shadow-xl backdrop--light p-2 rounded-full info" to="/search">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
</RouterLink> </RouterLink>
</nav> </nav>
</template> </template>

View File

@@ -1,27 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAudio } from '@/composables/useAudio'; import { useAudio } from "@/composables/useAudio";
import { onMounted, computed } from 'vue' import { onMounted, computed } from "vue";
const audioStore = useAudio(); const audioStore = useAudio();
const title = computed(() => audioStore.currentSong.value?.name || 'Unknown Title') const title = computed(() => audioStore.currentSong.value?.name || "Unknown Title");
const artist = computed(() => audioStore.currentSong.value?.artist || 'Unknown Artist') const artist = computed(() => audioStore.currentSong.value?.artist || "Unknown Artist");
const bgimg = computed(() => audioStore.currentSong.value?.previewimage || '/default-bg.jpg') const bgimg = computed(() => audioStore.currentSong.value?.previewimage || "/default-bg.jpg");
onMounted(() => { onMounted(() => {
audioStore.init() audioStore.init();
}) });
</script> </script>
<template> <template>
<div> <div>
<hr> <hr />
<div class="relative wrapper p-1 action"> <div class="relative wrapper p-1 action">
<img :src="encodeURI(bgimg + '?h=150&w=400')" class="w-full absolute top-0 left-0 right-0 h-full" <img
:style="{ 'filter': 'blur(2px)', 'opacity': '0.5' }" alt="Background Image" /> :src="encodeURI(bgimg + '?h=150&w=400')"
class="w-full absolute top-0 left-0 right-0 h-full"
:style="{ filter: 'blur(2px)', opacity: '0.5' }"
alt="Background Image"
/>
<nav class="relative flex-col z-10"> <nav class="relative flex-col z-10">
<div class="flex justify-between"> <div class="flex justify-between">
<RouterLink to="/nowplaying" class="grow overflow-hidden"> <RouterLink to="/nowplaying" class="grow overflow-hidden">
<p class="relative text-sm text-left font-bold info overflow-hidden text-ellipsis text-nowrap"> <p class="relative text-sm text-left font-bold info overflow-hidden text-ellipsis text-nowrap">
@@ -33,17 +36,19 @@ onMounted(() => {
</p> </p>
</RouterLink> </RouterLink>
<div class="flex flex-col text-center justify-center px-2" @click="audioStore.togglePlay"> <div class="flex flex-col text-center justify-center px-2" @click="audioStore.togglePlay">
<i :class="[audioStore.isPlaying.value ? ' fa-circle-pause' : 'fa-circle-play']" class="text-3xl fa-regular"></i> <i
:class="[audioStore.isPlaying.value ? ' fa-circle-pause' : 'fa-circle-play']"
class="text-3xl fa-regular"
></i>
</div> </div>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-0.5 dark:bg-gray-700"> <div class="w-full bg-gray-200 rounded-full h-0.5 dark:bg-gray-700">
<div class="bg-blue-600 h-0.5 rounded-full dark:bg-yellow-500" <div
:style="{ 'width': audioStore.percentDone.value + '%' }"> class="bg-blue-600 h-0.5 rounded-full dark:bg-yellow-500"
:style="{ width: audioStore.percentDone.value + '%' }"
></div>
</div> </div>
</div>
</nav> </nav>
</div> </div>
</div> </div>

View File

@@ -1,43 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAudio } from '@/composables/useAudio'; import { useAudio } from "@/composables/useAudio";
import type { Song } from '@/script/types'; import type { Song } from "@/script/types";
const props = defineProps<{ const props = defineProps<{
song: Song, song: Song;
action?: string, action?: string;
info?: string, info?: string;
border?: string, border?: string;
}>(); }>();
const audioStore = useAudio(); const audioStore = useAudio();
function updateSong() { function updateSong() {
let updated = props.song; let updated = props.song;
audioStore.setSong(updated) audioStore.setSong(updated);
} }
</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="m-1 md:text-xl border bordercolor rounded-lg flex"> <img
<img class="h-14 w-14 md:w-24 md:h-24 m-1 rounded-lg" class="m-1 rounded-lg w-14 md:w-24 h-14 md:h-24"
:src="encodeURI(props.song?.previewimage ? props.song?.previewimage + '?h=56&w=56' : '/default-bg.png')" :src="encodeURI(props.song?.previewimage ? props.song?.previewimage + '?h=56&w=56' : '/default-bg.png')"
loading="lazy" /> loading="lazy"
/>
<div class="flex flex-col overflow-hidden text-left"> <div class="flex flex-col overflow-hidden text-left">
<p :style="{ color: info }" class="text-nowrap text-ellipsis overflow-hidden text-base 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 ? props.song?.name : "Unknown Title" }}</slot>
</p> </p>
<h5 :style="{ color: action }" class="action text-sm text-nowrap text-ellipsis overflow-hidden text-base"> <h5 :style="{ color: action }" class="overflow-hidden text-sm text-base text-ellipsis text-nowrap action">
<slot name="artist">{{ props.song?.artist ? props.song.artist : 'Unknown Artist' }}</slot> <slot name="artist">{{ props.song?.artist ? props.song.artist : "Unknown Artist" }}</slot>
</h5> </h5>
<h5 :style="{ color: action }" class="action text-sm"> <h5 :style="{ color: action }" class="text-sm action">
<slot name="length">{{ Math.floor(props.song?.length / 60000 ?? 0) }}:{{ Math.floor((props.song?.length ?? 0 / <slot name="length"
1000) >{{ Math.floor(props.song.length / 60000 || 0) }}:{{
% 60).toString().padStart(2, '0') }}</slot> Math.floor((props.song.length ?? 0 / 1000) % 60)
.toString()
.padStart(2, "0")
}}</slot
>
</h5> </h5>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,17 +1,19 @@
import { MusicBackendApi, Configuration, } from '@/generated'; import { MusicBackendApi, Configuration } from "@/generated";
import type { ConfigurationParameters } from '@/generated'; import type { ConfigurationParameters } from "@/generated";
import { ref } from 'vue'; import { useUser } from "./useUser";
import { computed } from "vue";
export function useApi() { export function useApi() {
const basePath = ref(import.meta.env.BACKEND_URL || 'http://localhost:8080'); const userStore = useUser();
const musicApi = (): MusicBackendApi => {
const musicApi = computed(() => {
const configParams: ConfigurationParameters = { const configParams: ConfigurationParameters = {
basePath: basePath.value, basePath: userStore.cloudflareUrl.value,
}; };
const configuration = new Configuration(configParams); const configuration = new Configuration(configParams);
return new MusicBackendApi(configuration); return new MusicBackendApi(configuration);
}; });
return { return {
musicApi, musicApi,

View File

@@ -1,160 +1,155 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from "vue";
import { mapApiToSongs, type Song } from '@/script/types' import { mapApiToSongs, type Song } from "@/script/types";
import { useApi } from './useApi' import { useApi } from "./useApi";
let audioInstance: ReturnType<typeof createAudio> | null = null let audioInstance: ReturnType<typeof createAudio> | null = null;
function createAudio() { function createAudio() {
const { musicApi } = useApi() const { musicApi } = useApi();
const audioElement = document.createElement('audio') const audioElement = document.createElement("audio");
audioElement.setAttribute('id', 'global-audio') audioElement.setAttribute("id", "global-audio");
audioElement.setAttribute('controls', '') audioElement.setAttribute("controls", "");
audioElement.classList.add('hidden') audioElement.classList.add("hidden");
document.body.appendChild(audioElement) document.body.appendChild(audioElement);
const state = { const state = {
isPlaying: ref(false), isPlaying: ref(false),
duration: ref('0:00'), duration: ref("0:00"),
currentTime: ref('0:00'), currentTime: ref("0:00"),
percentDone: ref(0), percentDone: ref(0),
shuffle: ref(false), shuffle: ref(false),
repeat: ref(false), repeat: ref(false),
activeSongs: ref<Song[] | null>([]), activeSongs: ref<Song[] | null>([]),
currentSong: ref<Song | null>(null), currentSong: ref<Song | null>(null),
recentlyPlayed: ref(new Map<string, Song>()), recentlyPlayed: ref(new Map<string, Song>()),
} };
function saveToLocalStorage(key: string, data: any) { function saveToLocalStorage(key: string, data: any) {
localStorage.setItem(key, JSON.stringify(data)) localStorage.setItem(key, JSON.stringify(data));
} }
function loadFromLocalStorage<T>(key: string): T | null { function loadFromLocalStorage<T>(key: string): T | null {
const item = localStorage.getItem(key) const item = localStorage.getItem(key);
return item ? JSON.parse(item) as T : null return item ? (JSON.parse(item) as T) : null;
} }
function setSong(song: Song | null) { function setSong(song: Song | null) {
if (!song) return if (!song) return;
state.currentSong.value = song state.currentSong.value = song;
const map = state.recentlyPlayed.value; const map = state.recentlyPlayed.value;
saveToLocalStorage("lastPlayedSong", song);
saveToLocalStorage('lastPlayedSong', song)
if (map.has(song.hash)) { if (map.has(song.hash)) {
map.delete(song.hash); map.delete(song.hash);
} }
map.set(song.hash, song); map.set(song.hash, song);
audioElement.pause() audioElement.pause();
audioElement.src = song.url audioElement.src = song.url;
audioElement.addEventListener( audioElement.addEventListener("canplaythrough", () => audioElement.play().catch(console.error), { once: true });
'canplaythrough',
() => audioElement.play().catch(console.error),
{ once: true }
)
} }
function setCollection(song: Song[] | null) { function setCollection(song: Song[] | null) {
if (!song) return if (!song) return;
state.activeSongs.value = song state.activeSongs.value = song;
saveToLocalStorage('activeCollection', song) saveToLocalStorage("activeCollection", song);
} }
function togglePlay() { function togglePlay() {
if (audioElement.paused) { if (audioElement.paused) {
audioElement.play().catch(console.error) audioElement.play().catch(console.error);
} else { } else {
audioElement.pause() audioElement.pause();
} }
} }
function toggleNext() { function toggleNext() {
if (!state.activeSongs.value || !state.currentSong.value) return; if (!state.activeSongs.value || !state.currentSong.value) return;
const songs = state.activeSongs.value; const songs = state.activeSongs.value;
if(state.shuffle.value){ if (state.shuffle.value) {
setSong(songs[Math.floor(Math.random() * songs.length)]); setSong(songs[Math.floor(Math.random() * songs.length)]);
} }
const currentHash = state.currentSong.value.hash; const currentHash = state.currentSong.value.hash;
const currentIndex = songs.findIndex(song => song.hash === currentHash); const currentIndex = songs.findIndex((song) => song.hash === currentHash);
if (currentIndex === -1) return; if (currentIndex === -1) return;
const nextIndex = (currentIndex + 1) % songs.length; const nextIndex = (currentIndex + 1) % songs.length;
setSong(songs[nextIndex]); setSong(songs[nextIndex]);
} }
function togglePrevious() { function togglePrevious() {
if (!state.activeSongs.value || !state.currentSong.value) return; if (!state.activeSongs.value || !state.currentSong.value) return;
const songs = state.activeSongs.value; const songs = state.activeSongs.value;
const currentHash = state.currentSong.value.hash; const currentHash = state.currentSong.value.hash;
const currentIndex = songs.findIndex(song => song.hash === currentHash); const currentIndex = songs.findIndex((song) => song.hash === currentHash);
if (currentIndex === -1) return; if (currentIndex === -1) return;
const prevIndex = (currentIndex - 1 + songs.length) % songs.length; const prevIndex = (currentIndex - 1 + songs.length) % songs.length;
setSong(songs[prevIndex]); setSong(songs[prevIndex]);
} }
function update() { function update() {
const { currentTime: ct, duration: dur } = audioElement const { currentTime: ct, duration: dur } = audioElement;
state.isPlaying.value = !audioElement.paused state.isPlaying.value = !audioElement.paused;
state.currentTime.value = formatTime(ct) state.currentTime.value = formatTime(ct);
state.duration.value = formatTime(dur) state.duration.value = formatTime(dur);
state.percentDone.value = isNaN(dur) ? 0 : (ct / dur) * 100 state.percentDone.value = isNaN(dur) ? 0 : (ct / dur) * 100;
if (audioElement.ended) { if (audioElement.ended) {
if (state.repeat.value) { if (state.repeat.value) {
audioElement.currentTime = 0 audioElement.currentTime = 0;
audioElement.play() audioElement.play();
return return;
} }
toggleNext(); toggleNext();
} }
} }
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
const min = Math.floor(seconds / 60) const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60).toString().padStart(2, '0') const sec = Math.floor(seconds % 60)
return `${min}:${sec}` .toString()
.padStart(2, "0");
return `${min}:${sec}`;
} }
function updateTime(value: number) { function updateTime(value: number) {
if (value) audioElement.currentTime = (Number(value) / 100) * audioElement.duration if (value) audioElement.currentTime = (Number(value) / 100) * audioElement.duration;
} }
async function loadInitialSong() { async function loadInitialSong() {
try { try {
const api = musicApi() const api = musicApi();
const res = await api.musicBackendRecent() const res = await api.musicBackendRecent();
let songs = mapApiToSongs(res.data.songs); let songs = mapApiToSongs(res.data.songs);
if (res.data?.songs?.length) { if (res.data?.songs?.length) {
setSong(songs[0]) setSong(songs[0]);
} }
} catch (err) { } catch (err) {
console.error('Failed to load song:', err) console.error("Failed to load song:", err);
} }
} }
function init() { function init() {
setSong(loadFromLocalStorage<Song>('lastPlayedSong')) setSong(loadFromLocalStorage<Song>("lastPlayedSong"));
setCollection(loadFromLocalStorage<Song[]>('activeCollection')) setCollection(loadFromLocalStorage<Song[]>("activeCollection"));
if (!state.currentSong.value) { if (!state.currentSong.value) {
loadInitialSong() loadInitialSong();
} }
audioElement.addEventListener('timeupdate', update) audioElement.addEventListener("timeupdate", update);
} }
onMounted(init) onMounted(init);
return { return {
...state, ...state,
@@ -167,12 +162,12 @@ function togglePrevious() {
setSong, setSong,
setCollection, setCollection,
init, init,
} };
} }
export function useAudio() { export function useAudio() {
if (!audioInstance) { if (!audioInstance) {
audioInstance = createAudio() audioInstance = createAudio();
} }
return audioInstance return audioInstance;
} }

View File

@@ -1,23 +1,23 @@
import { ref } from 'vue'; import { ref } from "vue";
export function useThemeColors() { export function useThemeColors() {
const bgColor = ref(localStorage.getItem('bgColor') || '#1c1719'); const bgColor = ref(localStorage.getItem("bgColor") || "#1c1719");
const actionColor = ref(localStorage.getItem('actionColor') || '#eab308'); const actionColor = ref(localStorage.getItem("actionColor") || "#eab308");
const infoColor = ref(localStorage.getItem('infoColor') || '#ec4899'); const infoColor = ref(localStorage.getItem("infoColor") || "#ec4899");
const borderColor = ref(localStorage.getItem('borderColor') || '#ec4899'); const borderColor = ref(localStorage.getItem("borderColor") || "#ec4899");
function applyColors(bg: string, main: string, info: string, border: string) { function applyColors(bg: string, main: string, info: string, border: string) {
document.documentElement.style.setProperty('--background-color', bg); document.documentElement.style.setProperty("--background-color", bg);
document.documentElement.style.setProperty('--action-color', main); document.documentElement.style.setProperty("--action-color", main);
document.documentElement.style.setProperty('--information-color', info); document.documentElement.style.setProperty("--information-color", info);
document.documentElement.style.setProperty('--border-color', border); document.documentElement.style.setProperty("--border-color", border);
} }
function save( function save(
bg: string | null = null, bg: string | null = null,
main: string | null = null, main: string | null = null,
info: string | null = null, info: string | null = null,
border: string | null = null border: string | null = null,
) { ) {
bgColor.value = bg ?? bgColor.value; bgColor.value = bg ?? bgColor.value;
actionColor.value = main ?? actionColor.value; actionColor.value = main ?? actionColor.value;
@@ -26,13 +26,12 @@ export function useThemeColors() {
applyColors(bgColor.value, actionColor.value, infoColor.value, borderColor.value); applyColors(bgColor.value, actionColor.value, infoColor.value, borderColor.value);
localStorage.setItem('bgColor', bgColor.value); localStorage.setItem("bgColor", bgColor.value);
localStorage.setItem('actionColor', actionColor.value); localStorage.setItem("actionColor", actionColor.value);
localStorage.setItem('infoColor', infoColor.value); localStorage.setItem("infoColor", infoColor.value);
localStorage.setItem('borderColor', borderColor.value); localStorage.setItem("borderColor", borderColor.value);
} }
// Initialize colors on composable use
applyColors(bgColor.value, actionColor.value, infoColor.value, borderColor.value); applyColors(bgColor.value, actionColor.value, infoColor.value, borderColor.value);
return { return {

View File

@@ -1,19 +1,19 @@
import { ref } from 'vue'; import { ref } from "vue";
import type { Me } from '@/script/types'; import type { Me } from "@/script/types";
let userInstance: ReturnType<typeof createUser> | null = null; let userInstance: ReturnType<typeof createUser> | null = null;
function createUser() { function createUser() {
const user = ref<Me | null>(null); const user = ref<Me | null>(null);
const baseUrl = ref(import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080'); const cloudflareUrl = ref(import.meta.env.VITE_BACKEND_URL || "http://localhost:8080");
const proxyUrl = ref(import.meta.env.VITE_PROXY_URL || 'https://proxy.illegalesachen.download'); const proxyUrl = ref(import.meta.env.VITE_PROXY_URL || "https://proxy.illegalesachen.download");
function saveUser(u: Me | null) { function saveUser(u: Me | null) {
localStorage.setItem('activeUser', JSON.stringify(u)); localStorage.setItem("activeUser", JSON.stringify(u));
} }
function loadUser(): Me | null { function loadUser(): Me | null {
const u = localStorage.getItem('activeUser'); const u = localStorage.getItem("activeUser");
return u ? JSON.parse(u) : null; return u ? JSON.parse(u) : null;
} }
@@ -25,12 +25,12 @@ function createUser() {
async function fetchMe(): Promise<Me | {}> { async function fetchMe(): Promise<Me | {}> {
try { try {
const response = await fetch(`${proxyUrl.value}/me`, { const response = await fetch(`${proxyUrl.value}/me`, {
method: 'GET', method: "GET",
credentials: 'include', credentials: "include",
}); });
if (response.redirected) { if (response.redirected) {
window.open(response.url, '_blank'); window.open(response.url, "_blank");
return { redirected: true }; return { redirected: true };
} }
@@ -42,7 +42,7 @@ function createUser() {
const data = await response.json(); const data = await response.json();
return data; return data;
} catch (error) { } catch (error) {
console.error('Fetch error:', error); console.error("Fetch error:", error);
return {}; return {};
} }
} }
@@ -51,7 +51,7 @@ function createUser() {
return { return {
user, user,
baseUrl, cloudflareUrl,
proxyUrl, proxyUrl,
setUser, setUser,
fetchMe, fetchMe,

View File

@@ -1,12 +1,12 @@
import './assets/main.css' import "./assets/main.css";
import { createApp } from 'vue' import { createApp } from "vue";
import App from './App.vue' import App from "./App.vue";
import router from './router' import router from "./router";
const app = createApp(App) const app = createApp(App);
app.use(router) app.use(router);
app.mount('#app') app.mount("#app");

View File

@@ -1,32 +1,31 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { register } from 'register-service-worker' import { register } from "register-service-worker";
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}service-worker.js`, { register(`${process.env.BASE_URL}service-worker.js`, {
ready () { ready() {
console.log( console.log(
'App is being served from cache by a service worker.\n' + "App is being served from cache by a service worker.\n" + "For more details, visit https://goo.gl/AFskqB",
'For more details, visit https://goo.gl/AFskqB' );
)
}, },
registered () { registered() {
console.log('Service worker has been registered.') console.log("Service worker has been registered.");
}, },
cached () { cached() {
console.log('Content has been cached for offline use.') console.log("Content has been cached for offline use.");
}, },
updatefound () { updatefound() {
console.log('New content is downloading.') console.log("New content is downloading.");
}, },
updated () { updated() {
console.log('New content is available; please refresh.') console.log("New content is available; please refresh.");
}, },
offline () { offline() {
console.log('No internet connection found. App is running in offline mode.') console.log("No internet connection found. App is running in offline mode.");
}, },
error (error) { error(error) {
console.error('Error during service worker registration:', error) console.error("Error during service worker registration:", error);
} },
}) });
} }

View File

@@ -1,69 +1,69 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from "vue-router";
import MenuView from '../views/MenuView.vue' import MenuView from "../views/MenuView.vue";
import RecentView from '../views/RecentView.vue' import RecentView from "../views/RecentView.vue";
import FavouritView from '../views/FavouritView.vue' import FavouritView from "../views/FavouritView.vue";
import CollectionView from '../views/CollectionView.vue' import CollectionView from "../views/CollectionView.vue";
import NowPlayingView from '../views/NowPlayingView.vue' import NowPlayingView from "../views/NowPlayingView.vue";
import MeView from '../views/MeView.vue' import MeView from "../views/MeView.vue";
import SearchView from '../views/SearchView.vue' import SearchView from "../views/SearchView.vue";
import CollectionItem from '../components/CollectionItem.vue' import CollectionItem from "../components/CollectionItem.vue";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: "/",
name: '', name: "",
component: MeView, component: MeView,
}, },
{ {
path: '/menu', path: "/menu",
name: 'menu', name: "menu",
component: MenuView, component: MenuView,
children: [ children: [
{ {
path: '', path: "",
name: 'default', name: "default",
component: RecentView component: RecentView,
}, },
{ {
path: 'recent', path: "recent",
name: 'recent', name: "recent",
component: RecentView component: RecentView,
}, },
{ {
path: 'favourites', path: "favourites",
name: 'favourites', name: "favourites",
component: FavouritView component: FavouritView,
}, },
{ {
path: 'collections', path: "collections",
name: 'collections', name: "collections",
component: CollectionView component: CollectionView,
} },
] ],
}, },
{ {
path: '/nowplaying', path: "/nowplaying",
name: 'nowplaying', name: "nowplaying",
component: NowPlayingView component: NowPlayingView,
}, },
{ {
path: '/me', path: "/me",
name: 'me', name: "me",
component: MeView component: MeView,
}, },
{ {
path: '/search', path: "/search",
name: 'search', name: "search",
component: SearchView component: SearchView,
}, },
{ {
path: '/collection/:id', path: "/collection/:id",
name: 'collection', name: "collection",
component: CollectionItem component: CollectionItem,
} },
] ],
}) });
export default router export default router;

View File

@@ -1,4 +1,4 @@
import type { Apiv1Song, v1CollectionPreview, v1Collection } from '@/generated'; import type { Apiv1Song, v1CollectionPreview, v1Collection } from "@/generated";
export type Song = { export type Song = {
hash: string; hash: string;
@@ -10,7 +10,7 @@ export type Song = {
mapper: string; mapper: string;
}; };
const basePath = import.meta.env.BACKEND_URL || 'http://localhost:8080'; const basePath = import.meta.env.BACKEND_URL || "http://localhost:8080";
export function mapToSong(apiSong: Apiv1Song): Song { export function mapToSong(apiSong: Apiv1Song): Song {
const image = apiSong.image; const image = apiSong.image;
@@ -21,10 +21,8 @@ export function mapToSong(apiSong: Apiv1Song): Song {
name: apiSong.title, name: apiSong.title,
artist: apiSong.artist, artist: apiSong.artist,
length: Number(apiSong.totalTime), length: Number(apiSong.totalTime),
url: `${basePath}/api/v1/audio/${btoa(apiSong.folder + "/" + apiSong.audio).replace(/=+$/, '')}`, url: `${basePath}/api/v1/audio/${btoa(apiSong.folder + "/" + apiSong.audio).replace(/=+$/, "")}`,
previewimage: imageIsMissing previewimage: imageIsMissing ? "/404.gif" : `${basePath}/api/v1/image/${btoa(image).replace(/=+$/, "")}`,
? "/404.gif"
: `${basePath}/api/v1/image/${btoa(image).replace(/=+$/, '')}`,
mapper: apiSong.creator, mapper: apiSong.creator,
}; };
} }
@@ -40,32 +38,25 @@ export type Collection = {
name: string; name: string;
items: number; items: number;
songs: Song[]; songs: Song[];
} };
export function mapApiToCollection(coll : v1Collection): Collection {
export function mapApiToCollection(coll: v1Collection): Collection {
return { return {
name: coll.name, name: coll.name,
items: coll.items, items: coll.items,
songs: mapApiToSongs(coll.songs) songs: mapApiToSongs(coll.songs),
} };
} }
export function mapToCollectionPreview(apiCollection: v1CollectionPreview, index: number): CollectionPreview {
export function mapToCollectionPreview(
apiCollection: v1CollectionPreview,
index: number
): CollectionPreview {
const image = apiCollection.image; const image = apiCollection.image;
const imageIsMissing = !image || image === "404.png"; const imageIsMissing = !image || image === "404.png";
return { return {
index, index: index,
name: apiCollection.name, name: apiCollection.name,
length: apiCollection.items, length: apiCollection.items,
previewimage: imageIsMissing previewimage: imageIsMissing ? "/404.gif" : `${basePath}/api/v1/image/${btoa(image).replace(/=+$/, "")}`,
? "/404.gif"
: `${basePath}/api/v1/image/${btoa(image).replace(/=+$/, '')}`,
}; };
} }
@@ -81,8 +72,6 @@ export function mapApiToSongs(apiSongs: Apiv1Song[]): Song[] {
return apiSongs.map(mapToSong); return apiSongs.map(mapToSong);
} }
export function mapApiToCollectionPreview( export function mapApiToCollectionPreview(apiCollections: v1CollectionPreview[], offset: number): CollectionPreview[] {
apiCollections: v1CollectionPreview[] return apiCollections.map((c, i) => mapToCollectionPreview(c, i + offset));
): CollectionPreview[] {
return apiCollections.map((c, i) => mapToCollectionPreview(c, i));
} }

View File

@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Song, type CollectionPreview, mapApiToCollectionPreview } from '../script/types' import { type Song, type CollectionPreview, mapApiToCollectionPreview } from "../script/types";
import { ref, onMounted } from 'vue' import { ref, onMounted } from "vue";
import CollectionListItem from '../components/CollectionListItem.vue' import CollectionListItem from "../components/CollectionListItem.vue";
import { useUser } from '@/composables/useUser'; import { useApi } from "@/composables/useApi";
import { useApi } from '@/composables/useApi';
const userStore = useUser(); const { musicApi } = useApi();
const { musicApi } = useApi() const api = musicApi.value;
const api = musicApi()
const collections = ref<CollectionPreview[]>([]); const collections = ref<CollectionPreview[]>([]);
const limit = ref(10); const limit = ref(10);
@@ -19,7 +17,7 @@ const fetchCollections = async () => {
isLoading.value = true; isLoading.value = true;
const response = await api.musicBackendSearchCollections("", limit.value, offset.value); const response = await api.musicBackendSearchCollections("", limit.value, offset.value);
let songs = mapApiToCollectionPreview(response.data.collections) let songs = mapApiToCollectionPreview(response.data.collections || [], offset.value);
collections.value = [...collections.value, ...songs]; collections.value = [...collections.value, ...songs];
offset.value += limit.value; offset.value += limit.value;
@@ -29,9 +27,9 @@ const fetchCollections = async () => {
onMounted(async () => { onMounted(async () => {
await fetchCollections(); await fetchCollections();
const container = document.querySelector('.collection-container'); const container = document.querySelector(".collection-container");
if (container) { if (container) {
container.addEventListener('scroll', async () => { container.addEventListener("scroll", async () => {
const scrollTop = container.scrollTop; const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight; const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight; const clientHeight = container.clientHeight;
@@ -42,11 +40,10 @@ onMounted(async () => {
}); });
} }
}); });
</script> </script>
<template> <template>
<main class="flex-1 text-center flex flex-col h-full overflow-y-hidden"> <main class="flex flex-col flex-1 h-full overflow-y-hidden text-center">
<div class="flex flex-col overflow-y-scroll collection-container"> <div class="flex flex-col overflow-y-scroll collection-container">
<CollectionListItem v-for="(collection, index) in collections" :key="index" :collection="collection" /> <CollectionListItem v-for="(collection, index) in collections" :key="index" :collection="collection" />
</div> </div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SongItem from '../components/SongItem.vue' import SongItem from "../components/SongItem.vue";
</script> </script>
<template> <template>

View File

@@ -1,5 +1,4 @@
<template> <template>
<main class="flex-1 flex-col"> <main class="flex-1 flex-col">
<div class="flex-1 flex-col h-full overflow-y-hidden song-container"> <div class="flex-1 flex-col h-full overflow-y-hidden song-container">
<p class="p-1 rounded-full backdrop--light shadow-xl text-center">History</p> <p class="p-1 rounded-full backdrop--light shadow-xl text-center">History</p>
@@ -10,12 +9,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue";
import SongItem from '../components/SongItem.vue' import SongItem from "../components/SongItem.vue";
import { useAudio } from '@/composables/useAudio'; import { useAudio } from "@/composables/useAudio";
const audioStore = useAudio(); const audioStore = useAudio();
const songs = computed(() => Array.from(audioStore.recentlyPlayed.value?.values()).reverse() || []) const songs = computed(() => Array.from(audioStore.recentlyPlayed.value?.values()).reverse() || []);
</script> </script>

View File

@@ -1,185 +1,233 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from "vue";
import SongItem from '../components/SongItem.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 type { Me } from '@/script/types'; import type { Me } from "@/script/types";
const audioStore = useAudio(); const audioStore = useAudio();
const userStore = useUser(); const userStore = useUser();
const bgColor = ref(''); const bgColor = ref("");
const actionColor = ref(''); const actionColor = ref("");
const infoColor = ref(''); const infoColor = ref("");
const borderColor = ref(''); const borderColor = ref("");
const loginStatus = ref('Login'); const loginStatus = ref("Login");
function update() { function update() {
var input = document.getElementById("url-input") as HTMLInputElement; var input = document.getElementById("url-input") as HTMLInputElement;
userStore.baseUrl.value = input.value; 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("--action-color", main ?? actionColor.value);
document.documentElement.style.setProperty("--information-color", info ?? infoColor.value);
document.documentElement.style.setProperty("--border-color", border ?? borderColor.value);
document.documentElement.style.setProperty('--background-color', bg ?? bgColor.value); localStorage.setItem("bgColor", bg ?? bgColor.value);
document.documentElement.style.setProperty('--action-color', main ?? actionColor.value); localStorage.setItem("actionColor", main ?? actionColor.value);
document.documentElement.style.setProperty('--information-color', info ?? infoColor.value); localStorage.setItem("infoColor", info ?? infoColor.value);
document.documentElement.style.setProperty('--border-color', border ?? borderColor.value); localStorage.setItem("borderColor", border ?? borderColor.value);
localStorage.setItem('bgColor', bg ?? bgColor.value);
localStorage.setItem('actionColor', main ?? actionColor.value);
localStorage.setItem('infoColor', info ?? infoColor.value);
localStorage.setItem('borderColor', border ?? borderColor.value);
} }
async function getMe() { async function getMe() {
const data = (await userStore.fetchMe()) as Me;
const data = await userStore.fetchMe() as Me;
if (data.redirected == true) { if (data.redirected == true) {
loginStatus.value = "waiting for login, click to refresh!" loginStatus.value = "waiting for login, click to refresh!";
console.log("redirect detected"); console.log("redirect detected");
} }
if (data.id === null || data.id === undefined || Object.keys(data).length === 0) { if (data.id === null || data.id === undefined || Object.keys(data).length === 0) {
return return;
} }
userStore.setUser(data); userStore.setUser(data);
userStore.baseUrl.value(data.endpoint); userStore.cloudflareUrl.value(data.endpoint);
} }
onMounted(() => { onMounted(() => {
reset(); reset();
}) });
function reset() { function reset() {
bgColor.value = localStorage.getItem("bgColor") || "#1c1719";
actionColor.value = localStorage.getItem("actionColor") || "#eab308";
infoColor.value = localStorage.getItem("infoColor") || "#ec4899";
borderColor.value = localStorage.getItem("borderColor") || "#ec4899";
bgColor.value = localStorage.getItem('bgColor') || '#1c1719'; document.documentElement.style.setProperty("--background-color", bgColor.value);
actionColor.value = localStorage.getItem('actionColor') || '#eab308'; document.documentElement.style.setProperty("--action-color", actionColor.value);
infoColor.value = localStorage.getItem('infoColor') || '#ec4899'; document.documentElement.style.setProperty("--information-color", infoColor.value);
borderColor.value = localStorage.getItem('borderColor') || '#ec4899'; document.documentElement.style.setProperty("--border-color", borderColor.value);
document.documentElement.style.setProperty('--background-color', bgColor.value);
document.documentElement.style.setProperty('--action-color', actionColor.value);
document.documentElement.style.setProperty('--information-color', infoColor.value);
document.documentElement.style.setProperty('--border-color', borderColor.value);
} }
</script> </script>
<template> <template>
<header> <header>
<div class="wrapper"> <div class="wrapper">
<nav class="flex justify-start my-2 mx-1 space-x-1"> <nav class="flex justify-start space-x-1 mx-1 my-2">
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i> <RouterLink class="shadow-xl backdrop--light p-1 rounded-full" to="/"
><i class="fa-arrow-left fa-solid"></i>
</RouterLink> </RouterLink>
</nav> </nav>
<hr> <hr />
</div> </div>
</header> </header>
<main class="flex-1 flex flex-col 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 /> <input @change="update" type="text" id="url-input" :value="userStore.user.value?.endpoint" disabled />
<br> <br />
<button v-if="!userStore.user.value" @click="getMe" class="border bordercolor rounded-lg p-0.5">{{ loginStatus }}</button> <button v-if="!userStore.user.value" @click="getMe" class="p-0.5 border rounded-lg bordercolor">
<div v-if="userStore.user.value" class="flex p-5 justify-between"> {{ loginStatus }}
<img :src="userStore.user.value.avatar_url" class="w-1/3"> </button>
<div v-if="userStore.user.value" class="flex justify-between p-5">
<img :src="userStore.user.value.avatar_url" class="w-1/3" />
<div> <div>
<p>{{ userStore.user.value.name }}</p> <p>{{ userStore.user.value.name }}</p>
<p>{{ userStore.user.value.endpoint == "" ? 'Not Connected' : 'Connected' }}</p> <p>
<p>Sharing: <button @click="userStore.user.value.share" class="border bordercolor rounded-lg p-0.5">{{ userStore.user.value.share {{ userStore.user.value.endpoint == "" ? "Not Connected" : "Connected" }}
}}</button></p> </p>
<button @click="getMe" class="border bordercolor rounded-lg p-0.5"> Refresh <p>
Sharing:
<button
@click="userStore.user.value.share = !userStore.user.value.share"
class="p-0.5 border rounded-lg bordercolor"
>
{{ userStore.user.value.share }}
</button> </button>
</p>
<button @click="getMe" class="p-0.5 border rounded-lg bordercolor">Refresh</button>
</div> </div>
</div> </div>
<div class="flex flex-col w-full justify-around p-10"> <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>
<input type="color" id="bgPicker" v-model="bgColor" @input="save()" <input
class="appearance-none w-8 h-8 border-2 p-0 overflow-hidden cursor-pointer"> type="color"
id="bgPicker"
v-model="bgColor"
@input="save(null, null, null, null)"
class="p-0 border-2 w-8 h-8 overflow-hidden appearance-none cursor-pointer"
/>
</div> </div>
<div class="flex flex-1 justify-between"> <div class="flex flex-1 justify-between">
<p>Main:</p> <p>Main:</p>
<input type="color" id="actionPicker" v-model="actionColor" @input="save()" <input
class="appearance-none w-8 h-8 border-2 p-0 overflow-hidden cursor-pointer"> type="color"
id="actionPicker"
v-model="actionColor"
@input="save(null, null, null, null)"
class="p-0 border-2 w-8 h-8 overflow-hidden appearance-none cursor-pointer"
/>
</div> </div>
<div class="flex flex-1 justify-between"> <div class="flex flex-1 justify-between">
<p>Secondary:</p> <p>Secondary:</p>
<input type="color" id="infoPicker" v-model="infoColor" @input="save()" <input
class="appearance-none w-8 h-8 border-2 p-0 overflow-hidden cursor-pointer"> type="color"
id="infoPicker"
v-model="infoColor"
@input="save(null, null, null, null)"
class="p-0 border-2 w-8 h-8 overflow-hidden appearance-none cursor-pointer"
/>
</div> </div>
<div class="flex flex-1 justify-between"> <div class="flex flex-1 justify-between">
<p>Border:</p> <p>Border:</p>
<input type="color" id="borderPicker" v-model="borderColor" @input="save()" <input
class="appearance-none w-8 h-8 border-2 p-0 overflow-hidden cursor-pointer"> type="color"
id="borderPicker"
v-model="borderColor"
@input="save(null, null, null, null)"
class="p-0 border-2 w-8 h-8 overflow-hidden appearance-none cursor-pointer"
/>
</div> </div>
</div> </div>
<div class="w-full p-2"> <div class="p-2 w-full">
<p>Current</p> <p>Current</p>
<SongItem :song="audioStore.currentSong.value" /> <SongItem :song="audioStore.currentSong.value" />
</div> </div>
<div class="w-full p-2 bg-black"> <div class="bg-black p-2 w-full">
<p class="flex-1 flex justify-between" style=" color : #57db5d">StaryNight <button style="border-color : #b3002d" <p class="flex flex-1 justify-between" style="color: #57db5d">
class="border rounded-lg p-0.5" @click="save('#000000', '#5e2d8f', '#57db5d', '#b3002d')">Choose StaryNight
<button
style="border-color: #b3002d"
class="p-0.5 border rounded-lg"
@click="save('#000000', '#5e2d8f', '#57db5d', '#b3002d')"
>
Choose
</button> </button>
</p> </p>
<SongItem :song="audioStore.currentSong.value" :border="'#b3002d'" :action="'#5e2d8f'" :info="'#57db5d'" /> <SongItem :song="audioStore.currentSong.value" :border="'#b3002d'" :action="'#5e2d8f'" :info="'#57db5d'" />
</div> </div>
<div class="w-full p-2" style="background-color: #1c1719"> <div class="p-2 w-full" style="background-color: #1c1719">
<p class="flex-1 flex justify-between" style=" color : #ec4889">Default<button style="border-color : #ec4889" <p class="flex flex-1 justify-between" style="color: #ec4889">
class="border rounded-lg p-0.5" @click="save('#1c1719', '#eab308', '#ec4889', '#ec4889')">Choose Default<button
style="border-color: #ec4889"
class="p-0.5 border rounded-lg"
@click="save('#1c1719', '#eab308', '#ec4889', '#ec4889')"
>
Choose
</button> </button>
</p> </p>
<SongItem :song="audioStore.currentSong.value" :border="'#ec4889'" :info="'#ec4889'" :action="'#eab308'" /> <SongItem :song="audioStore.currentSong.value" :border="'#ec4889'" :info="'#ec4889'" :action="'#eab308'" />
</div> </div>
<div class="w-full p-2" style="background-color: #ff4c4c"> <div class="p-2 w-full" style="background-color: #ff4c4c">
<p class="flex-1 flex justify-between" style="color: #ffffff"> <p class="flex flex-1 justify-between" style="color: #ffffff">
Bright Sunset Bright Sunset
<button style="border-color: #ffffff" class="border rounded-lg p-0.5" <button
@click="save('#ff4c4c', '#ffcc00', '#ffffff', '#ffffff')">Choose</button> style="border-color: #ffffff"
class="p-0.5 border rounded-lg"
@click="save('#ff4c4c', '#ffcc00', '#ffffff', '#ffffff')"
>
Choose
</button>
</p> </p>
<SongItem :song="audioStore.currentSong.value" :border="'#ffffff'" :info="'#ffffff'" :action="'#ffcc00'" /> <SongItem :song="audioStore.currentSong.value" :border="'#ffffff'" :info="'#ffffff'" :action="'#ffcc00'" />
</div> </div>
<div class="w-full p-2" style="background-color: #003d00"> <div class="p-2 w-full" style="background-color: #003d00">
<p class="flex-1 flex justify-between" style="color: #e0f8d8"> <p class="flex flex-1 justify-between" style="color: #e0f8d8">
Forest Night Forest Night
<button style="border-color: #e0f8d8" class="border rounded-lg p-0.5" <button
@click="save('#003d00', '#a8d5a2', '#e0f8d8', '#e0f8d8')">Choose</button> style="border-color: #e0f8d8"
class="p-0.5 border rounded-lg"
@click="save('#003d00', '#a8d5a2', '#e0f8d8', '#e0f8d8')"
>
Choose
</button>
</p> </p>
<SongItem :song="audioStore.currentSong.value" :border="'#e0f8d8'" :info="'#e0f8d8'" :action="'#a8d5a2'" /> <SongItem :song="audioStore.currentSong.value" :border="'#e0f8d8'" :info="'#e0f8d8'" :action="'#a8d5a2'" />
</div> </div>
<div class="w-full p-2" style="background-color: #00274d"> <div class="p-2 w-full" style="background-color: #00274d">
<p class="flex-1 flex justify-between" style="color: #00ffff"> <p class="flex flex-1 justify-between" style="color: #00ffff">
Electric Blue Electric Blue
<button style="border-color: #00ffff" class="border rounded-lg p-0.5" <button
@click="save('#00274d', '#0099ff', '#00ffff', '#00ffff')">Choose</button> style="border-color: #00ffff"
class="p-0.5 border rounded-lg"
@click="save('#00274d', '#0099ff', '#00ffff', '#00ffff')"
>
Choose
</button>
</p> </p>
<SongItem :song="audioStore.currentSong.value" :border="'#00ffff'" :info="'#00ffff'" :action="'#0099ff'" /> <SongItem :song="audioStore.currentSong.value" :border="'#00ffff'" :info="'#00ffff'" :action="'#0099ff'" />
</div> </div>
</main> </main>
</template> </template>
<style scoped> <style scoped>
input[type='color']::-webkit-color-swatch-wrapper { input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0; padding: 0;
} }
input[type='color']::-webkit-color-swatch { input[type="color"]::-webkit-color-swatch {
border: none; border: none;
} }
</style> </style>

View File

@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();
function isActive(path: string) { function isActive(path: string) {
return route.path === path ? 'bg-blue-500 text-white' : ''; return route.path === path ? "bg-blue-500 text-white" : "";
}; }
</script> </script>
<template> <template>
@@ -14,16 +13,20 @@ function isActive(path: string) {
<header v-show="true"> <header v-show="true">
<div class="wrapper"> <div class="wrapper">
<nav class="flex justify-start my-2 mx-1 space-x-1 overflow-y-auto overflow-x-auto flex-nowrap text-nowrap"> <nav class="flex justify-start my-2 mx-1 space-x-1 overflow-y-auto overflow-x-auto flex-nowrap text-nowrap">
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i> <RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"
><i class="fa-solid fa-arrow-left"></i>
</RouterLink> </RouterLink>
<RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/recent">Recently <RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/recent"
added</RouterLink> >Recently added</RouterLink
>
<RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/favourites"> <RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/favourites">
Favorites</RouterLink> Favorites</RouterLink
>
<RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/collections"> <RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/collections">
Collections</RouterLink> Collections</RouterLink
>
</nav> </nav>
<hr> <hr />
</div> </div>
</header> </header>

View File

@@ -1,62 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue";
import { useAudio } from '@/composables/useAudio'; import { useAudio } from "@/composables/useAudio";
const audioStore = useAudio(); const audioStore = useAudio();
const title = computed(() => audioStore.currentSong.value?.name || 'Unknown Title') const title = computed(() => audioStore.currentSong.value?.name || "Unknown Title");
const artist = computed(() => audioStore.currentSong.value?.artist || 'Unknown Artist') const artist = computed(() => audioStore.currentSong.value?.artist || "Unknown Artist");
const bgimg = computed(() => audioStore.currentSong.value?.previewimage || '/default-bg.jpg') const bgimg = computed(() => audioStore.currentSong.value?.previewimage || "/default-bg.jpg");
</script> </script>
<template> <template>
<header> <header>
<div class="wrapper"> <div class="wrapper">
<div class="relative"> <div class="relative">
<nav class="flex flex-1 justify-start my-2 mx-1 space-x-1"> <nav class="flex flex-1 justify-start space-x-1 mx-1 my-2">
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i> <RouterLink class="shadow-xl backdrop--light p-1 rounded-full" to="/"
><i class="fa-arrow-left fa-solid"></i>
</RouterLink> </RouterLink>
<h1 class="absolute left-0 right-0 text-center"> Now Playing </h1> <h1 class="right-0 left-0 absolute text-center">Now Playing</h1>
</nav> </nav>
</div> </div>
<hr> <hr />
</div> </div>
</header> </header>
<main class="flex-1 flex flex-col items-center justify-center text-center px-4"> <main class="flex flex-col flex-1 justify-center items-center px-4 text-center">
<div class="flex flex-col items-center w-full max-w-md space-y-6"> <div class="flex flex-col items-center space-y-6 w-full max-w-md">
<div class="relative w-full aspect-square"> <div class="relative w-full aspect-square">
<img <img
class="absolute inset-0 w-full h-full object-cover rounded-lg shadow-lg" class="absolute inset-0 shadow-lg rounded-lg w-full h-full object-cover"
:src="encodeURI(bgimg + '?h=320&w=320')" :src="encodeURI(bgimg + '?h=320&w=320')"
:key="bgimg" :key="bgimg"
alt="Album Art" alt="Album Art"
/> />
<i class="absolute inset-0 flex items-center justify-center text-white text-5xl"> <i class="absolute inset-0 flex justify-center items-center text-white text-5xl">
<i class="fa-solid fa-play bg-black bg-opacity-50 p-4 rounded-full"></i> <i class="bg-black bg-opacity-50 p-4 rounded-full fa-solid fa-play"></i>
</i> </i>
</div> </div>
<div class="flex justify-between items-center w-full text-3xl space-x-6"> <div class="flex justify-between items-center space-x-6 w-full text-3xl">
<i class="fa-solid fa-backward-step" @click="audioStore.togglePrevious"></i> <i class="fa-solid fa-backward-step" @click="audioStore.togglePrevious"></i>
<i <i
:class="[audioStore.isPlaying.value ? 'fa-circle-pause' : 'fa-circle-play']" :class="[audioStore.isPlaying.value ? 'fa-circle-pause' : 'fa-circle-play']"
class="fa-regular text-5xl" class="text-5xl fa-regular"
@click="audioStore.togglePlay" @click="audioStore.togglePlay"
></i> ></i>
<i class="fa-solid fa-forward-step" @click="audioStore.toggleNext"></i> <i class="fa-solid fa-forward-step" @click="audioStore.toggleNext"></i>
</div> </div>
<div class="text-center w-full px-2"> <div class="px-2 w-full text-center">
<p class="truncate text-lg font-semibold">{{ title }}</p> <p class="font-semibold text-lg truncate">{{ title }}</p>
<RouterLink :to="'search?a=' + artist" class="block text-sm text-blue-500 truncate"> <RouterLink :to="'search?a=' + artist" class="block text-blue-500 text-sm truncate">
{{ artist }} {{ artist }}
</RouterLink> </RouterLink>
</div> </div>
<div class="flex justify-between items-center w-full px-4"> <div class="flex justify-between items-center px-4 w-full">
<i <i
@click="audioStore.toggleShuffle" @click="audioStore.toggleShuffle"
:class="[audioStore.shuffle.value ? 'text-yellow-500' : '']" :class="[audioStore.shuffle.value ? 'text-yellow-500' : '']"
@@ -70,21 +69,21 @@ const bgimg = computed(() => audioStore.currentSong.value?.previewimage || '/def
<i @click="$router.go(-1)" class="fa-solid fa-arrow-down"></i> <i @click="$router.go(-1)" class="fa-solid fa-arrow-down"></i>
</div> </div>
<div class="w-full px-4"> <div class="px-4 w-full">
<input <input
class="w-full appearance-none h-2 rounded-full bg-yellow-200 bg-opacity-20 accent-yellow-600 outline-none" class="bg-yellow-200 bg-opacity-20 rounded-full outline-none w-full h-2 accent-yellow-600 appearance-none"
type="range" type="range"
@input="event => audioStore.updateTime(Number(event.target.value))" @input="audioStore.updateTime(Number(($event.target as HTMLInputElement).value) || 0)"
:max="100" :max="100"
step="0.001" step="0.001"
:value="audioStore.percentDone.value" :value="audioStore.percentDone.value"
/> />
</div> </div>
<div class="flex justify-between text-sm w-full px-4"> <div class="flex justify-between px-4 w-full text-sm">
<span>{{ audioStore.currentTime.value }}</span> <span>{{ audioStore.currentTime.value }}</span>
<span>{{ audioStore.duration.value }}</span> <span>{{ audioStore.duration.value }}</span>
</div> </div>
</div> </div>
</main> </main>
</template> </template>

View File

@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import SongItem from '../components/SongItem.vue' import SongItem from "../components/SongItem.vue";
import { type Song, type CollectionPreview, mapApiToSongs } from '../script/types' import { type Song, type CollectionPreview, mapApiToSongs } from "../script/types";
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from "vue";
import { useRoute } from 'vue-router'; import { useRoute } from "vue-router";
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";
const userStore = useUser(); const userStore = useUser();
const audioStore = useAudio(); const audioStore = useAudio();
const { musicApi } = useApi(); const { musicApi } = useApi();
const api = musicApi() const api = musicApi.value;
const songs = ref<Song[]>([]); const songs = ref<Song[]>([]);
const name = ref('name'); const name = ref("name");
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
const limit = ref(100); const limit = ref(100);
@@ -38,11 +37,9 @@ const fetchRecent = async () => {
audioStore.setCollection(songs.value); audioStore.setCollection(songs.value);
} }
} catch (error) { } catch (error) {
console.error('Failed to load songs:', error) console.error("Failed to load songs:", error);
} }
};
}
onMounted(async () => { onMounted(async () => {
await fetchRecent(); await fetchRecent();
@@ -51,7 +48,7 @@ onMounted(async () => {
const container = containerRef.value; const container = containerRef.value;
if (container) { if (container) {
container.addEventListener('scroll', async () => { container.addEventListener("scroll", async () => {
const scrollTop = container.scrollTop; const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight; const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight; const clientHeight = container.clientHeight;
@@ -61,16 +58,11 @@ onMounted(async () => {
} }
}); });
} }
}); });
</script> </script>
<template> <template>
<div <div ref="containerRef" class="flex-col flex-1 overflow-y-scroll song-container">
ref="containerRef"
class="flex-1 flex-col overflow-y-scroll song-container"
>
<SongItem v-for="(song, index) in songs" :key="index" :song="song" /> <SongItem v-for="(song, index) in songs" :key="index" :song="song" />
</div> </div>
</template> </template>

View File

@@ -1,103 +1,107 @@
<script setup lang="ts"> <script setup lang="ts">
import { mapApiToSongs, mapToSong, type Song } from '../script/types' 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 ActiveSearchList from "../components/ActiveSearchList.vue";
import SongItem from '../components/SongItem.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";
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() const api = musicApi.value;
const activesongs = ref<Song[]>([]) const activesongs = ref<Song[]>([]);
const songs = ref<Song[]>([]) const songs = ref<Song[]>([]);
const artists = ref<string[]>([]) const artists = ref<string[]>([]);
const showSearch = ref(false) const showSearch = ref(false);
const searchTerm = ref('') const searchTerm = ref("");
async function fetchActiveSearch(term: string) { async function fetchActiveSearch(term: string) {
const response = await api.musicBackendSearch(term); const response = await api.musicBackendSearch(term);
const songData = mapApiToSongs(response.data.songs) const songData = mapApiToSongs(response.data.songs ?? []);
activesongs.value = songData activesongs.value = songData;
if (response.data.artist) artists.value = [response.data.artist] if (response.data.artist) artists.value = [response.data.artist];
audioStore.setCollection(songData) audioStore.setCollection(songData);
showSearch.value = true showSearch.value = true;
searchTerm.value = term searchTerm.value = term;
router.replace({ query: { s: term } }) router.replace({ query: { s: term } });
} }
async function fetchSearchArtist(artist: string) { async function fetchSearchArtist(artist: string) {
const response = await api.musicBackendArtist(artist) const response = await api.musicBackendArtist(artist);
const data = mapApiToSongs(response.data.songs) const data = mapApiToSongs(response.data.songs || []);
data.forEach((song: Song) => { data.forEach((song: Song) => {
song.previewimage = `${userStore.baseUrl.value}/api/v1/images/${song.previewimage}` song.previewimage = `${userStore.cloudflareUrl.value}/api/v1/images/${song.previewimage}`;
song.url = `${userStore.baseUrl.value}/api/v1/audio/${song.url}` song.url = `${userStore.cloudflareUrl.value}/api/v1/audio/${song.url}`;
}) });
songs.value = data songs.value = data;
showSearch.value = false showSearch.value = false;
} }
async function emptySearch() { async function emptySearch() {
activesongs.value = [] activesongs.value = [];
artists.value = [] artists.value = [];
songs.value = [] songs.value = [];
showSearch.value = false showSearch.value = false;
searchTerm.value = '' searchTerm.value = "";
router.replace({ query: {} }) router.replace({ query: {} });
searchInput.value = "";
} }
onMounted(async () => { onMounted(async () => {
if (route.query.a) { if (route.query.a) {
await fetchSearchArtist(route.query.a as string) await fetchSearchArtist(route.query.a as string);
} }
if (route.query.s) { if (route.query.s) {
await fetchActiveSearch(route.query.s as string) await fetchActiveSearch(route.query.s as string);
} }
}) });
watch(() => route.query.a, async (newArtist) => { watch(
() => route.query.a,
async (newArtist) => {
if (newArtist) { if (newArtist) {
await fetchSearchArtist(newArtist as string) await fetchSearchArtist(newArtist as string);
} else { } else {
songs.value = [] songs.value = [];
} }
}) },
);
const searchInput = ref(searchTerm.value) const searchInput = ref(searchTerm.value);
watch(searchInput, async (val) => { watch(searchInput, async (val) => {
if (val && val.trim() !== '') { if (val && val.trim() !== "") {
await fetchActiveSearch(val) await fetchActiveSearch(val);
} else { } else {
showSearch.value = false showSearch.value = false;
activesongs.value = [] activesongs.value = [];
artists.value = [] artists.value = [];
router.replace({ query: {} }) router.replace({ query: {} });
} }
}) });
</script> </script>
<template> <template>
<header> <header>
<div class="wrapper"> <div class="wrapper">
<nav class="flex justify-start my-2 mx-1 space-x-1"> <nav class="flex justify-start space-x-1 mx-1 my-2">
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"> <RouterLink class="shadow-xl backdrop--light p-1 rounded-full" to="/">
<i class="fa-solid fa-arrow-left"></i> <i class="fa-arrow-left fa-solid"></i>
</RouterLink> </RouterLink>
<h1 class="absolute left-0 right-0 text-center">Search</h1> <h1 class="right-0 left-0 absolute text-center">Search</h1>
</nav> </nav>
<hr /> <hr />
</div> </div>
@@ -108,15 +112,15 @@ watch(searchInput, async (val) => {
<input <input
v-model="searchInput" v-model="searchInput"
placeholder="Type to Search..." placeholder="Type to Search..."
class="w-full flex-1 max-h-12 search border bordercolor accent-pink-800 bg-yellow-300 bg-opacity-20 rounded-lg p-2" class="flex-1 bg-yellow-300 bg-opacity-20 p-2 border rounded-lg w-full max-h-12 accent-pink-800 search bordercolor"
/> />
<div class="absolute top-4 right-4 flex flex-col justify-center cursor-pointer" @click="emptySearch"> <div class="top-4 right-4 absolute flex flex-col justify-center cursor-pointer" @click="emptySearch">
<i class="far fa-times-circle opacity-50"></i> <i class="opacity-50 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 flex-col w-full h-full overflow-y-scroll">
<div v-if="showSearch" class="absolute w-full text-center search-recommendations z-20"> <div v-if="showSearch" class="z-20 absolute w-full text-center search-recommendations">
<ActiveSearchList :songs="activesongs" :artist="artists" :search="searchTerm" /> <ActiveSearchList :songs="activesongs" :artist="artists" :search="searchTerm" />
</div> </div>
<SongItem v-for="(song, index) in songs" :key="index" :song="song" /> <SongItem v-for="(song, index) in songs" :key="index" :song="song" />

View File

@@ -1,12 +1,6 @@
{ {
"extends": "@tsconfig/node22/tsconfig.json", "extends": "@tsconfig/node22/tsconfig.json",
"include": [ "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"],
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"noEmit": true, "noEmit": true,

View File

@@ -1,18 +1,15 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from "node:url";
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import vue from '@vitejs/plugin-vue' import vue from "@vitejs/plugin-vue";
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from "vite-plugin-vue-devtools";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [vue(), vueDevTools()],
vue(),
vueDevTools(),
],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) "@": fileURLToPath(new URL("./src", import.meta.url)),
}, },
}, },
}) });