swap to generated api code and composables

This commit is contained in:
2025-05-21 16:44:23 +02:00
parent b9e865780a
commit a7a6a9b65d
29 changed files with 3307 additions and 517 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"explorer.excludeGitIgnore": false
}

2
frontend/.env Normal file
View File

@@ -0,0 +1,2 @@
PROXY_URL=
BACKEND_URL=http://localhost:8080

2
frontend/.gitignore vendored
View File

@@ -28,3 +28,5 @@ coverage
*.sw?
*.tsbuildinfo
generated/

View File

@@ -0,0 +1,580 @@
{
"swagger": "2.0",
"info": {
"title": "osu_music.proto",
"version": "version not set"
},
"tags": [
{
"name": "MusicBackend"
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/api/v1/artist/{artist}": {
"get": {
"operationId": "MusicBackend_Artist",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1ArtistResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "artist",
"in": "path",
"required": true,
"type": "string"
}
],
"tags": [
"MusicBackend"
]
}
},
"/api/v1/collections": {
"get": {
"operationId": "MusicBackend_Collections",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1CollectionResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "name",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "index",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
},
{
"name": "limit",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
},
{
"name": "offset",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"MusicBackend"
]
}
},
"/api/v1/favorites": {
"get": {
"operationId": "MusicBackend_Favorite",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1FavoriteResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "query",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "limit",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
},
{
"name": "offset",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"MusicBackend"
]
}
},
"/api/v1/recent": {
"get": {
"operationId": "MusicBackend_Recent",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1RecentResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
},
{
"name": "offset",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"MusicBackend"
]
}
},
"/api/v1/search": {
"get": {
"operationId": "MusicBackend_Search",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1SearchSharedResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "query",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "limit",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
},
{
"name": "offset",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"MusicBackend"
]
}
},
"/api/v1/search/artists": {
"get": {
"operationId": "MusicBackend_SearchArtists",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1SearchArtistResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "query",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "limit",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
},
{
"name": "offset",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"MusicBackend"
]
}
},
"/api/v1/search/collections": {
"get": {
"operationId": "MusicBackend_SearchCollections",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1SearchCollectionResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "query",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "limit",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
},
{
"name": "offset",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"MusicBackend"
]
}
},
"/api/v1/song/{hash}": {
"get": {
"operationId": "MusicBackend_Song",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1SongResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "hash",
"in": "path",
"required": true,
"type": "string"
}
],
"tags": [
"MusicBackend"
]
}
},
"/ping": {
"get": {
"operationId": "MusicBackend_Ping",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1PingResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "ping",
"in": "query",
"required": false,
"type": "string"
}
],
"tags": [
"MusicBackend"
]
}
}
},
"definitions": {
"apiv1Artist": {
"type": "object",
"properties": {
"artist": {
"type": "string"
},
"items": {
"type": "integer",
"format": "int32"
}
}
},
"apiv1Song": {
"type": "object",
"properties": {
"beatmapId": {
"type": "integer",
"format": "int32"
},
"md5Hash": {
"type": "string"
},
"title": {
"type": "string"
},
"artist": {
"type": "string"
},
"creator": {
"type": "string"
},
"folder": {
"type": "string"
},
"file": {
"type": "string"
},
"audio": {
"type": "string"
},
"totalTime": {
"type": "string",
"format": "int64"
},
"image": {
"type": "string"
}
},
"title": "===== models ====="
},
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
},
"v1ArtistResponse": {
"type": "object",
"properties": {
"songs": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/apiv1Song"
}
}
}
},
"v1CollectionPreview": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"image": {
"type": "string"
},
"items": {
"type": "integer",
"format": "int32"
}
}
},
"v1CollectionResponse": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"items": {
"type": "integer",
"format": "int32"
},
"songs": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/apiv1Song"
}
}
}
},
"v1FavoriteResponse": {
"type": "object",
"properties": {
"songs": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/apiv1Song"
}
}
}
},
"v1PingResponse": {
"type": "object",
"properties": {
"pong": {
"type": "string"
}
}
},
"v1RecentResponse": {
"type": "object",
"properties": {
"songs": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/apiv1Song"
}
}
}
},
"v1SearchArtistResponse": {
"type": "object",
"properties": {
"Artists": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/apiv1Artist"
}
}
}
},
"v1SearchCollectionResponse": {
"type": "object",
"properties": {
"collections": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/v1CollectionPreview"
}
}
}
},
"v1SearchSharedResponse": {
"type": "object",
"properties": {
"artist": {
"type": "string"
},
"songs": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/apiv1Song"
}
}
}
},
"v1SongResponse": {
"type": "object",
"properties": {
"song": {
"$ref": "#/definitions/apiv1Song"
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.13.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,14 +8,17 @@
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
"type-check": "vue-tsc --build",
"generate": "openapi-generator-cli generate -i api-specs/osu_music.swagger.json -g typescript-axios -o ./src/generated"
},
"dependencies": {
"axios": "^1.9.0",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.20.2",
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.9.3",
"@vitejs/plugin-vue": "^5.2.1",

View File

@@ -6,15 +6,12 @@ import MenuView from '@/views/MenuView.vue'
import Footer from '@/components/Footer.vue'
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { defineStore } from 'pinia'
import { useHeaderStore } from '@/stores/headerStore';
import { isMobile, isPc } from './script/utils.ts'
import { useAudio } from './composables/useAudio.ts'
const headerStore = useHeaderStore();
const showFooter = ref(true);
const showNowPlaying = ref(true);
const route = useRoute();
const audio = useAudio();
watch(route, async (to) => {
@@ -23,13 +20,13 @@ watch(route, async (to) => {
} else {
showNowPlaying.value = true;
}
/*
if (route.path.startsWith("/menu")) {
headerStore.hide();
} else {
headerStore.show();
}
*/
})
function loadColors() {
@@ -57,22 +54,17 @@ const checkScreenSize = () => {
};
onMounted(() => {
checkScreenSize(); // Initial check
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
});
onUnmounted(() => {
// Clean up the listener when component is destroyed
window.removeEventListener('resize', checkScreenSize);
});
</script>
<template>
<div v-if="screenInfo.isSmall" class="flex flex-col h-screen max-h-screen wrapper info text-xl ">
<RouterView />

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { Song, CollectionPreview } from '../script/types'
import { useAudioStore } from '@/stores/audioStore';
import { useAudio } from '@/composables/useAudio';
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
const audioStore = useAudioStore()
const audioStore = useAudio()
const props = defineProps<{
songs: Song[];

View File

@@ -1,52 +1,36 @@
<script setup lang="ts">
import SongItem from '../components/SongItem.vue'
import type { Song, CollectionPreview } from '../script/types'
import type { Song } from '../script/types'
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/userStore';
import { useRoute } from 'vue-router';
import { useRoute } from 'vue-router'
import CollectionListItem from '../components/CollectionListItem.vue'
import { useAudioStore } from '@/stores/audioStore';
import { useAudio } from '@/composables/useAudio'
import { useApi } from '@/composables/useApi'
const route = useRoute();
const userStore = useUserStore();
const audioStore = useAudioStore();
const route = useRoute()
const audioStore = useAudio()
const { musicApi } = useApi()
const songs = ref<Song[]>([]);
const name = ref('name');
const songs = ref<Song[]>([])
const name = ref('name')
onMounted(async () => {
const data = await userStore.fetchCollection(Number(route.params.id));
console.log(data)
try {
const response = await musicApi().musicBackendCollections(Array.isArray(route.params.id) ? route.params.id[0] : route.params.id) // Adjust method name if needed
data.songs.forEach(song => {
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
});
const base = import.meta.env.VITE_MUSIC_API_URL || 'http://localhost:8080'
name.value = data.name;
songs.value = data.songs;
response.songs.forEach(song => {
song.previewimage = `${base}/api/v1/images/${song.previewimage}`
song.url = `${base}/api/v1/audio/${song.url}`
})
audioStore.setCollection(songs.value)
});
name.value = response.name
songs.value = response.songs
audioStore.setCollection(songs.value)
} catch (error) {
console.error('Error fetching collection:', error)
}
})
</script>
<template>
<header>
<div class="wrapper">
<nav class="flex justify-start my-2 mx-1 space-x-1">
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/menu/collections"><i
class="fa-solid fa-arrow-left"></i>
</RouterLink>
<h1 class="px-8 text-nowrap overflow-scroll absolute left-0 right-0 text-center"> {{ name }} </h1>
</nav>
<hr>
</div>
</header>
<div class="flex-1 flex-col h-full overflow-scroll">
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
</div>
</template>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import { defineProps } from 'vue';
import type { CollectionPreview } from '@/script/types';
import { useUserStore } from '@/stores/userStore';
import { useAudioStore } from '@/stores/audioStore';
const userStore = useUserStore();
const audioStore = useAudioStore();
const props = defineProps<{ collection: CollectionPreview }>();

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { useUser } from '@/composables/useUser';
import { useUserStore } from '@/stores/userStore';
const userStore = useUserStore();
const userStore = useUser();
</script>
@@ -10,7 +10,7 @@ const userStore = useUserStore();
<hr>
<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">
<img :src="userStore.User ? userStore.User.avatar_url : 'https://osu.ppy.sh/images/layout/avatar-guest.png'"
<img :src="userStore.user.value?.avatar_url || 'https://osu.ppy.sh/images/layout/avatar-guest.png'"
class="md:h-12 h-6 rounded-full">
</RouterLink>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAudioStore } from '@/stores/audioStore';
import { useAudio } from '@/composables/useAudio';
const audioStore = useAudioStore();
const audioStore = useAudio();
</script>
@@ -10,7 +9,7 @@ const audioStore = useAudioStore();
<div>
<hr>
<div class="relative wrapper p-1 grow action">
<img :src="encodeURI(audioStore.bgimg + '?h=150&w=400')" class="w-full absolute top-0 left-0 right-0 h-full"
<img :src="encodeURI(audioStore.bgimg.value + '?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">
@@ -18,27 +17,27 @@ const audioStore = useAudioStore();
<div class="flex justify-between">
<RouterLink to="/nowplaying" class="grow overflow-hidden">
<p class="relative text-sm text-left font-bold info overflow-hidden text-ellipsis text-nowrap">
{{ audioStore.title }}
{{ audioStore.title.value }}
</p>
<p class="relative text-sm text-left font-bold info text-nowrap">
{{ audioStore.artist }}
{{ audioStore.artist.value }}
</p>
</RouterLink>
<div class="flex flex-col text-center justify-center px-2" @click="audioStore.togglePlay">
<i :class="[audioStore.isPlaying ? ' fa-circle-play' : 'fa-circle-pause']" class="text-3xl fa-regular"></i>
<i :class="[audioStore.isPlaying.value ? ' fa-circle-play' : 'fa-circle-pause']" class="text-3xl fa-regular"></i>
</div>
</div>
<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"
:style="{ 'width': audioStore.percentDone + '%' }">
:style="{ 'width': audioStore.percentDone.value + '%' }">
</div>
</div>
</nav>
<audio controls class="hidden" id="audio-player" :src="audioStore.songSrc"
<audio controls class="hidden" id="audio-player" :src="audioStore.songSrc.value"
@timeupdate="audioStore.update"></audio>
</div>
</div>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { ref, defineProps } from 'vue'
import { useAudioStore } from '@/stores/audioStore';
import { useUserStore } from '@/stores/userStore';
import { useAudio } from '@/composables/useAudio';
import type { Song } from '@/script/types';
const props = defineProps<{
@@ -10,8 +8,7 @@ const props = defineProps<{
info?: string,
border?: string,
}>();
const userStore = useUserStore();
const audioStore = useAudioStore();
const audioStore = useAudio();
function updateSong() {

View File

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

View File

@@ -0,0 +1,229 @@
import { ref, onMounted } from 'vue'
import { mapApiToSongs, type Song } from '@/script/types'
import { useApi } from './useApi'
let audioInstance: ReturnType<typeof createAudio> | null = null
function createAudio() {
const { musicApi } = useApi()
const songSrc = ref('https://cdn.pixabay.com/audio/2024/05/24/audio_46382ae035.mp3')
const artist = ref('Artist')
const title = ref('Title')
const bgimg = ref('https://assets.ppy.sh/beatmaps/2197744/covers/cover@2x.jpg?1722207959')
const hash = ref('0000')
const isPlaying = ref(false)
const duration = ref('0:00')
const currentTime = ref('0:00')
const percentDone = ref(0)
const shuffle = ref(false)
const repeat = ref(false)
const activeCollection = ref<Song[]>([])
const currentSong = ref<Song | null>(null)
function saveSongToLocalStorage(song: Song) {
console.log(song)
localStorage.setItem('lastPlayedSong', JSON.stringify(song))
}
function loadSongFromLocalStorage(): Song | null {
const song = localStorage.getItem('lastPlayedSong')
return song ? JSON.parse(song) : null
}
function saveCollectionToLocalStorage(collection: Song[]) {
localStorage.setItem('lastActiveCollection', JSON.stringify(collection))
}
function loadCollectionFromLocalStorage(): Song[] | null {
const collection = localStorage.getItem('lastActiveCollection')
return collection ? JSON.parse(collection) : null
}
function togglePlay() {
const audio = document.getElementById('audio-player') as HTMLAudioElement
if (!audio) return
if (audio.paused) {
audio.play()
} else {
audio.pause()
}
}
function update() {
const audio = document.getElementById('audio-player') as HTMLAudioElement
if (!audio) return
isPlaying.value = !audio.paused
const current_min = Math.floor(audio.currentTime / 60)
const current_sec = Math.floor(audio.currentTime % 60)
if (!isNaN(current_min) && !isNaN(current_sec)) {
currentTime.value = `${current_min}:${current_sec.toString().padStart(2, '0')}`
}
const duration_min = Math.floor(audio.duration / 60)
const duration_sec = Math.floor(audio.duration % 60)
if (!isNaN(duration_min) && !isNaN(duration_sec)) {
duration.value = `${duration_min}:${duration_sec.toString().padStart(2, '0')}`
}
const percent = (audio.currentTime / audio.duration) * 100
if (!isNaN(percent)) {
percentDone.value = percent
}
if (audio.ended) {
next()
}
}
function next() {
const audio = document.getElementById('audio-player') as HTMLAudioElement
if (!audio) return
if (repeat.value) {
audio.pause()
audio.currentTime = 0
audio.play()
return
}
if (shuffle.value) {
audio.pause()
const randomSong = activeCollection.value[Math.floor(Math.random() * activeCollection.value.length)]
setSong(randomSong)
audio.play()
return
}
toggleNext()
}
function updateTime() {
const audio = document.getElementById('audio-player') as HTMLAudioElement
const slider = document.getElementById('audio-slider') as HTMLInputElement
if (!audio || !slider) return
audio.currentTime = (Number(slider.value) / 100) * audio.duration
}
function togglePrev() {
const index = activeCollection.value.findIndex((s) => s.hash === hash.value)
if (index === -1) return
const prevIndex = (index - 1 + activeCollection.value.length) % activeCollection.value.length
setSong(activeCollection.value[prevIndex])
}
function toggleNext() {
let index = 0
if (shuffle.value) {
index = Math.floor(Math.random() * activeCollection.value.length)
} else {
index = activeCollection.value.findIndex((s) => s.hash === hash.value)
if (index === -1) return
index = (index + 1) % activeCollection.value.length
}
setSong(activeCollection.value[index])
}
function toggleShuffle() {
shuffle.value = !shuffle.value
}
function toggleRepeat() {
repeat.value = !repeat.value
}
function setSong(song: Song | null) {
if (!song) return
songSrc.value = song.url
artist.value = song.artist
title.value = song.name
bgimg.value = song.previewimage
hash.value = song.hash
currentSong.value = song
saveSongToLocalStorage(song)
const audio = document.getElementById('audio-player') as HTMLAudioElement
if (!audio) return
if (!audio.paused) audio.pause()
audio.src = song.url
audio.addEventListener('canplaythrough', () => {
audio.play().catch(console.error)
}, { once: true })
}
function setCollection(songs: Song[] | null) {
if (!songs) return
activeCollection.value = songs
saveCollectionToLocalStorage(songs)
}
async function loadInitialCollection() {
try {
const api = musicApi()
const response = await api.musicBackendRecent()
if (response.data.songs) {
setCollection(mapApiToSongs(response.data.songs))
}
} catch (error) {
console.error('Failed to load songs:', error)
}
}
function init() {
setSong(loadSongFromLocalStorage())
setCollection(loadCollectionFromLocalStorage())
console.log(activeCollection.value)
console.log(currentSong.value)
if (!currentSong.value || activeCollection.value.length === 0) {
loadInitialCollection()
}
}
onMounted(() => {
init()
})
return {
songSrc,
artist,
title,
bgimg,
hash,
isPlaying,
duration,
currentTime,
percentDone,
shuffle,
repeat,
activeCollection,
currentSong,
togglePlay,
update,
next,
updateTime,
togglePrev,
toggleNext,
toggleShuffle,
toggleRepeat,
setSong,
setCollection,
init,
}
}
export function useAudio() {
if (!audioInstance) {
audioInstance = createAudio()
}
return audioInstance
}

View File

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

View File

@@ -0,0 +1,66 @@
import { ref } from 'vue';
import type { Me } from '@/script/types';
let userInstance: ReturnType<typeof createUser> | null = null;
function createUser() {
const user = ref<Me | null>(null);
const baseUrl = ref(import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080');
const proxyUrl = ref(import.meta.env.VITE_PROXY_URL || 'http://localhost:8081');
function saveUser(u: Me | null) {
localStorage.setItem('activeUser', JSON.stringify(u));
}
function loadUser(): Me | null {
const u = localStorage.getItem('activeUser');
return u ? JSON.parse(u) : null;
}
function setUser(u: Me | null) {
user.value = u;
saveUser(u);
}
async function fetchMe(): Promise<Me | {}> {
try {
const response = await fetch(`${proxyUrl.value}/me`, {
method: 'GET',
credentials: 'include',
});
if (response.redirected) {
window.open(response.url, '_blank');
return { redirected: true };
}
if (!response.ok) {
console.error(`Fetch failed: ${response.status} ${response.statusText}`);
return { id: -1 } as Me;
}
const data = await response.json();
return data;
} catch (error) {
console.error('Fetch error:', error);
return {};
}
}
setUser(loadUser());
return {
user,
baseUrl,
proxyUrl,
setUser,
fetchMe,
};
}
export function useUser() {
if (!userInstance) {
userInstance = createUser();
}
return userInstance;
}

View File

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

View File

@@ -1,3 +1,5 @@
import type { Apiv1Song, v1CollectionPreview } from '@/generated';
export type Song = {
hash: string;
name: string;
@@ -8,6 +10,20 @@ export type Song = {
mapper: string;
};
const basePath = import.meta.env.BACKEND_URL || 'http://localhost:8080';
export function mapToSong(apiSong: Apiv1Song): Song {
return {
hash: apiSong.md5Hash,
name: apiSong.title,
artist: apiSong.artist,
length: Number(apiSong.totalTime),
url: `${basePath}/api/v1/audio/${btoa(apiSong.folder + "/" + apiSong.audio).replace(/=+$/, '')}`,
previewimage: `${basePath}/api/v1/image/${btoa(apiSong.image === "" || apiSong.image === undefined ? "404.png" : apiSong.image).replace(/=+$/, '')}`,
mapper: apiSong.creator,
};
}
export type CollectionPreview = {
index: number;
name: string;
@@ -15,6 +31,19 @@ export type CollectionPreview = {
previewimage: string;
};
export function mapToCollectionPreview(
apiCollection: v1CollectionPreview,
index: number
): CollectionPreview {
return {
index,
name: apiCollection.name,
length: apiCollection.items,
previewimage: `${basePath}/api/v1/images/${apiCollection.image}`,
};
}
export type Me = {
id: number;
name: string;
@@ -22,3 +51,13 @@ export type Me = {
endpoint: string;
share: boolean;
};
export function mapApiToSongs(apiSongs: Apiv1Song[]): Song[] {
return apiSongs.map(mapToSong);
}
export function mapApiToCollectionPreview(
apiCollections: v1CollectionPreview[]
): CollectionPreview[] {
return apiCollections.map((c, i) => mapToCollectionPreview(c, i));
}

View File

@@ -1,189 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Song, CollectionPreview } from '@/script/types';
export const useAudioStore = defineStore('audioStore', () => {
const songSrc = ref('https://cdn.pixabay.com/audio/2024/05/24/audio_46382ae035.mp3')
const artist = ref('Artist ');
const title = ref('Title ');
const bgimg = ref('https://assets.ppy.sh/beatmaps/2197744/covers/cover@2x.jpg?1722207959');
const hash = ref('0000');
const isPlaying = ref(true)
const duration = ref('0:00')
const currentTime = ref('0:00')
const percentDone = ref(0)
const shuffle = ref(false);
const repeat = ref(false);
const activeCollection = ref<Song[]>([]);
const currentSong = ref<Song | null>(null);
function saveSongToLocalStorage(song: Song) {
localStorage.setItem('lastPlayedSong', JSON.stringify(song));
}
function loadSongFromLocalStorage(): Song | null {
const song = localStorage.getItem('lastPlayedSong');
return song ? JSON.parse(song) : null;
}
function saveCollectionToLocalStorage(collection: Song[]) {
localStorage.setItem('lastActiveCollection', JSON.stringify(collection));
}
function loadCollectionFromLocalStorage(): Song[] | null {
const collection = localStorage.getItem('lastActiveCollection');
return collection ? JSON.parse(collection) : null;
}
function togglePlay() {
var audio = document.getElementById("audio-player") as HTMLAudioElement;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}
function update() {
var audio = document.getElementById("audio-player") as HTMLAudioElement;
isPlaying.value = audio.paused;
let current_min = Math.floor(audio.currentTime / 60);
let current_sec = Math.round(audio.currentTime % 60);
if (!isNaN(current_sec) && !isNaN(current_min)) {
currentTime.value = current_min + ':' + current_sec.toString().padStart(2, '0');
}
let duration_min = Math.floor(audio.duration / 60);
let duration_sec = Math.round(audio.duration % 60);
if (!isNaN(duration_sec) && !isNaN(duration_min)) {
duration.value = duration_min + ':' + duration_sec.toString().padStart(2, '0');
}
let percent = (audio.currentTime / audio.duration) * 100
if (!isNaN(percent)) {
percentDone.value = percent;
}
if (audio.ended) {
next();
}
}
function next() {
var audio = document.getElementById("audio-player") as HTMLAudioElement;
if (repeat.value) {
audio.pause()
audio.currentTime = 0;
audio.play()
return;
}
if (shuffle.value) {
audio.pause()
setSong(activeCollection.value[Math.floor(activeCollection.value.length * Math.random())])
audio.play()
return;
}
toggleNext()
}
function updateTime() {
var audioslider = document.getElementById("audio-slider") as HTMLInputElement;
var audio = document.getElementById("audio-player") as HTMLAudioElement;
audio.currentTime = Math.round((Number(audioslider.value) / 100) * audio.duration)
}
function togglePrev() {
let index = activeCollection.value.findIndex(s => s.hash == hash.value);
setSong(activeCollection.value[Math.abs((index - 1 + activeCollection.value.length) % activeCollection.value.length)])
console.log(Math.abs((index - 1 + activeCollection.value.length) % activeCollection.value.length))
}
function toggleNext() {
let index = 0;
if (shuffle.value) {
index = Math.floor(activeCollection.value.length * Math.random())
} else {
index = activeCollection.value.findIndex(s => s.hash == hash.value);
}
setSong(activeCollection.value[Math.abs((index + 1) % activeCollection.value.length)])
}
function toggleShuffle() {
console.log('shuffle', !shuffle.value)
shuffle.value = !shuffle.value;
}
function toggleRepeat() {
console.log('repeat', !repeat.value)
repeat.value = !repeat.value;
}
function setSong(song: Song | null) {
if (song === null) {
return;
}
console.log('setSong', song)
var audio = document.getElementById("audio-player") as HTMLAudioElement;
songSrc.value = song.url;
artist.value = song.artist;
title.value = song.name;
bgimg.value = song.previewimage;
hash.value = song.hash;
currentSong.value = song;
saveSongToLocalStorage(song);
if (audio === null) { return; }
if (!audio.paused) {
audio.pause
}
audio.src = song.url;
audio.addEventListener('canplaythrough', () => {
audio.play().catch(error => {
console.error('Playback error:', error);
});
});
}
function setCollection(songs: Song[] | null) {
if (songs === null) { return; }
activeCollection.value = songs;
saveCollectionToLocalStorage(songs);
}
setSong(loadSongFromLocalStorage());
setCollection(loadCollectionFromLocalStorage());
return { setCollection, songSrc, updateTime, artist, title, bgimg, shuffle, repeat, setSong, togglePlay, togglePrev, toggleNext, toggleRepeat, toggleShuffle, isPlaying, currentTime, duration, update, percentDone, currentSong }
})

View File

@@ -1,8 +1,11 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { Song, CollectionPreview, Me } from '@/script/types';
import { useApi } from '@/composables/useApi';
export const useUserStore = defineStore('userStore', () => {
const { musicApi } = useApi();
const userId = ref(null)
const baseUrl = ref('https://service.illegalesachen.download')
const proxyUrl = ref('https://proxy.illegalesachen.download')

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { Song, CollectionPreview } from '../script/types'
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/userStore';
import CollectionListItem from '../components/CollectionListItem.vue'
import { useUser } from '@/composables/useUser';
const userStore = useUserStore();
const userStore = useUser();
const collections = ref<CollectionPreview[]>([]);
const limit = ref(10);

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/userStore';
import SongItem from '../components/SongItem.vue'
import { useAudioStore } from '@/stores/audioStore';
import { useAudio } from '@/composables/useAudio';
import { useUser } from '@/composables/useUser';
import type { Me } from '@/script/types';
const audioStore = useAudioStore();
const userStore = useUserStore();
const audioStore = useAudio();
const userStore = useUser();
const bgColor = ref('');
const actionColor = ref('');
@@ -16,7 +17,7 @@ const loginStatus = ref('Login');
function update() {
var input = document.getElementById("url-input") as HTMLInputElement;
userStore.baseUrl = input.value;
userStore.baseUrl.value = input.value;
}
@@ -50,7 +51,7 @@ async function getMe() {
console.log("active user: ", data.name)
userStore.setUser(data);
userStore.setBaseUrl(data.endpoint);
userStore.baseUrl.value(data.endpoint);
}
@@ -87,15 +88,15 @@ function reset() {
<main class="flex-1 flex flex-col overflow-scroll">
<h1> Meeeeee </h1>
<input @change="update" type="text" id="url-input" :value="userStore.baseUrl" disabled />
<input @change="update" type="text" id="url-input" :value="userStore.baseUrl.value" disabled />
<br>
<button v-if="!userStore.User" @click="getMe" class="border bordercolor rounded-lg p-0.5">{{ loginStatus }}</button>
<div v-if="userStore.User" class="flex p-5 justify-between">
<img :src="userStore.User.avatar_url" class="w-1/3">
<button v-if="!userStore.user.value" @click="getMe" class="border bordercolor rounded-lg p-0.5">{{ loginStatus }}</button>
<div v-if="userStore.user.value" class="flex p-5 justify-between">
<img :src="userStore.user.value.avatar_url" class="w-1/3">
<div>
<p>{{ userStore.User.name }}</p>
<p>{{ userStore.User.endpoint == "" ? 'Not Connected' : 'Connected' }}</p>
<p>Sharing: <button @click="share" class="border bordercolor rounded-lg p-0.5">{{ userStore.User.share
<p>{{ userStore.user.value.name }}</p>
<p>{{ userStore.user.value.endpoint == "" ? 'Not Connected' : 'Connected' }}</p>
<p>Sharing: <button @click="share" class="border bordercolor rounded-lg p-0.5">{{ userStore.user.value.share
}}</button></p>
<button @click="getMe" class="border bordercolor rounded-lg p-0.5"> Refresh
</button>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useHeaderStore } from '@/stores/headerStore';
const route = useRoute();
@@ -9,7 +7,6 @@ function isActive(path: string) {
return route.path === path ? 'bg-blue-500 text-white' : '';
};
const headerStore = useHeaderStore();
</script>
<template>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAudioStore } from '@/stores/audioStore';
import { useAudio } from '@/composables/useAudio';
const audioStore = useAudioStore();
const audioStore = useAudio();
</script>
<template>
@@ -25,7 +24,7 @@ const audioStore = useAudioStore();
<i class="relative p-36 fa-solid fa-play">
<img class="absolute top-4 left-0 bottom-0 right-0 bg-center bg-cover rounded-lg"
:src="encodeURI(audioStore.bgimg + '?h=320&w=320')" :key="audioStore.bgimg" />
:src="encodeURI(audioStore.bgimg.value + '?h=320&w=320')" :key="audioStore.bgimg.value" />
</i>
</div>
@@ -34,25 +33,25 @@ const audioStore = useAudioStore();
<div>
<div class="flex w-full justify-around">
<i class="fa-solid fa-backward-step text-5xl self-center" @click="audioStore.togglePrev"></i>
<i :class="[audioStore.isPlaying ? 'fa-circle-play' : 'fa-circle-pause']" class="fa-regular text-7xl "
<i :class="[audioStore.isPlaying.value ? 'fa-circle-play' : 'fa-circle-pause']" class="fa-regular text-7xl "
@click="audioStore.togglePlay"></i>
<i class="fa-solid fa-forward-step text-5xl self-center" @click="audioStore.toggleNext"></i>
</div>
</div>
<div class="flex flex-1 justify-around ml-4">
<i @click="audioStore.toggleShuffle" :class="[audioStore.shuffle ? 'info' : '']"
<i @click="audioStore.toggleShuffle" :class="[audioStore.shuffle.value ? 'info' : '']"
class="fa-solid fa-shuffle"></i>
<div class="m-4 info flex-1 overflow-hidden">
<p>{{ audioStore.title }}</p>
<RouterLink :to="'search?a=' + audioStore.artist">
<p>{{ audioStore.title.value }}</p>
<RouterLink :to="'search?a=' + audioStore.artist.value">
{{ audioStore.artist }}
{{ audioStore.artist.value }}
</RouterLink>
</div>
<div class="flex flex-col justify-between mb-4 mr-4">
<i @click="audioStore.toggleRepeat" :class="[audioStore.repeat ? 'info' : '']"
<i @click="audioStore.toggleRepeat" :class="[audioStore.repeat.value ? 'info' : '']"
class="fa-solid fa-repeat"></i>
<i @click="$router.go(-1);" class="fa-solid fa-arrow-down"></i>
</div>
@@ -60,11 +59,11 @@ const audioStore = useAudioStore();
<div class="flex">
<input
class="appearance-none mx-4 flex-1 bg-yellow-200 bg-opacity-20 accent-yellow-600 rounded-lg outline-none slider "
type="range" id="audio-slider" @change="audioStore.updateTime" max="100" :value="audioStore.percentDone">
type="range" id="audio-slider" @change="audioStore.updateTime" max="100" :value="audioStore.percentDone.value">
</div>
<div class="flex justify-between mx-4">
<span id="current-time" class="time">{{ audioStore.currentTime }}</span>
<span id="duration" class="time ">{{ audioStore.duration }}</span>
<span id="current-time" class="time">{{ audioStore.currentTime.value }}</span>
<span id="duration" class="time ">{{ audioStore.duration.value }}</span>
</div>
</div>
</div>

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import SongItem from '../components/SongItem.vue'
import type { Song, CollectionPreview } from '../script/types'
import { type Song, type CollectionPreview, mapApiToSongs } from '../script/types'
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/userStore';
import { useRoute } from 'vue-router';
import CollectionListItem from '../components/CollectionListItem.vue'
import { useAudioStore } from '@/stores/audioStore';
import { useAudio } from '@/composables/useAudio';
import { useUser } from '@/composables/useUser';
import { useApi } from '@/composables/useApi';
const userStore = useUser();
const audioStore = useAudio();
const { musicApi } = useApi();
const api = musicApi()
const route = useRoute();
const userStore = useUserStore();
const audioStore = useAudioStore();
const songs = ref<Song[]>([]);
const name = ref('name');
@@ -23,19 +25,23 @@ const fetchRecent = async () => {
if (isLoading.value) return;
isLoading.value = true;
const data = await userStore.fetchRecent(limit.value, offset.value);
data.forEach(song => {
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
});
try {
const response = await api.musicBackendRecent(limit.value, offset.value);
if (response.data.songs) {
let newSongs = mapApiToSongs(response.data.songs);
offset.value += limit.value;
console.log(data)
songs.value = [...songs.value, ...data];
songs.value = [...songs.value, ...newSongs];
console.log(offset.value)
isLoading.value = false;
audioStore.setCollection(songs.value);
}
} catch (error) {
console.error('Failed to load songs:', error)
}
}

View File

@@ -1,124 +1,128 @@
<script setup lang="ts">
import type { Song, CollectionPreview } from '../script/types'
import { useUserStore } from '@/stores/userStore';
import { onMounted, ref, watch } from 'vue';
import { mapApiToSongs, type Song } from '../script/types'
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ActiveSearchList from '../components/ActiveSearchList.vue'
import { useRoute, useRouter } from 'vue-router';
import SongItem from '../components/SongItem.vue'
import { useAudioStore } from '@/stores/audioStore';
import { useAudio } from '@/composables/useAudio'
import { useUser } from '@/composables/useUser'
import { MusicBackendApi } from '@/generated'
import { useApi } from '@/composables/useApi'
const router = useRouter();
const route = useRoute();
const audioStore = useAudioStore();
const userStore = useUserStore();
const activesongs = ref<Song[]>([]);
const songs = ref<Song[]>([]);
const artists = ref<string[]>([]);
const showSearch = ref(false);
const searchTerm = ref('');
const router = useRouter()
const route = useRoute()
const audioStore = useAudio()
const userStore = useUser()
const { musicApi } = useApi()
const api = musicApi()
const activesongs = ref<Song[]>([])
const songs = ref<Song[]>([])
const artists = ref<string[]>([])
const showSearch = ref(false)
const searchTerm = ref('')
async function fetchActiveSearch(term: string) {
const response = await api.musicBackendSearch(term);
const songData = mapApiToSongs(response.data.songs)
songData.forEach((song: Song) => {
song.previewimage = `${userStore.baseUrl.value}/api/v1/images/${song.previewimage}`
song.url = `${userStore.baseUrl.value}/api/v1/audio/${song.url}`
})
activesongs.value = songData
if (response.data.artist) artists.value = [response.data.artist]
audioStore.setCollection(songData)
showSearch.value = true
searchTerm.value = term
router.replace({ query: { s: term } })
}
async function fetchSearchArtist(artist: string) {
const data = await musicApi.MusicBackend_SearchArtists(artist)
data.forEach((song: Song) => {
song.previewimage = `${userStore.baseUrl.value}/api/v1/images/${song.previewimage}`
song.url = `${userStore.baseUrl.value}/api/v1/audio/${song.url}`
})
songs.value = data
showSearch.value = false
}
async function emptySearch() {
activesongs.value = []
artists.value = []
songs.value = []
showSearch.value = false
searchTerm.value = ''
router.replace({ query: {} })
}
onMounted(async () => {
await loadartistifexist();
const container = document.querySelector('.search') as HTMLInputElement;
if (container) {
container.addEventListener('input', async (event: Event) => {
showSearch.value = true;
const target = event.target as HTMLInputElement;
if (target.value != undefined && target.value != "") {
const data = await userStore.fetchActiveSearch(target.value)
router.push({ query: { s: target.value } });
searchTerm.value = target.value
data.songs.forEach(song => {
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
});
activesongs.value = data.songs;
audioStore.setCollection(data.songs)
artists.value = data.artist;
} else {
activesongs.value = [];
artists.value = [];
showSearch.value = false;
router.push({ query: { s: target.value } });
}
});
container.addEventListener('enter', async (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.value != undefined && target.value != "") {
showSearch.value = false
const data = await userStore.fetchActiveSearch(target.value)
}
})
if (route.query.a) {
await fetchSearchArtist(route.query.a as string)
}
const s = route.query.s as string;
if (s) { container.value = s; container.dispatchEvent(new Event('input')) }
});
async function loadartistifexist() {
const query = route.query.a as string;
if (query) {
showSearch.value = false;
const data = await userStore.fetchSearchArtist(query)
console.log(data);
data.forEach(song => {
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
});
songs.value = data;
if (route.query.s) {
await fetchActiveSearch(route.query.s as string)
}
}
})
function emptySearch() {
const container = document.querySelector('.search') as HTMLInputElement;
// Watch for artist query changes
watch(() => route.query.a, async (newArtist) => {
if (newArtist) {
await fetchSearchArtist(newArtist as string)
} else {
songs.value = []
}
})
container.value = "";
container.dispatchEvent(new Event('input'))
songs.value = [];
artists.value = [];
}
watch(() => route.query.a, async (newQuery) => {
await loadartistifexist();
});
// Search input model
const searchInput = ref(searchTerm.value)
watch(searchInput, async (val) => {
if (val && val.trim() !== '') {
await fetchActiveSearch(val)
} else {
showSearch.value = false
activesongs.value = []
artists.value = []
router.replace({ query: {} })
}
})
</script>
<template>
<header>
<div class="wrapper">
<nav class="flex justify-start my-2 mx-1 space-x-1">
<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>
<h1 class="absolute left-0 right-0 text-center"> Search </h1>
<h1 class="absolute left-0 right-0 text-center">Search</h1>
</nav>
<hr>
<hr />
</div>
</header>
<main class="flex flex-col flex-1 flex-col w-full h-full overflow-scroll">
<input placeholder="Type to Search..."
class="flex-1 max-h-12 search border bordercolor accent-pink-800 bg-yellow-300 bg-opacity-20 rounded-lg m-2 p-2" />
<div class="absolute h-16 right-4 flex flex-col justify-center">
<i @click="emptySearch" class="far fa-times-circle opacity-50"></i>
<main class="flex flex-col flex-1 w-full h-full overflow-scroll">
<div class="relative">
<input
v-model="searchInput"
placeholder="Type to Search..."
class="flex-1 max-h-12 search border bordercolor accent-pink-800 bg-yellow-300 bg-opacity-20 rounded-lg m-2 p-2"
/>
<div class="absolute h-16 right-4 flex flex-col justify-center cursor-pointer" @click="emptySearch">
<i class="far fa-times-circle opacity-50"></i>
</div>
</div>
<div class="relative flex flex-col w-full h-full overflow-scroll">
<div v-if="showSearch" class="absolute w-full text-center search-recommendations z-20">
<div v-if="showSearch" class="absolute w-full text-center search-recommendations z-20">
<ActiveSearchList :songs="activesongs" :artist="artists" :search="searchTerm" />
</div>
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />