21 Commits

Author SHA1 Message Date
wanji
4a34922552 pass blurhash to image loader 2025-12-17 11:14:07 +03:00
wanji
84bbafea73 fix image height 2025-12-17 11:08:38 +03:00
wanji
768df2daf4 blurhash draft 2 2025-12-17 10:51:14 +03:00
wanji
736134a1d5 blurhash draft 1 2025-12-16 11:37:35 +03:00
wanji
f7080d3adf fix statitem album url construction 2025-12-13 04:13:57 +03:00
wanji
a39d7a4c2f fix card size mismatch on first load in album/artist list views 2025-12-13 04:06:45 +03:00
wanji
275f00548a fix bottom bar animation direction
+ animate np image
2025-12-13 03:56:58 +03:00
wanji
1d34001369 fix right sidebar paddings and margins 2025-12-12 03:27:01 +03:00
wanji
0f51584cbb fix now playing mobile responsiveness 2025-12-08 17:20:57 +03:00
wanji
f5e3468c03 fix now playing and lyrics screen
+ new lyrics screen layout
+ fix bottom bar icons
2025-12-08 12:53:13 +03:00
wanji
684a2d6261 move np home into a component 2025-12-02 05:31:19 +03:00
wanji
7543e59b65 use original image in np screen 2025-12-02 04:49:20 +03:00
wanji
78f152d8e4 gradient revision 2 + animated color transition 2025-11-30 10:17:03 +03:00
wanji
da7f5bae7d new gradient revision 1 2025-11-30 08:07:26 +03:00
wanji
c5293a94e5 center np image 2025-11-30 06:36:37 +03:00
wanji
e3866a6ccc create blur + image + gradient bg on now playing 2025-11-28 18:46:51 +03:00
wanji
64462e24be fix: favorite albums loading only 50 items 2025-11-16 12:48:42 +03:00
wanji
9af7e6eaa6 add article aware sorting to settings
new: enable sorting tracks by filename in folder view
2025-10-20 19:38:56 +03:00
cwilvx
a4cb04d261 reuse rootdirs component in settings 2025-10-07 20:01:31 +03:00
cwilvx
6fd93a759d add finish component
- use sse to show progress in finish compoent
- note: review file list for changes
2025-09-20 16:48:45 +03:00
cwilvx
60e557aefd create initial onboarding draft 2025-09-13 03:25:24 +03:00
85 changed files with 3897 additions and 1171 deletions

View File

@@ -17,6 +17,7 @@
"@vueuse/integrations": "^9.2.0",
"@vueuse/motion": "^2.0.0",
"axios": "^0.26.1",
"blurhash": "^2.0.5",
"fuse.js": "^6.6.2",
"motion": "^10.15.5",
"node-vibrant": "3.1.6",

View File

@@ -0,0 +1,208 @@
let eventSource = null
let reconnectTimer = null
let reconnectDelay = 1000
let maxReconnectDelay = 30000
let reconnectAttempts = 0
let maxReconnectAttempts = 10
let isConnected = false
const is_dev = location.port === '5173'
const protocol = location.protocol.replace(':', '')
const base_url = is_dev ? `${protocol}://${location.hostname}:1980` : location.origin
const sse_url = base_url + '/events/stream'
function connect() {
console.log('connect called')
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
return
}
try {
eventSource = new EventSource(sse_url, {
withCredentials: true,
})
console.log('eventSource created')
eventSource.onopen = function (event) {
isConnected = true
reconnectAttempts = 0
reconnectDelay = 1000
postMessage({
type: 'connection',
status: 'connected',
timestamp: Date.now(),
})
}
eventSource.onmessage = function (event) {
try {
postMessage({
type: 'message',
data: JSON.parse(event.data),
timestamp: Date.now(),
})
} catch (error) {
postMessage({
type: 'error',
error: 'Failed to parse message data',
rawData: event.data,
timestamp: Date.now(),
})
}
}
eventSource.onerror = function (event) {
isConnected = false
postMessage({
type: 'connection',
status: 'error',
readyState: eventSource.readyState,
timestamp: Date.now(),
})
if (eventSource.readyState === EventSource.CLOSED) {
scheduleReconnect()
}
}
} catch (error) {
console.log('Failed to create EventSource', error)
postMessage({
type: 'error',
error: 'Failed to create EventSource',
message: error.message,
timestamp: Date.now(),
})
scheduleReconnect()
}
}
function scheduleReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
}
if (reconnectAttempts >= maxReconnectAttempts) {
postMessage({
type: 'connection',
status: 'max_reconnect_attempts_reached',
attempts: reconnectAttempts,
timestamp: Date.now(),
})
return
}
postMessage({
type: 'connection',
status: 'reconnecting',
delay: reconnectDelay,
attempt: reconnectAttempts + 1,
timestamp: Date.now(),
})
reconnectTimer = setTimeout(() => {
reconnectAttempts++
connect()
reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay)
}, reconnectDelay)
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (eventSource) {
eventSource.close()
eventSource = null
}
isConnected = false
reconnectAttempts = 0
reconnectDelay = 1000
postMessage({
type: 'connection',
status: 'disconnected',
timestamp: Date.now(),
})
}
function getConnectionStatus() {
postMessage({
type: 'status',
connected: isConnected,
readyState: eventSource ? eventSource.readyState : null,
reconnectAttempts: reconnectAttempts,
timestamp: Date.now(),
})
}
onmessage = function (e) {
const { command, options } = e.data
console.log('command', command)
console.log('options', options)
switch (command) {
case 'connect':
if (options && options.maxReconnectAttempts !== undefined) {
maxReconnectAttempts = options.maxReconnectAttempts
}
if (options && options.reconnectDelay !== undefined) {
reconnectDelay = options.reconnectDelay
}
if (options && options.maxReconnectDelay !== undefined) {
maxReconnectDelay = options.maxReconnectDelay
}
connect()
break
case 'disconnect':
disconnect()
break
case 'status':
getConnectionStatus()
break
case 'retry':
if (!isConnected) {
reconnectAttempts = 0
reconnectDelay = 1000
connect()
}
break
default:
postMessage({
type: 'error',
error: 'Unknown command',
command: command,
timestamp: Date.now(),
})
}
}
self.addEventListener('online', function () {
postMessage({
type: 'network',
status: 'online',
timestamp: Date.now(),
})
if (!isConnected) {
reconnectAttempts = 0
reconnectDelay = 1000
connect()
}
})
self.addEventListener('offline', function () {
postMessage({
type: 'network',
status: 'offline',
timestamp: Date.now(),
})
})

View File

@@ -1,9 +1,10 @@
<template>
<ContextMenu />
<Modal />
<Notification />
<ContextMenu v-if="!hideUI" />
<Modal v-if="!hideUI" />
<Notification v-if="!hideUI" />
<div id="drag-img" class="ellip2" style=""></div>
<section
v-if="!hideUI"
id="app-grid"
:class="{
useSidebar: settings.use_sidebar && xl,
@@ -27,153 +28,253 @@
<BottomBar />
<!-- <BubbleManager /> -->
</section>
<div v-else id="noui">
<BalancerProvider>
<RouterView />
</BalancerProvider>
</div>
</template>
<script setup lang="ts">
// @libraries
import { vElementSize } from "@vueuse/components";
import { onStartTyping } from "@vueuse/core";
import { onMounted, Ref, ref } from "vue";
import { useRouter } from "vue-router";
import { BalancerProvider } from "vue-wrap-balancer";
import { vElementSize } from '@vueuse/components'
import { onStartTyping } from '@vueuse/core'
import { onBeforeMount, onMounted, Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { BalancerProvider } from 'vue-wrap-balancer'
// @stores
import useAuth from "@/stores/auth";
import { content_height, content_width, isMobile, resizer_width, updateCardWidth } from "@/stores/content-width";
import useLyrics from "@/stores/lyrics";
import useModal from "@/stores/modal";
import useQueue from "@/stores/queue";
import useSettings from "@/stores/settings";
import useTracker from "@/stores/tracker";
import useAuth from '@/stores/auth'
import { content_height, content_width, isMobile, resizer_width, updateCardWidth } from '@/stores/content-width'
import useLyrics from '@/stores/lyrics'
import useModal from '@/stores/modal'
import useQueue from '@/stores/queue'
import useSettings from '@/stores/settings'
import useTracker from '@/stores/tracker'
import useInterface from '@/stores/interface'
// @utils
import handleShortcuts from "@/helpers/useKeyboard";
import { xl, xxl } from "./composables/useBreakpoints";
import handleShortcuts from '@/helpers/useKeyboard'
import { xl, xxl } from './composables/useBreakpoints'
// @small-components
import ContextMenu from "@/components/ContextMenu.vue";
import Modal from "@/components/modal.vue";
import Notification from "@/components/Notification.vue";
import ContextMenu from '@/components/ContextMenu.vue'
import Modal from '@/components/modal.vue'
import Notification from '@/components/Notification.vue'
// @app-grid-components
import BottomBar from "@/components/BottomBar/BottomBar.vue";
import LeftSidebar from "@/components/LeftSidebar/index.vue";
import NavBar from "@/components/nav/NavBar.vue";
import RightSideBar from "@/components/RightSideBar/Main.vue";
import BottomBar from '@/components/BottomBar/BottomBar.vue'
import LeftSidebar from '@/components/LeftSidebar/index.vue'
import NavBar from '@/components/nav/NavBar.vue'
import RightSideBar from '@/components/RightSideBar/Main.vue'
import { getAllSettings } from "@/requests/settings";
import { getRootDirs } from "@/requests/settings/rootdirs";
import { getLoggedInUser } from "./requests/auth";
import { getAllSettings } from '@/requests/settings'
import { getRootDirs } from '@/requests/settings/rootdirs'
import { getLoggedInUser } from './requests/auth'
// import BubbleManager from "./components/bubbles/BinManager.vue";
const appcontent: Ref<HTMLLegendElement | null> = ref(null);
const auth = useAuth();
const queue = useQueue();
const modal = useModal();
const lyrics = useLyrics();
const router = useRouter();
const settings = useSettings();
useTracker();
const appcontent: Ref<HTMLLegendElement | null> = ref(null)
const auth = useAuth()
const queue = useQueue()
const modal = useModal()
const lyrics = useLyrics()
const router = useRouter()
const route = useRoute()
const settings = useSettings()
const UIStore = useInterface()
const { hideUI } = storeToRefs(UIStore)
useTracker()
handleShortcuts(useQueue, useModal);
handleShortcuts(useQueue, useModal)
router.afterEach(() => {
(document.getElementById("acontent") as HTMLElement).scrollTo(0, 0);
});
const acontent = document.getElementById('acontent') as HTMLElement
if (acontent) {
acontent.scrollTo(0, 0)
}
})
onStartTyping(() => {
const elem = document.getElementById("globalsearch") as HTMLInputElement;
elem.focus();
elem.value = "";
});
const elem = document.getElementById('globalsearch') as HTMLInputElement
elem.focus()
elem.value = ''
})
function getContentSize() {
const elem = document.getElementById("acontent") as HTMLElement;
const elem = document.getElementById('acontent') as HTMLElement
return {
width: elem.offsetWidth,
height: elem.offsetHeight,
};
}
}
function updateContentElemSize({ width, height }: { width: number; height: number }) {
// 1572 is the maxwidth of the #acontent. see app-grid.scss > $maxwidth
const elem_width = appcontent.value?.offsetWidth || 0;
const elem_width = appcontent.value?.offsetWidth || 0
content_width.value = elem_width;
content_height.value = height;
content_width.value = elem_width
content_height.value = height
resizer_width.value = elem_width;
updateCardWidth();
resizer_width.value = elem_width
updateCardWidth()
}
function handleRootDirsPrompt() {
getRootDirs().then(dirs => {
if (dirs.length === 0) {
modal.showRootDirsPromptModal();
modal.showRootDirsPromptModal()
} else {
settings.setRootDirs(dirs);
settings.setRootDirs(dirs)
}
});
})
}
const getCookieValue = (name: string) => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || ''
onMounted(async () => {
const { width, height } = getContentSize();
updateContentElemSize({ width, height });
if (import.meta.env.DEV) {
const onBoardingData = await useAxios({
url: paths.api.onboardingData,
method: 'GET',
})
const res = await getLoggedInUser();
if (onBoardingData.status !== 200) {
// INFO: What should we do 😱?
return
}
if (res.status == 200) {
auth.setUser(res.data);
} else {
return;
const { adminExists, rootDirsSet, onboardingComplete } = onBoardingData.data
if (!onboardingComplete) {
UIStore.setHideUi(true)
if (!rootDirsSet) {
UIStore.setOnboardingStep(2)
}
if (!adminExists) {
UIStore.setOnboardingStep(0)
}
router.push({
name: Routes.Onboarding,
})
}
}
settings.initializeVolume();
if (UIStore.hideUI) {
// console.log('waiting for onboarding complete')
// return
await Waiter.wait(Waiter.keys.ONBOARDING_COMPLETE, null)
}
handleRootDirsPrompt();
let path: string = ''
const splits = window.location.href.split('#')
if (splits.length > 1) {
path = splits[1]
}
// INFO: If we are stuck on the onboarding page at this point,
// redirect to the home page
if (path === '/onboarding') {
return router.push({
name: Routes.Home,
})
}
const { width, height } = getContentSize()
updateContentElemSize({ width, height })
const res = await getLoggedInUser()
if (res.status == 200) {
auth.setUser(res.data)
} else {
return
}
settings.initializeVolume()
handleRootDirsPrompt()
getAllSettings()
.then(({ settings: data }) => {
settings.mapDbSettings(data);
settings.mapDbSettings(data)
})
.then(() => {
if (queue.currenttrack && !settings.use_lyrics_plugin) {
lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash);
lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash)
}
});
});
})
})
onBeforeMount(async () => {
if (import.meta.env.DEV) {
return
}
const onboardingComplete = getCookieValue('x-onboarding-complete')
if (!onboardingComplete || onboardingComplete == 'true') {
return
}
UIStore.setHideUi(true)
const adminExists = getCookieValue('x-admin-exists')
const rootDirsSet = getCookieValue('x-root-dirs-set')
if (rootDirsSet == 'false') {
UIStore.setOnboardingStep(2)
}
if (adminExists == 'false') {
UIStore.setOnboardingStep(0)
}
return router.push({
name: Routes.Onboarding,
})
})
</script>
<script lang="ts">
// Detect OS & browser agents and add class
import { defineComponent } from "vue";
import usePlayer from "./composables/usePlayer";
import { defineComponent } from 'vue'
import { Routes } from './router'
import { storeToRefs } from 'pinia'
import useAxios from './requests/useAxios'
import { paths } from './config'
import { Waiter } from './composables/waiter'
export default defineComponent({
name: "OsAndBrowserSpecificContent",
name: 'OsAndBrowserSpecificContent',
mounted() {
this.applyClassBasedOnAgent();
this.applyClassBasedOnAgent()
},
methods: {
applyClassBasedOnAgent() {
const userAgent = navigator.userAgent;
const isWindows = /Win/.test(userAgent);
const isLinux = /Linux/.test(userAgent) && !/Android/.test(userAgent);
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor);
const userAgent = navigator.userAgent
const isWindows = /Win/.test(userAgent)
const isLinux = /Linux/.test(userAgent) && !/Android/.test(userAgent)
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor)
if ((isWindows || isLinux) && isChrome) {
document.documentElement.classList.add("designatedOS");
document.documentElement.classList.add('designatedOS')
} else {
document.documentElement.classList.add("otherOS");
document.documentElement.classList.add('otherOS')
}
},
},
});
})
</script>
<style lang="scss">
@import "./assets/scss/mixins.scss";
@import './assets/scss/mixins.scss';
.designatedOS .r-sidebar {
&::-webkit-scrollbar {
display: none;
}
}
#noui {
height: 100vh;
width: 100vw;
}
</style>

4
src/assets/icons/add.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M14.105 26.21C20.7369 26.21 26.2121 20.7273 26.2121 14.105C26.2121 7.47312 20.7273 2 14.0954 2C7.47523 2 2 7.47312 2 14.105C2 20.7273 7.48484 26.21 14.105 26.21ZM14.105 23.8255C8.71085 23.8255 4.39412 19.4991 4.39412 14.105C4.39412 8.71085 8.70124 4.38452 14.0954 4.38452C19.4895 4.38452 23.8276 8.71085 23.8276 14.105C23.8276 19.4991 19.4991 23.8255 14.105 23.8255Z" fill="currentColor"/>
<path d="M8.68359 14.1029C8.68359 14.7383 9.13265 15.1819 9.78304 15.1819H12.9963V18.4048C12.9963 19.0456 13.4496 19.5043 14.085 19.5043C14.7321 19.5043 15.1929 19.0552 15.1929 18.4048V15.1819H18.4179C19.0586 15.1819 19.5152 14.7383 19.5152 14.1029C19.5152 13.4558 19.0586 12.995 18.4179 12.995H15.1929V9.78167C15.1929 9.12917 14.7321 8.67261 14.085 8.67261C13.4496 8.67261 12.9963 9.12917 12.9963 9.78167V12.995H9.78304C9.13265 12.995 8.68359 13.4558 8.68359 14.1029Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

View File

@@ -1,4 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M6.84421 24.8972H21.0295C23.5685 24.8972 24.8737 23.5919 24.8737 21.0914V6.82921C24.8737 4.32656 23.5685 3.02344 21.0295 3.02344H6.84421C4.31484 3.02344 3 4.31695 3 6.82921V21.0914C3 23.6016 4.31484 24.8972 6.84421 24.8972Z" fill="white"/>
<path d="M12.6617 19.7301C12.219 19.7301 11.8571 19.5387 11.5314 19.1137L8.65744 15.6204C8.45002 15.3523 8.34033 15.0818 8.34033 14.7848C8.34033 14.1879 8.81588 13.7037 9.42033 13.7037C9.7822 13.7037 10.0611 13.8281 10.3646 14.2148L12.6212 17.0827L17.4669 9.32416C17.7233 8.92409 18.0554 8.71338 18.4225 8.71338C18.9981 8.71338 19.5376 9.12401 19.5376 9.73807C19.5376 10.0085 19.4033 10.2949 19.232 10.5642L13.7474 19.1053C13.4802 19.5152 13.0982 19.7301 12.6617 19.7301Z" fill="blue"/>
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M6.84421 24.8972H21.0295C23.5685 24.8972 24.8737 23.5919 24.8737 21.0914V6.82921C24.8737 4.32656 23.5685 3.02344 21.0295 3.02344H6.84421C4.31484 3.02344 3 4.31695 3 6.82921V21.0914C3 23.6016 4.31484 24.8972 6.84421 24.8972Z" fill="transparent"/>
<path d="M12.6617 19.7301C12.219 19.7301 11.8571 19.5387 11.5314 19.1137L8.65744 15.6204C8.45002 15.3523 8.34033 15.0818 8.34033 14.7848C8.34033 14.1879 8.81588 13.7037 9.42033 13.7037C9.7822 13.7037 10.0611 13.8281 10.3646 14.2148L12.6212 17.0827L17.4669 9.32416C17.7233 8.92409 18.0554 8.71338 18.4225 8.71338C18.9981 8.71338 19.5376 9.12401 19.5376 9.73807C19.5376 10.0085 19.4033 10.2949 19.232 10.5642L13.7474 19.1053C13.4802 19.5152 13.0982 19.7301 12.6617 19.7301Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 839 B

View File

@@ -1,4 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.20702 14.7471C2.90241 14.7471 3.40983 14.2376 3.40983 13.5401V12.4964C3.40983 10.466 4.78303 9.16615 6.93787 9.16615H13.1636V11.8715C13.1636 12.431 13.5018 12.7617 14.0666 12.7617C14.3219 12.7617 14.556 12.667 14.7508 12.5095L19.3471 8.67702C19.7929 8.30999 19.7983 7.74632 19.3471 7.36968L14.7508 3.52969C14.556 3.3743 14.3219 3.28711 14.0666 3.28711C13.5018 3.28711 13.1636 3.6082 13.1636 4.17937V6.81375H7.12115C3.38804 6.81375 1 8.88468 1 12.263V13.5401C1 14.2354 1.50203 14.7471 2.20702 14.7471ZM25.5676 13.6303C24.8626 13.6303 24.3627 14.1281 24.3627 14.8373V15.881C24.3627 17.9114 22.9916 19.1995 20.8251 19.1995H11.7646V16.5187C11.7646 15.9476 11.436 15.6265 10.8712 15.6265C10.6063 15.6265 10.3722 15.7115 10.1795 15.869L5.58109 19.7015C5.14703 20.0803 5.13953 20.644 5.58109 21.0089L10.1795 24.8489C10.3722 25.0064 10.6063 25.1011 10.8712 25.1011C11.436 25.1011 11.7646 24.7704 11.7646 24.2109V21.5636H20.6418C24.3845 21.5636 26.7725 19.4906 26.7725 16.1144V14.8373C26.7725 14.1323 26.263 13.6303 25.5676 13.6303Z" fill="white"/>
<path d="M25.6006 11.2024C26.3485 11.2024 26.8133 10.7639 26.8133 9.9621V4.4632C26.8133 3.58312 26.2154 3 25.3663 3C24.6831 3 24.2448 3.22242 23.7137 3.62695L22.1455 4.81172C21.8214 5.06156 21.7236 5.26898 21.7236 5.56031C21.7236 5.99624 22.048 6.31007 22.5151 6.31007C22.7256 6.31007 22.9065 6.25264 23.0799 6.10897L24.2621 5.16983H24.373V9.9621C24.373 10.7639 24.8474 11.2024 25.6006 11.2024Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.20702 14.46C2.88131 14.46 3.40983 13.9294 3.40983 13.253V12.2093C3.40983 10.1789 4.78303 8.87904 6.93787 8.87904H15.9983V11.5844C15.9983 12.1439 16.3365 12.4746 16.9014 12.4746C17.1566 12.4746 17.4004 12.3799 17.5855 12.2224L22.1818 8.38991C22.6159 8.03249 22.633 7.45921 22.1818 7.08257L17.5855 3.24258C17.4004 3.08719 17.1566 3 16.9014 3C16.3365 3 15.9983 3.32109 15.9983 3.89226V6.52664H7.12115C3.38804 6.52664 1 8.59757 1 11.9758V13.253C1 13.9294 1.52101 14.46 2.20702 14.46ZM25.5676 13.3432C24.8816 13.3432 24.3627 13.8642 24.3627 14.5502V15.5939C24.3627 17.6243 22.9916 18.9124 20.8251 18.9124H11.7646V16.2316C11.7646 15.6605 11.436 15.3394 10.8712 15.3394C10.6063 15.3394 10.3722 15.4244 10.1795 15.5819L5.58109 19.4144C5.14703 19.7932 5.13953 20.3568 5.58109 20.7218L10.1795 24.5618C10.3722 24.7193 10.6063 24.8139 10.8712 24.8139C11.436 24.8139 11.7646 24.4832 11.7646 23.9238V21.2765H20.6418C24.3845 21.2765 26.7725 19.2035 26.7725 15.8273V14.5502C26.7725 13.8642 26.2419 13.3432 25.5676 13.3432Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5498 18.3389C3.5498 18.8486 3.94531 19.209 4.49023 19.209H6.55566C8.06738 19.209 8.95508 18.7695 9.99219 17.5479L12.1191 15.0166L14.2197 17.5039C15.2832 18.7695 16.2852 19.209 17.7969 19.209H19.5547V21.3271C19.5547 21.749 19.8096 21.9951 20.2314 21.9951C20.4248 21.9951 20.6006 21.9248 20.75 21.8105L24.1953 18.9365C24.5293 18.6641 24.5293 18.2422 24.1953 17.9521L20.75 15.0781C20.6006 14.9551 20.4248 14.8936 20.2314 14.8936C19.8096 14.8936 19.5547 15.1309 19.5547 15.5615V17.46H17.8496C16.8125 17.46 16.1357 17.1172 15.3887 16.2207L13.2441 13.6807L15.3975 11.1318C16.1621 10.2266 16.7773 9.90137 17.7969 9.90137H19.5547V11.835C19.5547 12.2568 19.8096 12.5029 20.2314 12.5029C20.4248 12.5029 20.6006 12.4326 20.75 12.3184L24.1953 9.44434C24.5293 9.17188 24.5293 8.75 24.1953 8.45996L20.75 5.58594C20.6006 5.46289 20.4248 5.40137 20.2314 5.40137C19.8096 5.40137 19.5547 5.63867 19.5547 6.06934V8.14355H17.8057C16.2412 8.14355 15.2832 8.57422 14.167 9.91016L12.1191 12.3447L9.99219 9.81348C8.95508 8.5918 8.00586 8.15234 6.50293 8.15234H4.49023C3.94531 8.15234 3.5498 8.5127 3.5498 9.02246C3.5498 9.53223 3.94531 9.90137 4.49023 9.90137H6.43262C7.41699 9.90137 8.10254 10.2441 8.8584 11.1406L10.9941 13.6807L8.8584 16.2207C8.10254 17.1172 7.46973 17.46 6.49414 17.46H4.49023C3.94531 17.46 3.5498 17.8291 3.5498 18.3389Z" fill="#F2F2F2"/>
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5498 18.3389C3.5498 18.8486 3.94531 19.209 4.49023 19.209H6.55566C8.06738 19.209 8.95508 18.7695 9.99219 17.5479L12.1191 15.0166L14.2197 17.5039C15.2832 18.7695 16.2852 19.209 17.7969 19.209H19.5547V21.3271C19.5547 21.749 19.8096 21.9951 20.2314 21.9951C20.4248 21.9951 20.6006 21.9248 20.75 21.8105L24.1953 18.9365C24.5293 18.6641 24.5293 18.2422 24.1953 17.9521L20.75 15.0781C20.6006 14.9551 20.4248 14.8936 20.2314 14.8936C19.8096 14.8936 19.5547 15.1309 19.5547 15.5615V17.46H17.8496C16.8125 17.46 16.1357 17.1172 15.3887 16.2207L13.2441 13.6807L15.3975 11.1318C16.1621 10.2266 16.7773 9.90137 17.7969 9.90137H19.5547V11.835C19.5547 12.2568 19.8096 12.5029 20.2314 12.5029C20.4248 12.5029 20.6006 12.4326 20.75 12.3184L24.1953 9.44434C24.5293 9.17188 24.5293 8.75 24.1953 8.45996L20.75 5.58594C20.6006 5.46289 20.4248 5.40137 20.2314 5.40137C19.8096 5.40137 19.5547 5.63867 19.5547 6.06934V8.14355H17.8057C16.2412 8.14355 15.2832 8.57422 14.167 9.91016L12.1191 12.3447L9.99219 9.81348C8.95508 8.5918 8.00586 8.15234 6.50293 8.15234H4.49023C3.94531 8.15234 3.5498 8.5127 3.5498 9.02246C3.5498 9.53223 3.94531 9.90137 4.49023 9.90137H6.43262C7.41699 9.90137 8.10254 10.2441 8.8584 11.1406L10.9941 13.6807L8.8584 16.2207C8.10254 17.1172 7.46973 17.46 6.49414 17.46H4.49023C3.94531 17.46 3.5498 17.8291 3.5498 18.3389Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,4 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8205 24.9747C13.6952 24.9747 14.3304 24.3395 14.3304 23.4744V5.57171C14.3304 4.70664 13.6952 4.01172 12.8013 4.01172C12.2041 4.01172 11.7881 4.26625 11.1462 4.86883L6.23296 9.4593C6.16476 9.52539 6.06984 9.56055 5.9632 9.56055H2.64538C0.927185 9.56055 0 10.5175 0 12.3273V16.6783C0 18.4977 0.927185 19.4472 2.64538 19.4472H5.96109C6.06773 19.4472 6.16265 19.476 6.23085 19.5421L11.1462 24.1773C11.7241 24.7351 12.2149 24.9747 12.8205 24.9747Z" fill="currentColor"/>
<path d="M18.5131 19.6804C18.9969 20.0005 19.6489 19.8932 20.0075 19.3792C20.952 18.1218 21.5122 16.3335 21.5122 14.4815C21.5122 12.6295 20.952 10.8508 20.0075 9.57953C19.6489 9.06977 18.9969 8.95071 18.5131 9.28258C17.942 9.65711 17.84 10.3464 18.2877 11.0019C18.9357 11.9226 19.3013 13.1748 19.3013 14.4815C19.3013 15.7881 18.924 17.0287 18.2877 17.961C17.8496 18.6241 17.942 19.2962 18.5131 19.6804Z" fill="currentColor"/>
<path d="M23.1864 22.8128C23.7128 23.1479 24.3615 23.0193 24.7264 22.4875C26.2517 20.3322 27.137 17.4477 27.137 14.4815C27.137 11.5152 26.2613 8.61156 24.7264 6.47336C24.3615 5.94368 23.7128 5.81501 23.1864 6.15016C22.6346 6.49797 22.5559 7.18375 22.9611 7.79077C24.2089 9.61023 24.9282 12.0065 24.9282 14.4815C24.9282 16.9565 24.1897 19.3314 22.9611 21.1701C22.5655 21.7771 22.6346 22.465 23.1864 22.8128Z" fill="currentColor"/>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M14.105 26.21C20.7369 26.21 26.2121 20.7273 26.2121 14.105C26.2121 7.47312 20.7273 2 14.0954 2C7.47523 2 2 7.47312 2 14.105C2 20.7273 7.48484 26.21 14.105 26.21ZM14.105 23.8255C8.71085 23.8255 4.39412 19.4991 4.39412 14.105C4.39412 8.71085 8.70124 4.38452 14.0954 4.38452C19.4895 4.38452 23.8276 8.71085 23.8276 14.105C23.8276 19.4991 19.4991 23.8255 14.105 23.8255Z" fill="currentColor"/>
<path d="M9.64882 15.215H18.5527C19.2532 15.215 19.7386 14.8023 19.7386 14.1263C19.7386 13.4408 19.2725 13.0184 18.5527 13.0184H9.64882C8.92906 13.0184 8.45117 13.4408 8.45117 14.1263C8.45117 14.8023 8.94828 15.215 9.64882 15.215Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -1,4 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M5.58773 19.4704H8.91726C9.01218 19.4704 9.09749 19.4992 9.17531 19.5695L14.3234 24.1773C14.9227 24.7213 15.4017 24.9747 16.0073 24.9747C16.8724 24.9747 17.5172 24.3395 17.5172 23.4744V5.57171C17.5172 4.70664 16.8724 4.01172 15.9881 4.01172C15.3909 4.01172 14.977 4.28008 14.3234 4.86883L9.17531 9.4361C9.09538 9.5043 9.01218 9.53524 8.91726 9.53524H5.58773C3.86953 9.53524 3 10.4366 3 12.2561V16.7612C3 18.5786 3.87914 19.4704 5.58773 19.4704ZM5.78366 17.4072C5.39647 17.4072 5.20991 17.2185 5.20991 16.8313V12.1839C5.20991 11.7892 5.39647 11.6005 5.78366 11.6005H9.44929C9.76663 11.6005 10.0073 11.5356 10.2769 11.2895L14.9695 7.01803C15.0239 6.96154 15.0825 6.9306 15.1624 6.9306C15.2466 6.9306 15.319 6.99131 15.319 7.09888V21.88C15.319 21.9876 15.2466 22.0621 15.1624 22.0621C15.1017 22.0621 15.0335 22.027 14.9695 21.9705L10.2769 17.7182C10.0073 17.4796 9.76663 17.4072 9.44929 17.4072H5.78366Z" fill="currentColor"/>
<path d="M21.5033 19.69C21.9871 20.0122 22.6391 19.9028 22.9977 19.3888C23.9401 18.1314 24.5024 16.3431 24.5024 14.4911C24.5024 12.6391 23.9401 10.8604 22.9977 9.58914C22.6391 9.07938 21.9871 8.96032 21.5033 9.29219C20.9322 9.66672 20.8302 10.356 21.2757 11.0116C21.9238 11.9322 22.2915 13.1845 22.2915 14.4911C22.2915 15.7977 21.9142 17.0383 21.2757 17.9706C20.8398 18.6358 20.9322 19.3058 21.5033 19.69Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,4 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M7.94572 18.8783H11.2699C11.369 18.8783 11.4522 18.9113 11.5279 18.9774L16.678 23.5853C17.2965 24.1292 17.7564 24.3826 18.3716 24.3826C18.9193 24.3826 19.3724 24.1147 19.6525 23.6551L17.7489 21.7611L12.5697 17.0856C12.329 16.8683 12.1694 16.8151 11.8499 16.8151H8.14376C7.74486 16.8151 7.55829 16.6264 7.55829 16.2392V11.5609L5.86096 9.86566C5.53448 10.2765 5.34839 10.8756 5.34839 11.664V16.1692C5.34839 17.9865 6.22752 18.8783 7.94572 18.8783ZM19.8751 16.7612L19.8772 4.97964C19.8772 4.11456 19.2367 3.41964 18.3524 3.41964C17.7456 3.41964 17.335 3.688 16.6717 4.27675L11.7637 8.6359L13.2475 10.1251L17.3179 6.42595C17.3744 6.36946 17.4426 6.33853 17.5108 6.33853C17.597 6.33853 17.6673 6.39923 17.6673 6.50681V14.5492L19.8751 16.7612Z" fill="currentColor"/>
<path d="M23.7703 25.5182C24.1345 25.8803 24.7394 25.8824 25.0898 25.5182C25.4519 25.1464 25.454 24.5628 25.0898 24.2007L4.60336 3.71426C4.23914 3.35004 3.63422 3.35004 3.27 3.71426C2.91 4.06676 2.91 4.68129 3.27 5.03379L23.7703 25.5182Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -149,12 +149,6 @@ $g-border: solid 1px $gray5;
padding-bottom: 2rem;
}
#lyricscontent {
padding-top: 0;
padding-left: 2rem;
padding-right: 2rem;
}
@media only screen and (min-width: 1980px) {
// NOTE: Styles for 1680px and below
$alt_layout_pad: max(2rem, calc((100% - 1680px) / 2));

View File

@@ -1,275 +1,279 @@
input[type="search"]::-webkit-search-cancel-button {
display: none;
input[type='search']::-webkit-search-cancel-button {
display: none;
}
// TEXT
.t-center {
text-align: center;
text-align: center;
}
.ellip {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
width: fit-content;
max-width: 100%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
width: fit-content;
max-width: 100%;
}
.ellip2 {
word-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.heading {
font-size: 1.5rem;
font-weight: 700;
font-size: 1.5rem;
font-weight: 700;
}
a {
text-decoration: none;
color: #fff;
text-decoration: none;
color: #fff;
}
.image {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
transition: transform 0.3s ease-in-out;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
transition: transform 0.3s ease-in-out;
}
// BORDERS
.rounded {
border-radius: 1rem;
border-radius: 1rem;
}
.rounded-sm {
border-radius: $small;
border-radius: $small;
}
.rounded-xsm {
border-radius: 4px;
}
.rounded-md {
border-radius: $medium;
border-radius: $medium;
}
.rounded-lg {
border-radius: 1.25rem;
border-radius: 1.25rem;
}
.circular {
border-radius: 10rem;
border-radius: 10rem;
}
.bg-primary {
background-color: $gray4;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.425);
background-color: $gray4;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.425);
}
// BUTTONS
button {
font-family: "SF Compact Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 0.9rem !important;
font-weight: 700;
line-height: 1.2;
color: inherit;
border-radius: $small;
border: none;
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
padding: 0 $small;
transition: background-color 0.2s ease-out, color 0.2s ease-out, border 0.2s ease-out;
font-family: 'SF Compact Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
font-size: 0.9rem !important;
font-weight: 500;
line-height: 1.2;
color: inherit;
border-radius: $small;
border: none;
display: flex;
align-items: center;
justify-content: center;
height: 2rem;
transition: background-color 0.2s ease-out, color 0.2s ease-out, border 0.2s ease-out;
padding: $small 0.85rem;
background-color: $gray4;
cursor: pointer;
background-color: $gray4;
cursor: pointer;
svg {
transition: all 0.2s;
}
&:active {
svg {
transform: scale(0.75);
transition: all 0.2s;
}
}
&:focus {
outline: none;
}
&:active {
svg {
transform: scale(0.75);
}
}
&:hover {
background-color: $darkestblue;
}
&:focus {
outline: none;
}
&:hover {
background-color: $darkestblue;
}
}
.btn-active {
background-color: $darkestblue;
background-color: $darkestblue;
}
.btn-disabled {
pointer-events: none;
opacity: 0.5;
pointer-events: none;
opacity: 0.5;
}
.btn-more {
width: 2.5rem;
width: 2.5rem;
}
// POSITION
.abs {
position: absolute;
position: absolute;
}
// OTHERS
.grid {
display: grid;
display: grid;
}
.flex {
display: flex;
display: flex;
}
.separator {
border-top: 1px $separator solid;
color: transparent;
margin: $small 0 $small 0;
opacity: 0.5;
border-top: 1px $separator solid;
color: transparent;
margin: $small 0 $small 0;
opacity: 0.5;
}
.no-border {
border: none;
border: none;
}
.no-scroll {
overflow: hidden;
overflow: hidden;
}
.no-select {
user-select: none;
user-select: none;
}
.load_disabled {
pointer-events: all;
background: $gray5 !important;
border-color: $gray5 !important;
opacity: 1;
pointer-events: all;
background: $gray5 !important;
border-color: $gray5 !important;
opacity: 1;
}
#drag-img {
width: max-content;
max-width: 15rem;
background-color: $darkblue;
padding: $smaller $small;
border-radius: $smaller;
opacity: 1;
position: absolute;
left: -20rem;
width: max-content;
max-width: 15rem;
background-color: $darkblue;
padding: $smaller $small;
border-radius: $smaller;
opacity: 1;
position: absolute;
left: -20rem;
}
.spinner {
border: solid 3px rgb(221, 217, 217);
border-top: solid 3px transparent;
border-left: solid 3px transparent;
border-radius: 50%;
width: 1.25rem;
height: 1.25rem;
animation: spin 0.45s linear infinite;
border: solid 3px rgb(221, 217, 217);
border-top: solid 3px transparent;
border-left: solid 3px transparent;
border-radius: 50%;
width: 1.25rem;
height: 1.25rem;
animation: spin 0.45s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.card-list-scroll-x {
overflow: hidden;
overflow: hidden;
h3 {
display: grid;
grid-template-columns: 1fr max-content;
align-items: baseline;
padding: 0 $medium;
margin-bottom: $medium;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
overflow-x: auto;
scroll-snap-type: x mandatory;
flex-direction: row;
padding-bottom: 2rem;
@include hideScrollbars;
}
.album-card {
&:hover {
background-color: $gray;
h3 {
display: grid;
grid-template-columns: 1fr max-content;
align-items: baseline;
padding: 0 $medium;
margin-bottom: $medium;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
overflow-x: auto;
scroll-snap-type: x mandatory;
flex-direction: row;
padding-bottom: 2rem;
@include hideScrollbars;
}
.album-card {
&:hover {
background-color: $gray;
}
}
}
}
.rhelp {
text-transform: uppercase;
font-size: 11px;
color: $purple;
font-weight: 700;
margin: $smaller 0;
text-transform: uppercase;
font-size: 11px;
color: $purple;
font-weight: 700;
margin: $smaller 0;
&.album {
color: $orange;
}
&.album {
color: $orange;
}
&.track {
color: $pink;
}
&.track {
color: $pink;
}
&.folder {
color: $teal;
}
&.folder {
color: $teal;
}
&.playlist {
color: $green;
}
&.playlist {
color: $green;
}
&.mix {
color: $lightbrown;
}
&.mix {
color: $lightbrown;
}
}
// Badges used in settings
.badge {
margin-left: $small;
opacity: 0.75;
padding: 0 $smaller;
border-radius: $smaller;
font-size: 12px !important;
margin-left: $small;
opacity: 0.75;
padding: 0 $smaller;
border-radius: $smaller;
font-size: 12px !important;
}
.experimental {
border: solid 1px $yellow;
color: $yellow;
border: solid 1px $yellow;
color: $yellow;
}
.badge.new {
background-color: $blue;
opacity: 1;
background-color: $blue;
opacity: 1;
}
.explicit-icon {
width: 0.9rem;
margin-left: $smaller;
}
width: 0.9rem;
margin-left: $smaller;
}

View File

@@ -27,7 +27,7 @@ $gray2: #636366;
$gray3: #48484a;
$gray4: #3a3a3c;
$gray5: #2c2c2e;
$body: #000;
$body: #000f;
$red: #f7635c;
$blue: #0a84ff;

View File

@@ -17,7 +17,14 @@
}"
class="np-image rounded-sm no-scroll"
>
<img :src="paths.images.thumb.small + queue.currenttrack?.image" alt="" />
<!-- <img :src="paths.images.thumb.small + queue.currenttrack?.image" alt="" /> -->
<ImageLoader
:image="paths.images.thumb.small + queue.currenttrack?.image"
:blurhash="queue.currenttrack.blurhash"
:duration="1000"
img-class="rounded-sm"
style="width: 48px; aspect-ratio: 1;"
/>
<div class="expandicon">
<ExpandSvg />
</div>
@@ -25,15 +32,22 @@
<div
class="track-info"
:style="{
color: getShift(colors.theme1, [0, -170]),
color: getShift(colors.lightVibrant, [0, -170]),
}"
>
<div v-tooltip class="title">
<span class="ellip">
{{ queue.currenttrack?.title || 'Hello there' }}
</span>
<ExplicitIcon class="explicit-icon" v-if="queue.currenttrack?.explicit" />
<MasterFlag :bitrate="queue.currenttrack?.bitrate || 0" />
<TextLoader
:text="queue.currenttrack?.title || 'Hello there'"
:duration="1000"
:fade-duration="1000"
:direction="queue.direction"
/>
<ExplicitIcon
v-if="queue.currenttrack?.explicit"
:key="queue.currenttrack.trackhash"
class="explicit-icon"
/>
<MasterFlag :key="queue.currenttrack?.trackhash" :bitrate="queue.currenttrack?.bitrate || 0" />
</div>
<ArtistName
:artists="queue.currenttrack?.artists || []"
@@ -51,22 +65,24 @@ import { paths } from '@/config'
import { Routes } from '@/router'
import { getShift } from '@/utils/colortools/shift'
import useColorStore from '@/stores/colors'
import { isLargerMobile, isMobile } from '@/stores/content-width'
import useQStore from '@/stores/queue'
import useColorStore from '@/stores/colors'
import useSettingsStore from '@/stores/settings'
import { isLargerMobile, isMobile } from '@/stores/content-width'
import ExpandSvg from '@/assets/icons/expand.svg'
import ArtistName from '@/components/shared/ArtistName.vue'
import HotKeys from '../LeftSidebar/NP/HotKeys.vue'
import HeartSvg from '../shared/HeartSvg.vue'
import MasterFlag from '../shared/MasterFlag.vue'
import Actions from './Right.vue'
import HeartSvg from '../shared/HeartSvg.vue'
import ExpandSvg from '@/assets/icons/expand.svg'
import MasterFlag from '../shared/MasterFlag.vue'
import TextLoader from '../shared/TextLoader.vue'
import HotKeys from '../LeftSidebar/NP/HotKeys.vue'
import ExplicitIcon from '@/assets/icons/explicit.svg'
import ImageLoader from '@/components/shared/ImageLoader.vue'
import ArtistName from '@/components/shared/ArtistName.vue'
const queue = useQStore()
const settings = useSettingsStore()
const colors = useColorStore()
const settings = useSettingsStore()
defineEmits<{
(e: 'handleFav'): void
@@ -84,6 +100,10 @@ defineEmits<{
line-height: 1.2;
margin-right: $medium;
.text-loader {
height: 1rem;
}
.np-image {
position: relative;
height: 3rem;
@@ -183,5 +203,22 @@ defineEmits<{
gap: 0;
max-width: calc(100% - 8px);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.explicit-icon,
.master-flag {
opacity: 0;
animation: fadeIn 0.5s ease-in-out forwards;
animation-delay: 1.5s;
}
}
</style>

View File

@@ -11,7 +11,7 @@
<RepeatOneSvg v-if="settings.repeat == 'one'" />
<RepeatAllSvg v-else />
</button>
<button title="Shuffle" @click="queue.shuffleQueue">
<button class="shuffle" title="Shuffle" @click="queue.shuffleQueue">
<ShuffleSvg />
</button>
<HeartSvg
@@ -71,10 +71,16 @@ defineEmits<{
}
}
.shuffle {
padding: $small $smallest !important;
}
.lyrics,
.repeat {
.repeat,
.shuffle {
svg {
transform: scale(0.75);
height: 1.5rem;
width: 1.5rem;
}
&:active > svg {
@@ -82,6 +88,11 @@ defineEmits<{
}
}
.speaker svg {
height: 1.35rem;
width: 1.35rem;
}
button.repeat.repeat-disabled {
svg {
opacity: 0.25;

View File

@@ -14,10 +14,10 @@
min="0"
step="0.01"
:value="settings.volume"
@input="changeVolume"
:style="{
backgroundSize: `${(settings.volume / 1) * 100}% 100%`,
}"
@input="changeVolume"
/>
<div className="volume_indicator">{{ ((settings.volume / 1) * 100).toFixed(0) }}</div>
</div>
@@ -70,10 +70,6 @@ const handleMouseWheel = (event: WheelEvent) => {
place-items: center;
}
svg {
transform: scale(0.75);
}
.dialog {
position: absolute;
cursor: default;

View File

@@ -1,7 +1,11 @@
<template>
<div class="now-playing-header">
<div class="top">
<RouterLink :to="sourceData.location">
{{ sourceData.name }}
</RouterLink>
</div>
<div class="centered">
<PlayingFrom />
<RouterLink
:to="{
name: Routes.album,
@@ -12,8 +16,14 @@
title="Go to Album"
class="np-image"
>
<img v-motion-fade class="rounded" :src="paths.images.thumb.large + queue.currenttrack?.image" />
<ImageLoader
:image="paths.images.thumb.original + queue.currenttrack?.image"
:blurhash="queue.currenttrack?.blurhash"
:duration="1000"
/>
</RouterLink>
</div>
<div class="below">
<NowPlayingInfo @handle-fav="handleFav" />
<Progress v-if="isMobile" />
<div class="below-progress">
@@ -26,7 +36,8 @@
</div>
</div>
</div>
<h3 class="nowplaying_title" v-if="queue.next">Up Next</h3>
<!-- <TrackContext /> -->
<!-- <h3 v-if="queue.next" class="nowplaying_title">Up Next</h3>
<SongItem
v-if="queue.next"
:track="queue.next"
@@ -34,7 +45,7 @@
:source="dropSources.folder"
@play-this="queue.playNext"
/>
<h3 class="nowplaying_title">Queue</h3>
<h3 class="nowplaying_title">Queue</h3> -->
</div>
</template>
@@ -52,8 +63,21 @@ import Buttons from '../BottomBar/Right.vue'
import SongItem from '../shared/SongItem.vue'
import NowPlayingInfo from './NowPlayingInfo.vue'
import PlayingFrom from './PlayingFrom.vue'
import TrackContext from './TrackContext.vue'
import { From } from '@/stores/queue/tracklist'
import playingFrom from '@/utils/playingFrom'
import { computed } from 'vue'
import ImageLoader from '../shared/ImageLoader.vue'
const props = defineProps<{
source: From
}>()
const queue = useQueueStore()
const sourceData = computed(() => {
const { name, location } = playingFrom(props.source)
return { name, location }
})
function handleFav() {
favoriteHandler(
@@ -75,6 +99,15 @@ function handleFav() {
padding-bottom: $smaller;
position: relative;
display: grid;
place-items: stretch;
justify-items: center;
grid-template-rows: 1fr max-content 1fr;
@include largePhones {
padding: 1.5rem !important;
}
.nowplaying_title {
padding-left: 1rem;
margin: 1.25rem 0;
@@ -147,10 +180,35 @@ function handleFav() {
}
}
$image-size: auto;
.centered {
margin: 0 auto;
width: 26rem;
max-width: 100%;
width: 100%;
// max-width: $image-size;
}
.image-loader {
// height: $image-size;
img {
border-radius: $small;
}
}
.below {
display: flex;
flex-direction: column;
justify-content: flex-start;
// width: min(100%, $image-size);
}
.top {
display: flex;
align-items: flex-end;
padding-bottom: 1rem;
opacity: 0.75;
font-size: 14px;
}
.np-image {
@@ -160,8 +218,7 @@ function handleFav() {
img {
width: 100%;
height: 100%;
max-width: 30rem;
// aspect-ratio: 1;
aspect-ratio: 1;
object-fit: cover;
}
}
@@ -182,5 +239,24 @@ function handleFav() {
height: 0.8rem;
}
}
@include allPhones {
margin-left: 0;
padding: 0 1rem;
display: block;
padding: 2rem !important;
.top {
opacity: 0;
}
}
@include largePhones {
.np-image {
display: block;
width: 90% !important;
margin: 0 auto;
}
}
}
</style>
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="np-home">
<Header :source="store.from" />
<div class="queuetracks">
<div></div>
<div class="queue-content">
<Queue />
</div>
<div></div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import useTracklist from '@/stores/queue/tracklist'
import updatePageTitle from '@/utils/updatePageTitle'
import Header from '@/components/NowPlaying/Header.vue'
import Queue from '@/components/RightSideBar/Queue.vue'
const store = useTracklist()
onMounted(() => updatePageTitle('Now Playing'))
</script>
<style lang="scss">
.np-home {
position: relative;
display: grid;
grid-template-columns: min(32rem, 45%) min(32rem, 45%);
gap: 4rem;
height: 100%;
place-content: center;
$gap: $smaller;
@include allPhones {
gap: 2rem;
}
@include largePhones {
display: block;
overflow: auto;
padding-bottom: 2rem;
@include hideScrollbars;
}
.queuetracks {
display: grid;
grid-template-rows: 1fr calc(32rem - $gap) 1fr;
.queue-content {
display: grid;
grid-template-rows: max-content 1fr;
gap: $smaller;
}
// force show remove from queue button
.track-item {
.float-buttons {
opacity: 1;
.heart-button {
opacity: 0;
}
.remove-track {
opacity: 0.5;
svg {
color: #fff;
}
}
.favorited {
opacity: 1 !important;
}
}
&:hover {
.heart-button {
opacity: 1 !important;
}
}
}
}
#queue-scrollable {
padding: 1rem 1.5rem 0rem 0 !important;
@include largePhones {
padding: 1rem 1rem 0 1rem !important;
@include hideScrollbars;
}
}
}
</style>

View File

@@ -1,95 +1,107 @@
<template>
<div class="now-playing-info">
<div class="text">
<div class="title">{{ queue.currenttrack?.title || "Swing Music" }}</div>
<ArtistName
v-if="queue.currenttrack"
:artists="queue.currenttrack?.artists || null"
:albumartists="queue.currenttrack?.albumartists || ''"
/>
<span v-else class="artist author">
<a href="https://github.com/mungai-njoroge" target="_blank">built by @mungai-njoroge </a>
</span>
</div>
<div class="actions">
<div class="now-playing-info">
<div class="text">
<div class="title">{{ queue.currenttrack?.title || 'Swing Music' }}</div>
<div class="artist">
<ArtistName
v-if="queue.currenttrack"
:artists="queue.currenttrack?.artists || null"
:albumartists="queue.currenttrack?.albumartists || ''"
/>
<span v-else class="artist author">
<a href="https://github.com/cwilvx" target="_blank">built by @cwilvx </a>
</span>
</div>
</div>
<!-- <div class="actions">
<HeartSvg :state="queue.currenttrack?.is_favorite" @handle-fav="$emit('handleFav', queue.currenttrackhash)" />
<OptionSvg class="optionsvg" :class="{ context_menu_showing }" @click="showMenu" />
</div> -->
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref } from 'vue'
import ArtistName from "../shared/ArtistName.vue";
import HeartSvg from "../shared/HeartSvg.vue";
import ArtistName from '../shared/ArtistName.vue'
import HeartSvg from '../shared/HeartSvg.vue'
import OptionSvg from "@/assets/icons/more.svg";
import { showTrackContextMenu } from "@/helpers/contextMenuHandler";
import useQueueStore from "@/stores/queue";
import OptionSvg from '@/assets/icons/more.svg'
import { showTrackContextMenu } from '@/helpers/contextMenuHandler'
import useQueueStore from '@/stores/queue'
const context_menu_showing = ref(false);
const context_menu_showing = ref(false)
const queue = useQueueStore();
const queue = useQueueStore()
defineEmits<{
(e: "handleFav", trackhash: string): void;
}>();
(e: 'handleFav', trackhash: string): void
}>()
function showMenu(e: MouseEvent) {
if (!queue.currenttrack) return;
if (!queue.currenttrack) return
showTrackContextMenu(e, queue.currenttrack, context_menu_showing);
showTrackContextMenu(e, queue.currenttrack, context_menu_showing)
}
</script>
<style lang="scss">
.now-playing-info {
display: grid;
grid-template-columns: 1fr max-content;
gap: 1rem;
margin-top: 1rem;
font-weight: 500;
.artist {
font-size: 0.8rem;
color: $gray1;
}
.actions {
display: flex;
align-items: center;
display: grid;
// grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1rem;
text-align: center;
.optionsvg {
transform: scale(1.5) rotate(90deg);
border-radius: $small;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray3;
cursor: pointer;
}
.text {
display: grid;
place-items: center;
}
svg.context_menu_showing {
background-color: $gray3;
.title {
font-weight: 600;
font-size: 1.5rem;
}
}
.heart-button {
background-color: $gray;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray4;
.artist {
font-size: 1rem;
color: $gray1;
}
}
.author {
& > * {
color: $gray1 !important;
.actions {
display: flex;
align-items: center;
gap: 1rem;
.optionsvg {
transform: scale(1.5) rotate(90deg);
border-radius: $small;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray3;
cursor: pointer;
}
}
svg.context_menu_showing {
background-color: $gray3;
}
}
.heart-button {
background-color: $gray;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray4;
}
}
.author {
& > * {
color: $gray1 !important;
}
}
}
}
</style>

View File

@@ -9,7 +9,7 @@
tracklist.from.type === FromOptions.mix
"
:src="data.image"
:class="`${tracklist.from.type === FromOptions.artist ? 'circular' : 'rounded-sm'}`"
:class="`${tracklist.from.type === FromOptions.artist ? 'circular' : 'rounded-xsm'}`"
/>
<div v-else class="from-icon border rounded-sm">
<component :is="data.icon"></component>
@@ -39,7 +39,6 @@ import MoreSvg from '@/assets/icons/more.svg'
import { showQueueContextMenu } from '@/helpers/contextMenuHandler'
const tracklist = useTracklist()
const context_showing = ref(false)
const data = computed(() => {
@@ -59,13 +58,19 @@ function showContextMenu(e: MouseEvent) {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
margin: 0 2rem 0 $small;
@include largePhones {
margin: 0 1.5rem;
}
.options {
transform: rotate(90deg);
width: 2rem;
height: 2.5rem;
padding: 0;
svg {
transform: scale(1.25);
transform: rotate(90deg) scale(1.25);
}
}
}
@@ -80,7 +85,7 @@ function showContextMenu(e: MouseEvent) {
align-items: center;
img {
width: 2.5rem;
width: 3rem;
aspect-ratio: 1;
object-fit: cover;
}

View File

@@ -0,0 +1,18 @@
<template>
<div class="np-track-context">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Corrupti dolore blanditiis numquam, provident exercitationem vitae quisquam deleniti aliquid nobis explicabo rerum est sapiente voluptates inventore nostrum, harum vero iusto! Tempore?
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.np-track-context {
width: 100%;
height: 100%;
outline: solid 1px;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="account">
<form class="createadmin" @submit.prevent="createAccount">
<Avatar class="avatar" :name="username" :size="48"/>
<div>
<div class="heading">Create admin account</div>
<div class="description">This account will be used to manage your server.</div>
</div>
<br />
<div class="form">
<div class="names">
<label for="username">Username</label>
<Input :placeholder="username" input-id="username" required @input="input => (username = input)" />
</div>
<div class="passwords">
<div class="names">
<label for="password">Password</label>
<Input
:placeholder="password"
type="password"
input-id="password"
required
@input="input => (password = input)"
/>
</div>
<div class="names">
<label for="confirmPassword">Confirm Password</label>
<Input
:placeholder="confirmPassword"
type="password"
input-id="confirmPassword"
required
@input="input => (confirmPassword = input)"
/>
</div>
</div>
</div>
<button class="btn-continue">Create account</button>
</form>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { addNewUser } from '@/requests/auth'
import Input from '@/components/shared/Input.vue'
import Avatar from '@/components/shared/Avatar.vue'
const username = ref('')
const password = ref('✶✶✶✶✶✶✶✶')
const confirmPassword = ref('✶✶✶✶✶✶✶✶')
const emit = defineEmits(['accountCreated', 'error'])
function validatePassword() {
// check if password is at least 8 characters
if (password.value.length < 8) {
return emit('error', 'Password must be at least 8 characters')
}
// check if password and confirm password match
if (password.value !== confirmPassword.value) {
return emit('error', 'Passwords do not match')
}
emit('error', '')
return true
}
async function createAccount() {
if (!validatePassword()) {
return
}
const response = await addNewUser({
username: username.value,
password: password.value,
})
if (response.status === 200) {
emit('accountCreated', response.data.userhome)
}
}
onMounted(() => {
// focus on username input
document.getElementById('username')?.focus()
})
</script>
<style lang="scss">
.account {
.passwords {
display: flex;
flex-direction: row;
gap: 1rem;
margin-top: $small;
}
.createadmin {
width: 100%;
height: 100%;
// outline: solid 1px;
position: relative;
.heading {
margin-bottom: $smaller;
}
.avatar {
position: absolute;
top: 0;
right: 0;
// outline: solid 1px;
}
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: flex-end;
label {
font-size: 0.9rem;
font-weight: 600;
color: rgb(210, 210, 210);
}
}
}
</style>

View File

@@ -0,0 +1,352 @@
<template>
<div class="file-picker">
<div class="input-container">
<div class="head">
<button class="btn-back" @click="$emit('cancel')">
<ArrowLeftSvg height="1.2rem" />
</button>
<div>
<div class="breadcrumb">
<div
v-for="path in subPaths"
:key="path.path"
class="bitem"
@click="() => fetchFolders(path.path)"
>
{{ path.name }}
</div>
</div>
</div>
<span
><button
class="btn-finish"
@click="$emit('submitDirs', finalSelection.length ? finalSelection : [currentPath])"
>
Continue
</button></span
>
</div>
</div>
<div class="folders">
<div
v-for="(folder, index) in renderedFolders"
:key="folder.path"
class="folder"
:class="{
selected: selectedFolders.has(index),
'selected-first': selectedFolders.has(index) && isFirstSelected(index),
'selected-last': selectedFolders.has(index) && isLastSelected(index),
}"
@click.exact="handleSelect(index, false, false)"
@click.meta="handleSelect(index, false, true)"
@click.ctrl="handleSelect(index, false, true)"
@click.shift="handleSelect(index, true, false)"
@dblclick="fetchFolders(folder.path)"
>
<FolderSvg />
<span>{{ folder.name }}</span>
</div>
</div>
<div class="help rounded-sm">
<div class="help-content">
<InfoSvg />
<span>Use (/Ctrl or Shift) + Click to select multiple folders</span>
</div>
<span>{{ selectedFolders.size }} Selected</span>
</div>
</div>
</template>
<!-- TODO: Handle duplicates on final root dirs -->
<!-- TODO: Clear Errors on setting root dirs -->
<!-- TODO: Work on breadcrumb -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Input from '../shared/Input.vue'
import { getFolders } from '@/requests/settings/rootdirs'
import FolderSvg from '@/assets/icons/folder.svg'
import AddSvg from '@/assets/icons/add.svg'
import SubtractSvg from '@/assets/icons/subtract.svg'
import ArrowLeftSvg from '@/assets/icons/arrow.svg'
import InfoSvg from '@/assets/icons/info.svg'
import { Folder } from '@/interfaces'
import { createSubPaths } from '@/utils'
const props = defineProps<{
userhome: string
}>()
defineEmits<{
(e: 'submitDirs', dirs: string[]): void
(e: 'cancel'): void
}>()
const currentPath = ref<string>('')
const folders = ref<Folder[]>([])
const subPaths = computed(() => {
return createSubPaths(currentPath.value, '')[1]
})
const renderedFolders = computed(() => {
const first2 = [...folders.value]
if (first2.length >= 2) {
first2[0].name = '↑'
first2[1].name = '. (this folder)'
}
return first2
})
const lastSelectedIndex = ref<number>(-1)
const selectedFolders = ref<Set<number>>(new Set())
const finalSelection = computed(() =>
Array.from(selectedFolders.value.values()).map(index => folders.value[index].path)
)
function isFirstSelected(index: number): boolean {
if (!selectedFolders.value.has(index)) return false
const previousIndex = index - 1
return previousIndex < 0 || !selectedFolders.value.has(previousIndex)
}
function isLastSelected(index: number): boolean {
if (!selectedFolders.value.has(index)) return false
const nextIndex = index + 1
return nextIndex >= renderedFolders.value.length || !selectedFolders.value.has(nextIndex)
}
async function fetchFolders(folder: string) {
const results = await getFolders(folder)
folders.value = results
currentPath.value = folder
selectedFolders.value.clear()
lastSelectedIndex.value = -1
}
function handleSelect(index: number, shift: boolean, ctrl: boolean) {
// INFO: Handle shift - range selection
if (shift) {
// INFO: Handle selection of parent and current folder
// INFO: .. and . folder should only be selected alone
// INFO: Handle selection from ../. to folder N
if (lastSelectedIndex.value <= 1) {
lastSelectedIndex.value = 2
selectedFolders.value = new Set([2])
}
// INFO: Handle selection from folder N to parent
if (index <= 1) {
selectedFolders.value = new Set([lastSelectedIndex.value])
index = 2
}
// Select range from last selected to current
const start = Math.min(lastSelectedIndex.value, index)
const end = Math.max(lastSelectedIndex.value, index)
for (let i = start; i <= end; i++) {
selectedFolders.value.add(i)
}
lastSelectedIndex.value = index
return
}
// INFO: Handle ctrl - toggle selection
if (ctrl) {
if (index <= 1) {
selectedFolders.value = new Set([index])
lastSelectedIndex.value = index
return
}
if (selectedFolders.value.has(0) || selectedFolders.value.has(1)) {
selectedFolders.value = new Set([index])
lastSelectedIndex.value = index
return
}
if (selectedFolders.value.has(index)) {
selectedFolders.value.delete(index)
} else {
selectedFolders.value.add(index)
}
lastSelectedIndex.value = index
return
}
// INFO: Handle regular click - single selection
selectedFolders.value.clear()
selectedFolders.value.add(index)
lastSelectedIndex.value = index
}
onMounted(async () => {
currentPath.value = props.userhome
await fetchFolders(props.userhome)
})
</script>
<style lang="scss">
.file-picker {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: max-content 1fr max-content;
position: relative;
gap: $small;
.breadcrumb {
display: flex;
flex-direction: row;
gap: $small;
font-weight: 500;
font-size: 0.8rem;
.bitem {
color: rgb(155, 154, 154);
position: relative;
cursor: pointer;
// outline: solid 1px;
}
.bitem:last-child {
color: $blue;
cursor: default;
&::after {
display: none;
}
}
.bitem::after {
content: '/';
margin-right: $small;
color: $gray2;
font-size: 0.8rem;
font-weight: 500;
cursor: default;
position: absolute;
// center the before vertical
top: 50%;
right: -0.9rem;
transform: translateY(-50%);
}
}
.head {
margin-bottom: $small;
font-size: 1rem;
font-weight: 600;
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: center;
gap: 1rem;
button {
font-size: 0.8rem;
}
}
.help {
display: flex;
align-items: center;
justify-content: space-between;
// margin: $small $small;
font-size: 0.65rem;
.help-content {
display: flex;
align-items: center;
gap: $small;
}
background-color: transparent;
padding: $small;
margin-bottom: -$small;
color: $gray1;
svg {
height: 0.75rem;
}
}
.folder:nth-child(2),
.folder:first-child {
font-size: 0.7rem;
color: $gray1;
svg {
color: #fff;
}
}
.folder:nth-child(2) {
font-style: italic;
}
}
.folders {
display: flex;
flex-direction: column;
height: 100%;
width: calc(100% + $small);
overflow-y: auto;
margin-left: $smaller;
.folder {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: center;
gap: $small;
padding: $small;
cursor: default;
border-radius: $smaller;
font-size: 0.9rem;
font-weight: 500;
// disable select
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
width: calc(100% - 1rem);
&:hover {
background-color: $gray5;
.action {
opacity: 1;
}
}
}
.folder.selected {
background-color: $darkblue;
border-radius: 0;
}
.folder.selected-first {
border-top-left-radius: $small;
border-top-right-radius: $small;
}
.folder.selected-last {
border-bottom-left-radius: $small;
border-bottom-right-radius: $small;
}
svg {
height: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div class="onboardingfinish">
<div class="heading">
<span v-if="isFinished">You're all set! 🎉</span>
<span v-else>You're almost there!</span>
</div>
<div class="description">
<span v-if="isFinished">Click the button below to continue to your library.</span
><span v-else>Swing Music is scanning your music folders. Please wait ...</span>
</div>
<br />
<div class="progress">
<div class="progress-bar rounded-sm">
<div
class="progress-fill"
:style="{
width: `${progressPercentage}%`,
}"
></div>
</div>
</div>
<br />
<div class="btn-container">
<button class="btn-continue" :class="{ 'btn-disabled': !isFinished }" @click="emit('finish')">
Finish
</button>
</div>
</div>
</template>
<script setup lang="ts">
import events from '@/stores/events'
import { getOnboardingData } from '@/requests/auth'
import { onBeforeUnmount, onMounted, ref, computed, watch } from 'vue'
const steps = ref(5)
const currentStep = ref(0.25)
const lastUpdated = ref(Date.now())
const isFinished = computed(() => {
return currentStep.value === steps.value
})
const progressPercentage = computed(() => {
return (currentStep.value / steps.value) * 100
})
const emit = defineEmits(['finish'])
function updateProgress(current: number, total: number) {
if (current > currentStep.value) {
currentStep.value = current
}
if (total !== steps.value) {
steps.value = total
}
}
onMounted(() => {
// INFO: batch is a string like "1/5"
events.subscribe('scan_batch_cleared', (data: { batch: `${number}/${number}` }) => {
const [current, total] = data.batch.split('/')
const currentVal = parseInt(current)
const totalVal = parseInt(total)
updateProgress(currentVal, totalVal)
lastUpdated.value = Date.now()
})
})
const interval = setInterval(async () => {
if (Date.now() - lastUpdated.value > 5000) {
const res = await getOnboardingData()
if (res.scanMessage) {
const [current, total] = res.scanMessage.split(':')[1].split('/')
const currentVal = parseInt(current)
const totalVal = parseInt(total)
updateProgress(currentVal, totalVal)
}
}
}, 5000)
// Clear interval when finish state is reached
watch(isFinished, finished => {
if (finished) {
clearInterval(interval)
}
})
onBeforeUnmount(() => {
events.unsubscribe('scan_batch_cleared')
clearInterval(interval)
})
</script>
<style lang="scss">
.onboardingfinish {
text-align: center;
.description {
color: $gray1;
}
.progress {
width: 26rem;
}
.progress-bar {
background-color: #3a3a3c;
height: 0.5rem;
width: 100%;
overflow: hidden;
position: relative;
}
.progress-fill {
// background: linear-gradient(to right, #d020f3, #ff0ae6);
background-color: $blue;
height: 100%;
transition: width 0.5s ease-out;
}
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<FilePicker v-if="showFilePicker" :userhome="userHome" @submitDirs="handleSubmitDirs" @cancel="toggleFilePicker" />
<div v-else class="rootdirconfig">
<div class="heading">Configure root directories</div>
<div class="description">Where do you want to look for music?</div>
<br />
<div class="options">
<div class="option" @click="toggleHomeDir">
<div>
<div class="option-title">Home directory</div>
<div class="option-description">
Scan all folders in <span class="userhome">{{ userHome }}</span>
</div>
</div>
<div class="option-selected">
<CheckSvg v-if="homeDirSelected" height="1.75rem" />
</div>
</div>
<br />
<div class="option" @click="toggleFilePicker">
<div>
<div class="option-title">Specific directory</div>
<div class="option-description">
{{
specificDirsSelected
? `${finalRootDirs.length} folder${finalRootDirs.length !== 1 ? 's' : ''} selected`
: 'Select folder to scan for music'
}}
</div>
</div>
<div v-show="specificDirsSelected" class="option-selected">
<span>Add Folders</span>
<CheckSvg v-if="specificDirsSelected" height="1.75rem" />
</div>
</div>
</div>
<br />
<div class="btn-container">
<button class="btn-continue" @click="handleContinue">{{ fromSettings ? 'Update' : 'Continue' }}</button>
</div>
</div>
<div v-if="rootDirs.length > 0 && !homeDirSelected" class="selected-folders rounded-sm">
<div class="heading">{{ finalRootDirs.length }} Selected Folders</div>
<div class="folders">
<div v-for="folder in finalRootDirs" :key="folder" class="folder">
<FolderSvg />
{{ folder.startsWith(userHome) ? folder.replace(userHome, '~') : folder }}
<div class="action" @click="handleRemoveFolder(folder)">
<SubtractSvg />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { paths } from '@/config'
import { router } from '@/router'
import useAxios from '@/requests/useAxios'
import useSettingsStore from '@/stores/settings'
import { getAllSettings } from '@/requests/settings'
import { addRootDirs } from '@/requests/settings/rootdirs'
import FilePicker from './FilePicker.vue'
import FolderSvg from '@/assets/icons/folder.svg'
import SubtractSvg from '@/assets/icons/subtract.svg'
import CheckSvg from '@/assets/icons/check.filled.svg'
// SECTION: Props & Emits
const props = defineProps<{
userhome: string
}>()
const userHome = ref('')
const emit = defineEmits<{
(e: 'setRootDirs', dirs: string[]): void
(e: 'error', error: string): void
}>()
// SECTION: Properties
const showFilePicker = ref(false)
const rootDirs = ref<string[]>([])
const removedDirs = ref<string[]>([])
const fromSettings = computed(() => router.currentRoute.value.params.step === 'dirconfig')
const finalRootDirs = computed(() => {
if (rootDirs.value.length <= 1) {
return rootDirs.value
}
// Remove duplicates first
const uniqueDirs = [...new Set(rootDirs.value)]
// Sort directories by length (shortest first) to process parents before children
const sortedDirs = uniqueDirs.sort((a, b) => a.length - b.length)
const filteredDirs: string[] = []
for (const dir of sortedDirs) {
// Check if this directory is a child of any already selected directory
const isChild = filteredDirs.some(parentDir => {
// Ensure parent directory ends with '/' for proper path comparison
const normalizedParent = parentDir.endsWith('/') ? parentDir : parentDir + '/'
return dir.startsWith(normalizedParent)
})
// Only add if it's not a child of any parent directory
if (!isChild) {
filteredDirs.push(dir)
}
}
return filteredDirs
})
const homeDirSelected = computed(() => {
return finalRootDirs.value.length == 1 && finalRootDirs.value[0] == userHome.value
})
const specificDirsSelected = computed(() => finalRootDirs.value.length && !homeDirSelected.value)
// SECTION: Handlers
function toggleFilePicker() {
// INFO: Reset root dirs if home dir is selected
if (homeDirSelected.value) {
rootDirs.value = []
}
showFilePicker.value = !showFilePicker.value
}
function toggleHomeDir() {
if (homeDirSelected.value) {
rootDirs.value = []
} else {
rootDirs.value = [userHome.value]
}
}
function handleSubmitDirs(dirs: string[]) {
rootDirs.value.push(...dirs)
emit('error', '')
showFilePicker.value = false
}
function handleRemoveFolder(folder: string) {
rootDirs.value = rootDirs.value.filter(dir => dir !== folder)
removedDirs.value.push(folder)
}
async function handleContinue() {
if (!rootDirs.value.length) {
emit('error', 'Please select a root directory')
return
}
if (fromSettings.value) {
await addRootDirs(finalRootDirs.value, removedDirs.value)
// INFO: Go back to previous page
return router.go(-1)
}
emit('setRootDirs', finalRootDirs.value)
}
onMounted(async () => {
if (fromSettings.value) {
const settings = useSettingsStore()
// INFO: If root dirs are not loaded, fetch from server
// NOTE: this path is executed when you reload this page
if (!settings.root_dirs.length) {
const { settings: data } = await getAllSettings()
settings.mapDbSettings(data)
}
rootDirs.value = settings.root_dirs
}
if (!props.userhome) {
const res = await useAxios({
url: paths.api.onboardingData,
method: 'GET',
})
if (res.status !== 200) {
return
}
const { userHome: ResUserHome } = res.data
userHome.value = ResUserHome
} else {
userHome.value = props.userhome
}
})
</script>
<style lang="scss">
.rootdirconfig {
width: 30rem;
// text-align: center;
.heading {
margin-bottom: $smaller;
}
}
.option {
padding: 1rem;
border-radius: $small;
background-color: $gray5;
cursor: pointer;
display: grid;
grid-template-columns: 1fr max-content;
gap: 1rem;
align-items: center;
&:hover {
background-color: $gray3;
}
.option-selected {
// width: 1.25rem;
font-size: 0.8rem;
font-weight: 500;
color: $gray1;
svg {
color: $green;
}
display: flex;
align-items: center;
gap: $small;
}
}
.userhome {
font-weight: 500;
font-family: 'SF Mono';
}
.option-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: $smaller;
}
.option-description {
font-size: 0.8rem;
font-weight: 500;
}
.options {
display: flex;
flex-direction: column;
gap: $small;
}
.btn-container {
display: flex;
justify-content: center;
}
.selected-folders {
position: absolute;
top: 34rem;
width: 100%;
background-color: $gray;
padding: $small;
padding-top: 1rem;
.folders {
height: auto;
max-height: 12rem;
overflow-y: auto;
}
.heading {
position: absolute;
top: -2rem;
font-size: 0.8rem;
font-weight: 600;
color: $gray1;
}
// font-size: 0.8rem;
.action {
opacity: 0.5;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $red;
}
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="welcome">
<div class="logo"><LogoSvg /></div>
<div class="heading">Welcome to</div>
<div class="appname">Swing Music</div>
<p class="tagline">
You will need to configure your account login details <br />
and root directories to get started.
</p>
<button class="btn-continue" @click="emit('continue')">Get Started</button>
</div>
</template>
<script setup lang="ts">
import LogoSvg from '@/assets/icons/logos/logo-fill.light.svg'
const emit = defineEmits(['continue'])
</script>
<style lang="scss">
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $medium;
// center everything
text-align: center;
.logo {
width: 100%;
height: 100%;
}
.appname {
color: $highlight-blue;
font-size: 2.5rem;
font-weight: 700;
// gradient text
background: linear-gradient(to right, $red, $blue, $red);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
// disable selection
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}
</style>

View File

@@ -36,16 +36,14 @@ const tabs = useTabStore()
display: grid;
grid-template-rows: max-content 1fr;
background-color: rgb(22, 22, 22);
padding-bottom: 1rem;
border-top: none;
border-bottom: none;
margin-bottom: -1rem;
.rtopbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 1rem;
}
.gsearch-input {
@@ -75,7 +73,7 @@ const tabs = useTabStore()
.r-content {
width: 100%;
height: 100%;
background-color: $gray;
// background-color: $gray;
.r-search {
height: 100%;
@@ -92,6 +90,18 @@ const tabs = useTabStore()
gap: $small;
grid-template-rows: max-content 1fr;
}
#queue-scrollable {
padding: 0 1rem 2rem $small;
}
.now-playing-top {
margin: 0 1rem;
}
}
.top-result-item {
background-color: $gray5;
}
}

View File

@@ -1,92 +1,92 @@
<template>
<QueueActions />
<div
class="queue-virtual-scroller"
@mouseover="mouseover = true"
@mouseout="mouseover = false"
>
<NoItems
:flag="!store.tracklist.length"
:title="'No songs in queue'"
:description="'When you start playing songs, they will appear here.'"
:icon="QueueSvg"
/>
<RecycleScroller
id="queue-scrollable"
v-slot="{ item, index }"
class="scroller"
style="height: 100%"
:items="scrollerItems"
:item-size="itemHeight"
key-field="id"
>
<TrackItem
:index="index"
:track="item.track"
:is-current="index === queue.currentindex"
:is-current-playing="index === queue.currentindex && queue.playing"
:is-queue-track="true"
@playThis="playFromQueue(index)"
/>
</RecycleScroller>
</div>
<!-- <QueueActions /> -->
<PlayingFrom />
<div class="queue-virtual-scroller" @mouseover="mouseover = true" @mouseout="mouseover = false">
<NoItems
:flag="!store.tracklist.length"
:title="'No songs in queue'"
:description="'When you start playing songs, they will appear here.'"
:icon="QueueSvg"
/>
<RecycleScroller
id="queue-scrollable"
v-slot="{ item, index }"
class="scroller"
style="height: 100%"
:items="scrollerItems"
:item-size="itemHeight"
key-field="id"
>
<TrackItem
:index="index"
:track="item.track"
:is-current="index === queue.currentindex"
:is-current-playing="index === queue.currentindex && queue.playing"
:is-queue-track="true"
@playThis="playFromQueue(index)"
/>
</RecycleScroller>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import useQStore from "@/stores/queue";
import useInterface from "@/stores/interface";
import useTracklist from "@/stores/queue/tracklist";
import useQStore from '@/stores/queue'
import useInterface from '@/stores/interface'
import useTracklist from '@/stores/queue/tracklist'
import NoItems from "../shared/NoItems.vue";
import QueueActions from "./Queue/QueueActions.vue";
import TrackItem from "@/components/shared/TrackItem.vue";
import QueueSvg from "@/assets/icons/queue.svg";
import NoItems from '../shared/NoItems.vue'
import QueueActions from './Queue/QueueActions.vue'
import TrackItem from '@/components/shared/TrackItem.vue'
import QueueSvg from '@/assets/icons/queue.svg'
import PlayingFrom from '../NowPlaying/PlayingFrom.vue'
const itemHeight = 64;
const queue = useQStore();
const store = useTracklist();
const mouseover = ref(false);
const itemHeight = 64
const paddingTop = 16
const { focusCurrentInSidebar, setScrollFunction } = useInterface();
const queue = useQStore()
const store = useTracklist()
const mouseover = ref(false)
const { focusCurrentInSidebar, setScrollFunction } = useInterface()
const scrollerItems = computed(() => {
return store.tracklist.map((track, index) => ({
track,
id: index,
}));
});
return store.tracklist.map((track, index) => ({
track,
id: index,
}))
})
function playFromQueue(index: number) {
queue.play(index);
queue.play(index)
}
const show_above = 1; // the number of tracks to show above the current track
const show_above = 1 // the number of tracks to show above the current track
function scrollToCurrent() {
const elem = document.getElementById("queue-scrollable") as HTMLElement;
const elem = document.getElementById('queue-scrollable') as HTMLElement
const top = (queue.currentindex - show_above) * itemHeight;
elem.scroll({
top,
behavior: "smooth",
});
const top = paddingTop + (queue.currentindex - show_above) * itemHeight
elem.scroll({
top,
behavior: 'smooth',
})
}
onMounted(() => {
setScrollFunction(scrollToCurrent, mouseover);
focusCurrentInSidebar();
});
setScrollFunction(scrollToCurrent, mouseover)
focusCurrentInSidebar()
})
onBeforeUnmount(() => {
setScrollFunction(() => {}, null);
});
setScrollFunction(() => {}, null)
})
</script>
<style lang="scss">
.queue-virtual-scroller {
height: 100%;
overflow: hidden;
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -1,17 +1,18 @@
<template>
<div class="queue-actions">
<div class="left">
<button v-if="!onNowPlaying" v-wave class="shuffle-queue action" @click="queue.shuffleQueue">
<!-- <div class="left"> -->
<!-- <button v-if="!onNowPlaying" v-wave class="shuffle-queue action" @click="queue.shuffleQueue">
<ShuffleSvg />
<span>Shuffle</span>
</button>
<h2 v-else style="margin: 0">Now Playing</h2>
</div>
<h2 v-else style="margin: 0">Now Playing</h2> -->
<PlayingFrom />
<!-- </div>
<div class="right">
<button class="menu" :class="{ 'btn-active': context_showing }" @click="showContextMenu">
<OptionsSvg />
</button>
</div>
</div> -->
</div>
</template>
@@ -24,6 +25,7 @@ import { showQueueContextMenu } from "@/helpers/contextMenuHandler";
import OptionsSvg from "@/assets/icons/more.svg";
import ShuffleSvg from "@/assets/icons/shuffle.svg";
import PlayingFrom from "@/components/NowPlaying/PlayingFrom.vue";
const queue = useQueue();
const { tracklist } = useTracklist();
@@ -43,12 +45,14 @@ defineProps<{
<style lang="scss">
.queue-actions {
display: flex;
justify-content: space-between;
// display: flex;
// justify-content: space-between;
gap: $small;
margin: 1rem;
margin: 2rem 2rem 1rem 0;
margin-bottom: 0;
// margin-right: 2rem;
.lyricsversion {
display: flex;
gap: 1rem;

View File

@@ -55,6 +55,7 @@ defineEmits<{
.cardlistrow {
grid-template-columns: repeat(auto-fill, minmax(8.1rem, 1fr));
padding-bottom: 0;
}
}
@@ -63,6 +64,10 @@ defineEmits<{
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
.scroller {
padding: 0 1rem 2rem $small !important;
}
}
.designatedOS #tab-content::-webkit-scrollbar-track {

View File

@@ -21,6 +21,6 @@ const search = useSearchstore();
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
padding: $small $smaller;
gap: $small 0;
margin-bottom: 2rem;
padding: 0 $small 2rem $smaller;
}
</style>

View File

@@ -34,10 +34,6 @@ function handlePlay(track: Track) {
<style lang="scss">
.right-search-top-tracks {
margin-bottom: 2rem;
.track-item {
padding: $small 1rem;
}
margin: 0 1rem 2rem $small;
}
</style>

View File

@@ -80,6 +80,10 @@ onMounted(() => {
height: 100%;
display: grid;
grid-template-rows: 1fr max-content;
.scroller {
padding-bottom: 0rem !important;
}
}
#tracks-results .morexx {

View File

@@ -1,7 +1,7 @@
<template>
<form class="secretinput" @submit.prevent="$emit('submit', input)">
<div class="left rounded-sm no-scroll">
<input :type="showText ? 'text' : 'password'" v-model="input" @input="() => (showTextManual = true)" />
<input v-model="input" :type="showText ? 'text' : 'password'" @input="() => (showTextManual = true)" />
<button @click.prevent="showTextManual = !showTextManual">
<EyeSvg v-if="showText" />
<EyeSlashSvg v-else />

View File

@@ -61,7 +61,7 @@
:to="{
name: Routes.album,
params: {
albumhash: props.image?.replace('.webp', ''),
albumhash: props.image.split('?pathhash=')[0]?.replace('.webp', ''),
},
}"
>

View File

@@ -1,5 +1,5 @@
<template>
<div class="statshead" v-if="statItems.length">
<div v-if="statItems.length" class="statshead">
<div class="left">
<StatItem
v-for="item in statItems.slice(0, statItems.length - 1)"
@@ -19,7 +19,7 @@
/>
</div>
</div>
<div class="statsdates" v-if="date">
<div v-if="date" class="statsdates">
<CalendarSvg />
{{ date }}
</div>
@@ -31,7 +31,7 @@ import { onMounted, ref } from 'vue'
import StatItem from './StatItem.vue'
import CalendarSvg from '@/assets/icons/calendar.svg'
interface StatItem {
interface StatsItem {
cssclass: string
value: string
text: string
@@ -39,10 +39,10 @@ interface StatItem {
}
const props = defineProps<{
items?: StatItem[]
items?: StatsItem[]
}>()
const statItems = ref<StatItem[]>([])
const statItems = ref<StatsItem[]>([])
const date = ref<string | null>(null)
onMounted(async () => {

View File

@@ -24,9 +24,9 @@
/>
<CrudPage
v-if="modal.component == modal.options.page"
v-bind="modal.props"
@hideModal="hideModal"
@setTitle="setTitle"
v-bind="modal.props"
/>
<UpdatePlaylist
v-if="modal.component == modal.options.updatePlaylist"
@@ -43,7 +43,7 @@
</div>
<SetRootDirs v-if="modal.component == modal.options.setRootDirs" @hideModal="hideModal" />
<RootDirsPrompt v-if="modal.component == modal.options.rootDirsPrompt" @hideModal="hideModal" />
<Settings @set-title="setTitle" v-if="modal.component == modal.options.settings" />
<Settings v-if="modal.component == modal.options.settings" @set-title="setTitle" />
</div>
</div>
</template>

View File

@@ -1,20 +1,20 @@
<template>
<div
v-auto-animate
class="settingsmodal"
:class="{
isSmallPhone,
}"
v-auto-animate
>
<Sidebar
v-if="!(isSmallPhone && showContent)"
:current-group="(currentGroup as SettingGroup)"
@set-tab="tab => (currentTab = tab)"
v-if="!(isSmallPhone && showContent)"
/>
<div class="content" v-if="showContent">
<div class="head" v-auto-animate>
<div v-if="showContent" class="content">
<div v-auto-animate class="head">
<div class="h2">
<button class="back" v-if="isSmallPhone" @click="handleGoBack">
<button v-if="isSmallPhone" class="back" @click="handleGoBack">
<ArrowSvg />
</button>
{{ currentGroup?.title }}
@@ -64,6 +64,8 @@ const currentGroup = computed(() => {
}
}
}
return null
})
const showContent = computed(() => {
@@ -81,6 +83,7 @@ $modalheight: 38rem;
.settingsmodal {
display: grid;
grid-template-columns: 15rem 1fr;
height: 100%;
.content {
display: grid;

View File

@@ -18,7 +18,7 @@ defineProps<{
width: 100%;
padding: 0 1rem;
height: 100%;
max-height: calc(100vh - 6.85rem);
// max-height: calc(100vh - 6.85rem);
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;

View File

@@ -50,6 +50,7 @@ interface SortItem {
const items: SortItem[] = [
{ key: 'default', title: 'Default' },
{ key: 'title', title: 'Title' },
{ key: 'filepath', title: 'File Name' },
{ key: 'album', title: 'Album' },
// { key: 'albumartists', title: 'Album Artist' },
{ key: 'artists', title: 'Artist' },

View File

@@ -3,6 +3,8 @@
:size="size || 80"
:name="name"
:square="false"
:variant="'beam'"
:colors="['#3a3a3c']"
/>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div class="cardlistrow">
<component v-for="item in items" :key="item.key" :is="item.component" v-bind="item.props" />
<component :is="item.component" v-for="item in items" :key="item.key" v-bind="item.props" class="hlistitem" />
</div>
</template>

View File

@@ -4,15 +4,15 @@
<button
class="selected"
:class="{ showDropDown }"
@click.prevent="handleOpener"
:title="
reverse !== 'hide'
? `sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase()
: undefined
"
@click.prevent="handleOpener"
>
<span class="ellip">{{ current.title }}</span>
<ArrowSvg :class="{ reverse }" class="dropdown-arrow" v-if="reverse !== 'hide'" />
<ArrowSvg v-if="reverse !== 'hide'" :class="{ reverse }" class="dropdown-arrow" />
</button>
<div v-if="showDropDown" ref="dropOptionsRef" class="options rounded no-scroll shadow-lg">
<div

View File

@@ -2,6 +2,7 @@
<button
v-wave
class="heart-button circular"
:class="{ favorited: state }"
:style="{
color: color ? getTextColor(color) : '',
}"

View File

@@ -0,0 +1,235 @@
<template>
<div
ref="imageLoader"
class="image-loader"
:style="{
height: `${Math.max(imageHeights[images[0]?.key] || 0, containerWidth || 0)}px`,
}"
>
<canvas
v-if="props.blurhash"
ref="blurhashCanvas"
class="blurhash-placeholder rounded-sm"
:class="{ 'fade-out': imageLoaded }"
:style="{
transitionDuration: `${duration}ms`,
height: `${Math.max(imageHeights[images[0]?.key] || 0, containerWidth || 0)}px`,
}"
></canvas>
<img
v-for="(img, index) in images"
:key="img.key"
:ref="el => setImageRef(img.key, el)"
:src="img.src"
:class="`${index === activeIndex && readyToShowKeys.has(img.key) ? 'active' : ''} il-image ${
imgClass || ''
}`"
:style="{
transitionDuration: `${duration}ms`,
}"
@load="onDomImageLoad(img.key, $event)"
/>
</div>
</template>
<script setup lang="ts">
import { decode } from 'blurhash'
import { computed, ref, watch, nextTick, onMounted } from 'vue'
const props = defineProps<{
image: string
duration: number
blurhash?: string
imgClass?: string
}>()
const imageKey = ref(0)
const activeIndex = ref(0)
const imageLoaded = ref(false)
const readyToShowKeys = ref<Set<number>>(new Set())
const imageHeights = ref<Record<number, number>>({})
const imageLoader = ref<HTMLDivElement | null>(null)
const blurhashCanvas = ref<HTMLCanvasElement | null>(null)
const images = ref<Array<{ src: string; key: number }>>([])
const imageRefs = ref<Record<number, HTMLImageElement | null>>({})
const containerWidth = computed(() => imageLoader.value?.clientWidth)
watch(
() => props.image,
async newImage => {
if (!newImage) return
renderBlurhash()
imageLoaded.value = false
readyToShowKeys.value.clear()
const imageKeyValue = imageKey.value++
const newImageObj = {
src: newImage,
key: imageKeyValue,
}
images.value.push(newImageObj)
if (images.value.length > 2) {
const removedImage = images.value.shift()
if (removedImage) {
delete imageRefs.value[removedImage.key]
readyToShowKeys.value.delete(removedImage.key)
}
}
setTimeout(() => {
activeIndex.value = images.value.length - 1
}, 10)
await loadImageManually(newImage, imageKeyValue)
},
{ immediate: true }
)
function renderBlurhash() {
if (!props.blurhash || !blurhashCanvas.value || !containerWidth.value) return
const canvas = blurhashCanvas.value
const width = containerWidth.value
const height = imageHeights.value[images.value[0]?.key] || width
canvas.width = width
canvas.height = height
try {
const pixels = decode(props.blurhash, width, height)
const ctx = canvas.getContext('2d')
if (!ctx) return
const imageData = ctx.createImageData(width, height)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
} catch (error) {
console.error('Failed to decode blurhash:', error)
}
}
function setImageRef(key: number, el: unknown) {
if (el && el instanceof HTMLImageElement) {
imageRefs.value[key] = el
} else {
imageRefs.value[key] = null
}
}
function onDomImageLoad(imageKeyValue: number, eventOrElement: Event | HTMLImageElement) {
if (readyToShowKeys.value.has(imageKeyValue)) return
const imgElement = eventOrElement instanceof Event ? (eventOrElement.target as HTMLImageElement) : eventOrElement
if (!imgElement || !imageLoader.value) return
if (imgElement.complete && imgElement.naturalHeight > 0) {
const minHeight = Math.min(imageLoader.value?.clientWidth || 0, imgElement.naturalHeight)
imageHeights.value = { ...imageHeights.value, [imageKeyValue]: minHeight }
requestAnimationFrame(() => {
requestAnimationFrame(() => {
readyToShowKeys.value.add(imageKeyValue)
imageLoaded.value = true
})
})
}
}
async function loadImageManually(imageSrc: string, imageKeyValue: number) {
try {
const response = await fetch(imageSrc)
if (!response.ok) {
throw new Error(`Failed to load image: ${response.statusText}`)
}
const blob = await response.blob()
const imageUrl = URL.createObjectURL(blob)
await nextTick()
const imageIndex = images.value.findIndex(img => img.key === imageKeyValue)
if (imageIndex !== -1) {
const imgElement = imageRefs.value[imageKeyValue]
if (imgElement) {
if (imgElement.complete && imgElement.naturalHeight > 0) {
onDomImageLoad(imageKeyValue, imgElement)
}
}
images.value[imageIndex].src = imageUrl
}
} catch (error) {
console.error('Error loading image:', error)
await nextTick()
const imageIndex = images.value.findIndex(img => img.key === imageKeyValue)
if (imageIndex !== -1) {
const imgElement = imageRefs.value[imageKeyValue]
if (imgElement) {
if (imgElement.complete && imgElement.naturalHeight > 0) {
onDomImageLoad(imageKeyValue, imgElement)
}
}
images.value[imageIndex].src = imageSrc
}
}
}
watch(
() => activeIndex.value,
() => {
if (images.value.length > 1 && activeIndex.value === images.value.length - 1) {
setTimeout(() => {
images.value = images.value.slice(-1)
activeIndex.value = 0
}, props.duration)
}
}
)
onMounted(() => {
renderBlurhash()
})
</script>
<style lang="scss">
.image-loader {
position: relative;
width: 100%;
height: 100%;
.blurhash-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 1;
transition: opacity ease-in-out;
z-index: 0;
&.fade-out {
opacity: 0;
}
}
.il-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity ease-in-out;
z-index: 1;
&.active {
opacity: 1;
}
}
}
</style>

View File

@@ -2,15 +2,17 @@
<div class="passinput">
<input
:id="props.inputId"
v-model="value"
class="passinput"
:type="type"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
@input="$emit('input', ($event.target as HTMLInputElement).value)"
v-model="value"
/>
<div
class="showpass rounded-sm"
v-if="props.type === 'password'"
class="showpass rounded-sm"
:class="{ show: value.length }"
@click="toggleShowPassword"
>
@@ -30,6 +32,8 @@ const props = defineProps<{
type?: string
placeholder?: string
inputId?: string
required?: boolean
disabled?: boolean
}>()
const value = ref('')

View File

@@ -1,41 +1,39 @@
<template>
<button
title="Lyrics"
class="lyrics"
:class="{ showStatus: lyrics.exists }"
@click="handleClick"
>
<LyricsSvg /> {{ showText ? "Lyrics" : "" }}
</button>
<button title="Lyrics" class="lyrics" :class="{ showStatus: lyrics.exists }" @click="handleClick">
<LyricsSvg /> {{ showText ? 'Lyrics' : '' }}
</button>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ref } from 'vue'
import { Routes } from '@/router'
import { useRoute, useRouter } from 'vue-router'
import { Routes } from "@/router";
import useLyrics from "@/stores/lyrics";
import LyricsSvg from "@/assets/icons/lyrics.svg";
import useLyrics from '@/stores/lyrics'
import LyricsSvg from '@/assets/icons/lyrics.svg'
defineProps<{
showText?: boolean;
}>();
showText?: boolean
}>()
const route = useRoute();
const router = useRouter();
const route = useRoute()
const router = useRouter()
const lyrics = useLyrics();
let prevRoute = ref(route.name);
const lyrics = useLyrics()
let prevRoute = ref(route.name)
function handleClick() {
if (route.name === Routes.Lyrics) {
return router.back();
}
if (lyrics.onLyricsPage) {
return router.back()
}
router.push({
name: Routes.Lyrics,
});
router.push({
name: Routes.nowPlaying,
params: {
tab: 'lyrics',
},
})
prevRoute.value = route.name;
prevRoute.value = route.name
}
</script>

View File

@@ -32,9 +32,9 @@
</template>
<script setup lang="ts">
import SongItem from '@/components/shared/SongItem.vue'
import { dropSources } from '@/enums'
import { Track } from '@/interfaces'
import { dropSources } from '@/enums'
import SongItem from '@/components/shared/SongItem.vue'
import { isMedium, isSmall } from '@/stores/content-width'
defineProps<{

View File

@@ -0,0 +1,167 @@
<template>
<div class="text-loader" :style="{ '--duration': `${duration}ms`, '--fade-duration': `${fadeDuration}ms` }">
<div class="sizer">
{{ largestTextItem.text }}
</div>
<div
v-for="(textItem, index) in textItems"
:key="textItem.key"
:class="[
'text-item ellip',
{
active: index === activeIndex,
prev: index === prevIndex,
'slide-up': direction === 'up',
'slide-down': direction === 'down',
},
]"
>
{{ textItem.text }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
text: string
direction?: 'up' | 'down'
duration?: number
fadeDuration?: number
}>(),
{
direction: 'up',
duration: 500,
fadeDuration: 500,
}
)
const textKey = ref(0)
const prevIndex = ref(-1)
const activeIndex = ref(0)
const textItems = ref<Array<{ text: string; key: number }>>([])
const largestTextItem = computed(() => {
// create dom elements for each text item and get the width of the text
const tempElems = textItems.value.map(item => {
const tempElem = document.createElement('span')
tempElem.innerText = item.text
document.body.appendChild(tempElem)
const width = tempElem.offsetWidth
document.body.removeChild(tempElem)
return { text: item.text, key: item.key, width }
})
return tempElems.reduce((max, item) => {
return item.width > max.width ? item : max
}, tempElems[0])
})
watch(
() => props.text,
newText => {
if (newText === undefined || newText === null) return
const currentText = textItems.value[activeIndex.value]?.text
if (currentText === newText) return
const isInitialRender = textItems.value.length === 0
const newTextItem = {
text: newText,
key: textKey.value++,
}
if (isInitialRender) {
textItems.value.push(newTextItem)
activeIndex.value = 0
prevIndex.value = -1
return
}
prevIndex.value = activeIndex.value
textItems.value.push(newTextItem)
if (textItems.value.length > 2) {
textItems.value.shift()
prevIndex.value = activeIndex.value - 1
}
setTimeout(() => {
activeIndex.value = textItems.value.length - 1
}, 10)
setTimeout(() => {
if (textItems.value.length > 1) {
textItems.value = textItems.value.slice(-1)
activeIndex.value = 0
prevIndex.value = -1
}
}, props.duration)
},
{ immediate: true }
)
</script>
<style lang="scss">
.text-loader {
position: relative;
overflow: hidden;
width: fit-content;
height: 100%;
display: flex;
align-items: center;
.sizer {
opacity: 0;
width: fit-content;
pointer-events: none;
visibility: hidden;
}
.text-item {
position: absolute;
width: fit-content;
opacity: 0;
transition: transform var(--duration) cubic-bezier(0.68, -0.55, 0.265, 1.55),
opacity var(--fade-duration) ease-out;
&.active {
opacity: 1;
}
&.prev {
opacity: 0;
}
&.slide-up {
&.active {
transform: translateY(0);
}
&.prev {
transform: translateY(-100%);
}
&:not(.active):not(.prev) {
transform: translateY(100%);
}
}
&.slide-down {
&.active {
transform: translateY(0);
}
&.prev {
transform: translateY(100%);
}
&:not(.active):not(.prev) {
transform: translateY(-100%);
}
}
}
}
</style>

View File

@@ -1,211 +1,225 @@
<template>
<div
v-wave="{
duration: 0.35,
}"
class="track-item"
:class="[
{
currentInQueue: isCurrent,
},
{ contexton: context_on },
]"
@click="playThis(track)"
@contextmenu.prevent="showMenu"
>
<div class="album-art">
<img :src="paths.images.thumb.small + track.image" class="rounded-sm" />
<div v-if="isCurrent" class="now-playing-track-indicator image" :class="{ last_played: !isCurrentPlaying }"></div>
<div
v-wave="{
duration: 0.35,
}"
class="track-item"
:class="[
{
currentInQueue: isCurrent,
},
{ contexton: context_on },
]"
@click="playThis(track)"
@contextmenu.prevent="showMenu"
>
<div class="album-art">
<img :src="paths.images.thumb.small + track.image" />
<div
v-if="isCurrent"
class="now-playing-track-indicator image"
:class="{ last_played: !isCurrentPlaying }"
></div>
</div>
<div class="tags">
<div v-tooltip class="title">
<span class="ellip">
{{ track.title }}
</span>
</div>
<hr />
<div class="artist">
<ArtistName :artists="track.artists" :albumartists="track.albumartists" :smaller="true" />
</div>
</div>
<div class="float-buttons flex">
<div
class="fav-icon"
:title="is_fav ? 'Add to favorites' : 'Remove from favorites'"
@click.stop="() => addToFav(track.trackhash)"
>
<HeartSvg :state="is_fav" :no_emit="true" />
</div>
<div
v-if="isQueueTrack"
class="remove-track"
title="Remove from queue"
@click.stop="player.removeByIndex(index)"
>
<DelSvg />
</div>
</div>
</div>
<div class="tags">
<div v-tooltip class="title">
<span class="ellip">
{{ track.title }}
</span>
</div>
<hr />
<div class="artist">
<ArtistName :artists="track.artists" :albumartists="track.albumartists" :smaller="true" />
</div>
</div>
<div class="float-buttons flex">
<div
class="fav-icon"
:title="is_fav ? 'Add to favorites' : 'Remove from favorites'"
@click.stop="() => addToFav(track.trackhash)"
>
<HeartSvg :state="is_fav" :no_emit="true" />
</div>
<div v-if="isQueueTrack" class="remove-track" title="Remove from queue" @click.stop="player.removeByIndex(index)">
<DelSvg />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
import { onBeforeUnmount, ref, watch } from 'vue'
import useTracklist from "@/stores/queue/tracklist";
import useColor from '@/stores/colors'
import useTracklist from '@/stores/queue/tracklist'
import { paths } from "@/config";
import { favType } from "@/enums";
import { showTrackContextMenu as showContext } from "@/helpers/contextMenuHandler";
import favoriteHandler from "@/helpers/favoriteHandler";
import { Track } from "@/interfaces";
import { paths } from '@/config'
import { favType } from '@/enums'
import { showTrackContextMenu as showContext } from '@/helpers/contextMenuHandler'
import favoriteHandler from '@/helpers/favoriteHandler'
import { Track } from '@/interfaces'
import DelSvg from "@/assets/icons/plus.svg";
import ArtistName from "./ArtistName.vue";
import HeartSvg from "./HeartSvg.vue";
import DelSvg from '@/assets/icons/plus.svg'
import ArtistName from './ArtistName.vue'
import HeartSvg from './HeartSvg.vue'
import { getBackgroundColor, getTextColor } from '@/utils/colortools/shift'
const props = defineProps<{
track: Track;
isCurrent: boolean;
isCurrentPlaying: boolean;
isQueueTrack?: boolean;
index?: number;
}>();
track: Track
isCurrent: boolean
isCurrentPlaying: boolean
isQueueTrack?: boolean
index?: number
}>()
const player = useTracklist();
const context_on = ref(false);
const is_fav = ref(props.track.is_favorite);
const player = useTracklist()
const colors = useColor()
const context_on = ref(false)
const is_fav = ref(props.track.is_favorite)
function showMenu(e: MouseEvent) {
showContext(e, props.track, context_on);
showContext(e, props.track, context_on)
}
const emit = defineEmits<{
(e: "playThis"): void;
}>();
(e: 'playThis'): void
}>()
const playThis = (track: Track) => {
emit("playThis");
};
emit('playThis')
}
function addToFav(trackhash: string) {
favoriteHandler(
is_fav.value,
favType.track,
trackhash,
() => (is_fav.value = true),
() => (is_fav.value = false)
);
favoriteHandler(
is_fav.value,
favType.track,
trackhash,
() => (is_fav.value = true),
() => (is_fav.value = false)
)
}
const stop = watch(
() => props.track.is_favorite,
(newValue) => {
is_fav.value = newValue;
}
);
() => props.track.is_favorite,
newValue => {
is_fav.value = newValue
}
)
onBeforeUnmount(() => {
stop();
});
stop()
})
</script>
<style lang="scss">
.track-item.currentInQueue {
background-color: $gray4;
background-color: $gray5;
color: rgb(229, 229, 229);
}
.contexton {
background-color: $gray4;
color: $white !important;
background-color: $gray4;
color: $white !important;
}
.track-item {
display: grid;
grid-template-columns: min-content 1fr max-content;
align-items: center;
padding: $small 1rem;
transition: background-color 0.2s ease-out;
display: grid;
grid-template-columns: min-content 1fr max-content;
align-items: center;
padding: $small;
transition: background-color 0.2s ease-out;
border-radius: 8px;
.tags {
.title {
width: fit-content;
font-weight: 600;
}
}
.float-buttons {
opacity: 0;
gap: $small;
& > * {
cursor: pointer;
.tags {
.title {
width: fit-content;
font-weight: 600;
}
}
.heart-button {
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background-color: transparent;
.float-buttons {
opacity: 0;
gap: $small;
& > * {
cursor: pointer;
}
svg {
color: white;
}
}
.heart-button {
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: none;
background-color: transparent;
}
.remove-track {
transform: rotate(45deg);
height: 2rem;
width: 2rem;
.remove-track {
transform: rotate(45deg);
height: 1.75rem;
width: 1.75rem;
display: grid;
place-items: center;
display: grid;
place-items: center;
&:hover {
border-radius: 1rem;
}
&:hover {
border-radius: 1rem;
}
}
&:hover {
opacity: 1 !important;
}
}
&:hover {
opacity: 1 !important;
}
}
background-color: $gray4;
color: $white !important;
&:hover {
.float-buttons {
opacity: 1;
.float-buttons {
opacity: 1;
}
.remove-track {
transform: translateY(0) rotate(45deg);
}
}
.remove-track {
transform: translateY(0) rotate(45deg);
hr {
border: none;
margin: 0.1rem;
}
background-color: $gray5;
}
.album-art {
display: flex;
align-items: center;
justify-content: center;
hr {
border: none;
margin: 0.1rem;
}
margin-right: $medium;
position: relative;
.album-art {
display: flex;
align-items: center;
justify-content: center;
img {
border-radius: 4px;
}
margin-right: $medium;
position: relative;
.now-playing-track-indicator {
position: absolute;
.now-playing-track-indicator {
position: absolute;
}
}
}
img {
width: 3rem;
height: 3rem;
object-fit: contain;
}
img {
width: 3rem;
height: 3rem;
object-fit: contain;
}
.artist {
opacity: 0.67;
width: fit-content;
font-weight: 700;
}
.artist {
opacity: 0.67;
width: fit-content;
font-weight: 700;
}
}
</style>

150
src/composables/waiter.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Thrown when a Waiter event times out
*/
export class TimeoutError extends Error {
constructor(message: string) {
super(message)
this.name = 'TimeoutError'
}
}
/**
* Thrown when a Waiter event is aborted
*/
export class AbortError extends Error {
constructor(message: string) {
super(message)
this.name = 'AbortError'
}
}
// Bun does not support NodeJS.Timeout, so we need to create a type for it
type TimeoutType = ReturnType<typeof setTimeout>
/** Another possible solution
type TimeoutType = typeof globalThis extends { setTimeout: any }
? ReturnType<typeof setTimeout>
: any
*/
type WaitEntry = {
resolve: (value: any) => void
reject: (reason?: any) => void
promise: Promise<any>
timeoutId: TimeoutType
id: string
/**
* Optional state data to be associated with this wait entry
*/
data?: any
}
export class Waiter {
static preHeld: Map<string, null> = new Map()
static waitList: Map<string, WaitEntry> = new Map()
static keys = {
ONBOARDING_COMPLETE: 'onboardingComplete',
// etc
}
/**
* Registers a waitable event with a given id
*
* @param id - The id of the event to wait for
* @param timeout - The timeout for the event (null for infinite wait)
* @returns The data of the resolved event
*/
static async wait<T = any>(id: string, timeout: number | null = 10000): Promise<T> {
if (Waiter.waitList.has(id)) {
throw new Error(`[WAITER] Already waiting for id: ${id}`)
}
if (Waiter.preHeld.has(id)) {
console.log(`[WAITER] Found pre-held key: ${id}`)
Waiter.preHeld.delete(id)
return null as T
}
const promise = new Promise<T>((resolve, reject) => {
let timeoutId: TimeoutType | null = null
if (timeout !== null) {
timeoutId = setTimeout(() => {
Waiter.waitList.delete(id)
reject(new TimeoutError(`[WAITER] Timeout waiting for response: ${id}`))
}, timeout) as TimeoutType
}
Waiter.waitList.set(id, {
resolve,
reject,
timeoutId: timeoutId as TimeoutType,
id,
promise: null as unknown as Promise<T>,
})
console.log(`[WAITER] Created wait entry for ${id}`)
})
const entry = Waiter.waitList.get(id)
if (!entry) {
throw new Error(`[WAITER] Wait entry not found for ${id}`)
}
entry.promise = promise
return promise
}
/**
* Resolves the Promise registered with a given id with the provided data
*
* @param id - The id of the promise to resolve
* @param data - The data to resolve the promise with
*/
static resolve<T>(id: string, data?: T): T | null {
const entry = Waiter.waitList.get(id)
if (!entry) {
console.warn(`[WAITER] No wait entry found for ${id}`)
return null
}
clearTimeout(entry.timeoutId)
Waiter.preHeld.delete(id)
Waiter.waitList.delete(id)
entry.resolve(data)
console.log(`[WAITER] Resolved wait entry for ${id}`)
return data || null
}
static preHold(id: string) {
Waiter.preHeld.set(id, null)
}
/**
* Throw an AbortError to abort an event with a given id.
*
* @param id - The id of the event to abort
*/
static abort(id: string) {
const entry = Waiter.waitList.get(id)
if (!entry) {
console.warn(`[WAITER] No wait entry found for ${id}`)
return
}
clearTimeout(entry.timeoutId)
Waiter.preHeld.delete(id)
Waiter.waitList.delete(id)
entry.reject(new AbortError(`[WAITER] Aborted wait entry for ${id}`))
}
/**
* Checks if there is a promise registered with a given id
*
* @param id - The id to check
* @returns Whether there is a promise registered with the id
*/
static isWaiting(id: string): boolean {
return Waiter.waitList.has(id)
}
}

View File

@@ -19,10 +19,11 @@ const baseImgUrl = base_url + '/img'
const imageRoutes = {
thumb: {
original: '/thumbnail/original/',
large: '/thumbnail/',
small: '/thumbnail/xsmall/',
smallish: '/thumbnail/small/',
medium: '/thumbnail/medium/',
smallish: '/thumbnail/small/',
small: '/thumbnail/xsmall/',
},
artist: {
large: '/artist/',
@@ -34,6 +35,7 @@ const imageRoutes = {
export const paths = {
api: {
onboardingData: '/onboarding-data',
favorites: '/favorites',
get favAlbums() {
return this.favorites + '/albums'
@@ -222,6 +224,7 @@ export const paths = {
smallish: baseImgUrl + imageRoutes.thumb.smallish,
large: baseImgUrl + imageRoutes.thumb.large,
medium: baseImgUrl + imageRoutes.thumb.medium,
original: baseImgUrl + imageRoutes.thumb.original,
},
artist: {
small: baseImgUrl + imageRoutes.artist.small,

View File

@@ -106,4 +106,5 @@ export interface DBSettings {
lastfmApiSecret: string;
lastfmSessionKey: string;
showPlaylistsInFolderView: boolean;
artistArticleAwareSorting: boolean;
}

View File

@@ -42,6 +42,9 @@ export interface Track extends AlbumDisc {
trend: 'rising' | 'falling' | 'stable'
is_new: boolean
}
color?: string
blurhash?: string
}
export interface Folder {
@@ -68,6 +71,7 @@ export interface Album {
type?: string
color?: string
blurhash?: string
copyright?: string
help_text?: string
time?: string
@@ -129,7 +133,8 @@ export interface Artist {
trackcount: number
albumcount: number
duration: number
color: string
color?: string
blurhash?: string
is_favorite?: boolean
help_text?: string
time?: string

View File

@@ -102,4 +102,22 @@ export async function sendPairRequest() {
url: paths.api.auth.pair,
method: 'GET',
})
}
}
export async function getOnboardingData() {
const res = await useAxios({
url: paths.api.onboardingData,
method: 'GET',
})
return res.data as {
adminExists: boolean
rootDirsSet: boolean
onboardingComplete: boolean
userHome?: string
/**
* format: "scan_batch_cleared: {current}/{total}"
*/
scanMessage?: `scan_batch_cleared: ${number}/${number}`
}
}

View File

@@ -28,6 +28,7 @@ const StatsView = () => import('@/views/Stats/main.vue')
const MixView = () => import('@/views/MixView.vue')
const MixListView = () => import('@/views/MixListView.vue')
const Collection = () => import('@/views/Collections/Collection.vue')
const Onboarding = () => import('@/views/Onboarding.vue')
const folder = {
path: '/folder/:path',
@@ -208,6 +209,13 @@ const PageView = {
component: Collection,
}
const OnboardingView = {
path: '/onboarding/:step?',
name: 'Onboarding',
alias: ['/manconfig/:step?'],
component: Onboarding,
}
const routes = [
folder,
playlists,
@@ -232,6 +240,7 @@ const routes = [
Mix,
MixList,
PageView,
OnboardingView,
]
const Routes = {
@@ -258,6 +267,7 @@ const Routes = {
Mix: Mix.name,
MixList: MixList.name,
Page: PageView.name,
Onboarding: OnboardingView.name,
}
const router = createRouter({

View File

@@ -4,7 +4,7 @@ import { SettingType } from '../enums'
import useSettingsStore from '@/stores/settings'
import { updateConfig } from '@/requests/settings'
export default <Setting>{
const separators = <Setting>{
title: 'Enter separators separated by a comma',
desc: `These will be used to separate artists and album artists`,
state: () => {
@@ -21,7 +21,7 @@ export default <Setting>{
action: async (payload: string) => {
if (!payload) return
const { status } = await updateConfig("artistSeparators", payload)
const { status } = await updateConfig('artistSeparators', payload)
if (status == 200) {
useSettingsStore().setArtistSeparators(payload.split(','))
@@ -31,3 +31,13 @@ export default <Setting>{
},
type: SettingType.separators_input,
}
const articleAwareSorting = <Setting>{
title: 'Article aware sorting',
desc: "Ignore articles (e.g. The, A, An) when sorting artist names",
type: SettingType.binary,
state: () => useSettingsStore().article_aware_sorting,
action: () => useSettingsStore().toggleArticleAwareSorting(),
}
export default [articleAwareSorting, separators]

View File

@@ -12,7 +12,7 @@ import folderlistmode from './folderlistmode'
import layout from './layout'
import nowPlaying from './now-playing-group'
import rootDirSettings from './root-dirs'
import separators from './separators'
import artistSettings from './artists'
import sidebarSettings from './sidebar'
import tracks from './tracks'
// icons
@@ -50,7 +50,7 @@ export const library = {
show_if: loggedInUserIsAdmin,
groups: [
{
title: "Folders",
title: 'Folders',
icon: FolderSvg,
desc: rootRootStrings.desc,
settings: [...rootDirSettings],
@@ -77,14 +77,14 @@ export const library = {
title: 'Artists',
icon: AvatarSvg,
desc: 'Customize artist separators',
settings: [separators],
settings: [...artistSettings],
},
{
title: "Backup",
title: 'Backup',
icon: AvatarSvg,
desc: "Backup and restore your settings",
desc: 'Backup and restore your settings',
settings: [...restore],
}
},
],
} as SettingCategory

View File

@@ -5,6 +5,7 @@ import { manageRootDirsStrings as data } from '../strings'
import useModalStore from '@/stores/modal'
import settings from '@/stores/settings'
import { router, Routes } from '@/router'
const text = data.settings
@@ -12,8 +13,11 @@ const change_root_dirs: Setting = {
title: text.change,
type: SettingType.button,
state: null,
button_text: () => `\xa0 \xa0 ${settings().root_dirs.length ? 'Modify' : 'Configure'} \xa0 \xa0`,
action: () => useModalStore().showRootDirsPromptModal(),
button_text: () => `\xa0 \xa0 ${settings().root_dirs.length ? 'Update' : 'Configure'} \xa0 \xa0`,
action: () => {
useModalStore().hideModal()
return router.push({ path: '/manconfig/dirconfig' })
},
}
const list_root_dirs: Setting = {

View File

@@ -1,28 +1,49 @@
import { defineStore } from "pinia";
import Vibrant from "node-vibrant";
import listToRgbString from "@/utils/colortools/listToRgbString";
import { defineStore } from 'pinia'
import Vibrant from 'node-vibrant'
import listToRgbString from '@/utils/colortools/listToRgbString'
async function getImageColor(url: string) {
const vibrant = new Vibrant(url);
const vib = new Vibrant(url)
const palette = await vibrant.getPalette();
const lightvibrant = listToRgbString(palette.LightVibrant?.getRgb()) || "";
const darkvibrant = listToRgbString(palette.Muted?.getRgb()) || "";
const palette = await vib.getPalette()
const colors = [
palette.LightMuted,
palette.DarkMuted,
palette.DarkVibrant,
palette.Vibrant,
palette.LightVibrant,
palette.Muted,
].map(color => listToRgbString(color?.getRgb()) || '')
return { lightvibrant, darkvibrant };
return {
lightMuted: colors[0],
darkMuted: colors[1],
darkVibrant: colors[2],
vibrant: colors[3],
lightVibrant: colors[4],
muted: colors[5],
}
}
export default defineStore("SwingMusicColors", {
state: () => ({
theme1: "",
theme2: "",
}),
actions: {
async setTheme1Color(url: string) {
const { lightvibrant, darkvibrant} = await getImageColor(url);
this.theme1 = lightvibrant;
this.theme2 = darkvibrant
export default defineStore('SwingMusicColors', {
state: () => ({
lightMuted: '',
darkMuted: '',
darkVibrant: '',
vibrant: '',
lightVibrant: '',
muted: '',
}),
actions: {
async setTheme1Color(url: string) {
const { lightMuted, darkMuted, darkVibrant, vibrant, lightVibrant, muted } = await getImageColor(url)
this.lightMuted = lightMuted
this.darkMuted = darkMuted
this.darkVibrant = darkVibrant
this.vibrant = vibrant
this.lightVibrant = lightVibrant
this.muted = muted
},
},
},
persist: true,
});
persist: true,
})

52
src/stores/events.ts Normal file
View File

@@ -0,0 +1,52 @@
interface EventData {
event: string
data: {
[key: string]: any
}
timestamp: number
}
class Events {
private worker: Worker
private events: { [event: string]: (data: any) => any } = {}
constructor() {
console.log('Events constructor')
this.worker = new Worker(new URL('/workers/sse-events.js', import.meta.url))
this.worker.postMessage({
command: "connect",
options: {
maxReconnectAttempts: 10,
reconnectDelay: 1000,
maxReconnectDelay: 30000,
},
})
this.worker.onmessage = (event) => {
const { data } = event.data as { data: EventData }
if (!data) return
const eventType = data.event
const callback = this.events[eventType]
if (callback) {
callback(data.data)
}
}
}
async subscribe(eventName: string, callback: (data: any) => any) {
this.events[eventName] = callback
}
unsubscribe(event: string, callback?: ((data: any) => any)) {
delete this.events[event]
if (callback) {
callback(null)
}
}
}
const events = new Events()
export default events

View File

@@ -29,7 +29,7 @@ export default defineStore('homepage', () => {
}
async function fetchAll() {
const data: { [key: string]: HomePageItem }[] = await getHomePageData(maxAbumCards.value)
const data: { [key: string]: HomePageItem }[] = await getHomePageData(maxAbumCards.value + 2)
let keys = []
for (const [index, item] of data.entries()) {

View File

@@ -1,27 +1,32 @@
import { Ref } from "vue";
import useQueue from "./queue";
import { defineStore } from "pinia";
import { Ref } from 'vue'
import useQueue from './queue'
import { defineStore } from 'pinia'
export default defineStore("interfaceStore", {
state: () => ({
queueScrollFunction: (index: number) => {},
mousover: <Ref | null>null,
}),
actions: {
focusCurrentInSidebar(timeout = 500) {
const { currentindex } = useQueue();
if (!this.mousover) {
setTimeout(() => {
this.queueScrollFunction(currentindex - 1);
}, timeout);
}
export default defineStore('interfaceStore', {
state: () => ({
queueScrollFunction: (index: number) => {},
mousover: <Ref | null>null,
hideUI: false,
onboardingStep: 0,
}),
actions: {
focusCurrentInSidebar(timeout = 500) {
const { currentindex } = useQueue()
if (!this.mousover) {
setTimeout(() => {
this.queueScrollFunction(currentindex - 1)
}, timeout)
}
},
setScrollFunction(cb: (index: number) => void, mousover: Ref<boolean> | null) {
this.queueScrollFunction = cb
this.mousover = mousover
},
setHideUi(hide: boolean) {
this.hideUI = hide
},
setOnboardingStep(step: number) {
this.onboardingStep = step
},
},
setScrollFunction(
cb: (index: number) => void,
mousover: Ref<boolean> | null
) {
this.queueScrollFunction = cb;
this.mousover = mousover;
},
},
});
})

View File

@@ -1,196 +1,200 @@
import { defineStore } from "pinia";
import { defineStore } from 'pinia'
import useLyricsPlugin from "./plugins/lyrics";
import useQueue from "./queue";
import useSettings from "./settings";
import useLyricsPlugin from './plugins/lyrics'
import useQueue from './queue'
import useSettings from './settings'
import { LyricsLine } from "@/interfaces";
import { checkExists, getLyrics } from "@/requests/lyrics";
import { Routes, router } from "@/router";
import { LyricsLine } from '@/interfaces'
import { checkExists, getLyrics } from '@/requests/lyrics'
import { Routes, router } from '@/router'
// a custom error class called HasNoSyncedLyricsError
class HasUnSyncedLyricsError extends Error {
constructor() {
super("Lyrics are not synced");
this.name = "HasNoSyncedLyricsError";
}
constructor() {
super('Lyrics are not synced')
this.name = 'HasNoSyncedLyricsError'
}
}
export default defineStore("lyrics", {
state: () => ({
lyrics: <LyricsLine[]>[],
currentLine: -1,
ticking: false,
currentTrack: "",
exists: false,
synced: true,
copyright: "",
user_scrolled: false,
}),
actions: {
async getLyrics(force = false) {
const queue = useQueue();
const track = queue.currenttrack;
export default defineStore('lyrics', {
state: () => ({
lyrics: <LyricsLine[]>[],
currentLine: -1,
ticking: false,
currentTrack: '',
exists: false,
synced: true,
copyright: '',
user_scrolled: false,
}),
actions: {
async getLyrics(force = false) {
const queue = useQueue()
const track = queue.currenttrack
if (!force && this.currentTrack === track.trackhash) {
this.sync();
return;
}
if (!force && this.currentTrack === track.trackhash) {
this.sync()
return
}
this.currentLine = -1;
this.copyright = "";
this.synced = true;
this.currentLine = -1
this.copyright = ''
this.synced = true
getLyrics(track.filepath, track.trackhash)
.then((data) => {
this.currentTrack = track.trackhash;
getLyrics(track.filepath, track.trackhash)
.then(data => {
this.currentTrack = track.trackhash
if (data.error) {
throw new Error(data.error);
}
if (data.error) {
throw new Error(data.error)
}
this.synced = data.synced;
this.lyrics = data.lyrics;
this.copyright = data.copyright;
this.exists = true;
this.synced = data.synced
this.lyrics = data.lyrics
this.copyright = data.copyright
this.exists = true
if (this.lyrics.length && !this.synced) {
throw new HasUnSyncedLyricsError();
}
})
.then(async () => {
const line = this.calculateCurrentLine();
if (this.lyrics.length && !this.synced) {
throw new HasUnSyncedLyricsError()
}
})
.then(async () => {
const line = this.calculateCurrentLine()
if (line == -1) {
return this.scrollToContainerTop();
}
if (line == -1) {
return this.scrollToContainerTop()
}
this.scrollToCurrentLine();
})
.catch((e) => {
const settings = useSettings();
const plugin = useLyricsPlugin();
this.scrollToCurrentLine()
})
.catch(e => {
const settings = useSettings()
const plugin = useLyricsPlugin()
// catch HasUnSyncedLyricsError instance
if (e instanceof HasUnSyncedLyricsError) {
if (!settings.lyrics_plugin_settings.overide_unsynced) return;
plugin.searchLyrics();
}
// catch HasUnSyncedLyricsError instance
if (e instanceof HasUnSyncedLyricsError) {
if (!settings.lyrics_plugin_settings.overide_unsynced) return
plugin.searchLyrics()
}
this.exists = false;
this.lyrics = <LyricsLine[]>[];
this.copyright = "";
this.exists = false
this.lyrics = <LyricsLine[]>[]
this.copyright = ''
if (settings.lyrics_plugin_settings.auto_download) {
plugin.searchLyrics();
}
});
if (settings.lyrics_plugin_settings.auto_download) {
plugin.searchLyrics()
}
})
},
scrollToContainerTop() {
const container = document.getElementById('scrollbale')
if (container) {
container.scroll({
top: 0,
behavior: 'smooth',
})
}
},
checkExists(filepath: string, trackhash: string) {
if (!this.onLyricsPage) {
this.lyrics = <LyricsLine[]>[]
}
checkExists(filepath, trackhash).then(data => {
this.exists = data.exists
})
},
setNextLineTimer(duration: number) {
this.ticking = true
setTimeout(() => {
if (useQueue().playing) {
this.currentLine++
this.ticking = false
this.scrollToCurrentLine()
}
}, duration - 300)
},
setCurrentLine(line: number, scroll = true, delay: number = 400) {
this.currentLine = line
this.ticking = false
if (!scroll) return
setTimeout(() => {
this.scrollToCurrentLine(this.currentLine, scroll)
}, delay)
},
scrollToCurrentLine(line: number = -1, forceScroll: boolean = false) {
let lineToScroll = this.currentLine
if (line >= 0) {
lineToScroll = line
}
const third = window.innerHeight / 3
const two_thirds = third * 2
const elem = document.getElementById(`lyricsline-${lineToScroll}`)
if (!elem) return
const { y } = elem.getBoundingClientRect()
if (!forceScroll && this.user_scrolled && (y < third || y > two_thirds)) {
return
}
this.setUserScrolled(false)
elem.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'start',
})
},
calculateCurrentLine() {
if (!this.lyrics.length) return -1
const queue = useQueue()
const duration = queue.duration.current
if (!this.synced || !this.lyrics) return -1
const millis = duration * 1000
const closest = this.lyrics.reduce((prev, curr) => {
return Math.abs(curr.time - millis) < Math.abs(prev.time - millis) ? curr : prev
})
return this.lyrics.indexOf(closest) - 1
},
sync() {
const line = this.calculateCurrentLine()
this.setCurrentLine(line)
},
setLyrics(lyrics: LyricsLine[]) {
this.lyrics = lyrics
this.synced = true
this.exists = true
this.currentTrack = useQueue().currenttrackhash
this.setCurrentLine(this.currentLine)
},
setUserScrolled(value: boolean) {
this.user_scrolled = value
},
},
scrollToContainerTop() {
const container = document.getElementById("lyricscontent");
getters: {
nextLineTime(): number {
if (!this.lyrics) return 0
if (this.lyrics.length > this.currentLine + 1) {
return this.lyrics[this.currentLine + 1].time
}
if (container) {
container.scroll({
top: 0,
behavior: "smooth",
});
}
return 0
},
onLyricsPage(): boolean {
return (
router.currentRoute.value.name === Routes.nowPlaying &&
router.currentRoute.value.params.tab === 'lyrics'
)
},
},
checkExists(filepath: string, trackhash: string) {
if (router.currentRoute.value.name !== Routes.Lyrics) {
this.lyrics = <LyricsLine[]>[];
}
checkExists(filepath, trackhash).then((data) => {
this.exists = data.exists;
});
},
setNextLineTimer(duration: number) {
this.ticking = true;
setTimeout(() => {
if (useQueue().playing) {
this.currentLine++;
this.ticking = false;
this.scrollToCurrentLine();
}
}, duration - 300);
},
setCurrentLine(line: number, scroll = true) {
this.currentLine = line;
this.ticking = false;
if (!scroll) return;
setTimeout(() => {
this.scrollToCurrentLine();
}, 400);
},
scrollToCurrentLine(line: number = -1) {
let lineToScroll = this.currentLine;
if (line >= 0) {
lineToScroll = line;
}
const third = window.innerHeight / 3;
const two_thirds = third * 2;
const elem = document.getElementById(`lyricsline-${lineToScroll}`);
if (!elem) return;
const { y } = elem.getBoundingClientRect();
if (this.user_scrolled && (y < third || y > two_thirds)) {
return;
}
this.setUserScrolled(false);
elem.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "start",
});
},
calculateCurrentLine() {
if (!this.lyrics.length) return -1;
const queue = useQueue();
const duration = queue.duration.current;
if (!this.synced || !this.lyrics) return -1;
const millis = duration * 1000;
const closest = this.lyrics.reduce((prev, curr) => {
return Math.abs(curr.time - millis) < Math.abs(prev.time - millis)
? curr
: prev;
});
return this.lyrics.indexOf(closest) - 1;
},
sync() {
const line = this.calculateCurrentLine();
this.setCurrentLine(line);
},
setLyrics(lyrics: LyricsLine[]) {
this.lyrics = lyrics;
this.synced = true;
this.exists = true;
this.currentTrack = useQueue().currenttrackhash;
this.setCurrentLine(this.currentLine);
},
setUserScrolled(value: boolean) {
this.user_scrolled = value;
},
},
getters: {
nextLineTime(): number {
if (!this.lyrics) return 0;
if (this.lyrics.length > this.currentLine + 1) {
return this.lyrics[this.currentLine + 1].time;
}
return 0;
},
},
});
})

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia'
import useUI from '@/stores/interface'
export enum ModalOptions {
newPlaylist,
@@ -74,6 +75,12 @@ export default defineStore('newModal', {
this.showModal(ModalOptions.setRootDirs)
},
showLoginModal() {
if (useUI().hideUI) {
console.log('🙊 showLoginModal but hideUI is true')
return
}
console.log('🙉 showLoginModal')
this.showModal(ModalOptions.login)
},
showSettingsModal() {
@@ -86,5 +93,11 @@ export default defineStore('newModal', {
setTitle(new_title: string) {
this.title = new_title
},
resetModal() {
this.visible = false
this.title = ''
this.props = {}
this.component = null
},
},
})

View File

@@ -261,9 +261,9 @@ export const usePlayer = defineStore('player', () => {
updateMediaNotif()
colors.setTheme1Color(paths.images.thumb.small + queue.currenttrack.image)
if (router.currentRoute.value.name == Routes.Lyrics) {
return lyrics.getLyrics()
}
// if (router.currentRoute.value.name == Routes.nowPlaying) {
return lyrics.getLyrics()
// }
// if (!settings.use_lyrics_plugin) {
// lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash)
@@ -305,7 +305,9 @@ export const usePlayer = defineStore('player', () => {
}
const updateLyricsPosition = () => {
if (!lyrics.exists || router.currentRoute.value.name !== Routes.Lyrics) return
if (!lyrics.exists || !lyrics.onLyricsPage) {
return
}
const millis = Math.round(audio.currentTime * 1000)
const diff = lyrics.nextLineTime - millis
@@ -350,7 +352,7 @@ export const usePlayer = defineStore('player', () => {
const silence = e.data
if (!silence.ending_file){
if (!silence.ending_file) {
return
}

View File

@@ -23,6 +23,7 @@ export default defineStore('Queue', {
playing: false,
/** Whether track has been triggered manually */
manual: true,
direction: <'up' | 'down'>('up'),
}),
actions: {
setPlaying(val: boolean) {
@@ -44,6 +45,8 @@ export default defineStore('Queue', {
const { tracklist } = useTracklist()
if (tracklist.length === 0) return
this.direction = index > this.currentindex ? 'up' : 'down'
this.playing = true
this.currentindex = index
this.manual = manual
@@ -88,7 +91,11 @@ export default defineStore('Queue', {
const resetQueue = () => {
this.currentindex = 0
audioSource.playingSource.src = getUrl(this.next.filepath, this.next.trackhash, settings.use_legacy_streaming_endpoint)
audioSource.playingSource.src = getUrl(
this.next.filepath,
this.next.trackhash,
settings.use_legacy_streaming_endpoint
)
audioSource.pausePlayingSource()
this.playing = false
@@ -128,9 +135,9 @@ export default defineStore('Queue', {
}
}
if (router.currentRoute.value.name == Routes.Lyrics) {
if (lyrics.onLyricsPage) {
const line = lyrics.calculateCurrentLine()
lyrics.setCurrentLine(line)
lyrics.setCurrentLine(line, true, 0)
}
const player = usePlayer()

View File

@@ -40,6 +40,7 @@ export default defineStore('settings', {
show_albums_as_singles: false,
separators: <string[]>[],
show_playlists_in_folders: false,
article_aware_sorting: false,
// client
useCircularArtistImg: true,
@@ -89,6 +90,7 @@ export default defineStore('settings', {
this.separators = settings.artistSeparators
this.show_albums_as_singles = settings.showAlbumsAsSingles
this.show_playlists_in_folders = settings.showPlaylistsInFolderView
this.article_aware_sorting = settings.artistArticleAwareSorting
this.enablePeriodicScans = settings.enablePeriodicScans
this.periodicInterval = settings.scanInterval
@@ -295,6 +297,9 @@ export default defineStore('settings', {
async toggleMergeAlbums() {
return await this.genericToggleSetting('mergeAlbums', !this.merge_albums, 'merge_albums')
},
async toggleArticleAwareSorting() {
return await this.genericToggleSetting('artistArticleAwareSorting', !this.article_aware_sorting, 'article_aware_sorting')
},
async toggleShowAlbumsAsSingles() {
return await this.genericToggleSetting(

View File

@@ -1,8 +1,87 @@
import { brightness } from '@nextcss/color-tools'
import rgb2Hex from '@/utils/colortools/rgb2Hex'
import listToRgbString from '@/utils/colortools/listToRgbString'
function rgbToArray(rgb: string | null | undefined): number[] | null {
if (!rgb || typeof rgb !== 'string') {
return null;
}
try {
const match = rgb.match(/\d+/g);
if (!match || match.length < 3) {
return null;
}
return match.map(Number);
} catch (error) {
return null;
}
}
export function getTypeColor(color: string) {
const lightness = brightness(rgb2Hex(color))
const is_light = lightness > 50
return is_light ? 'rgb(109, 69, 16)' : '#ac8e68'
}
}
export function transitionColor(
color1: string | null | undefined,
color2: string | null | undefined,
durationMs: number,
onUpdate: (color: string) => void
): (() => void) | null {
const startArray = rgbToArray(color1);
const endArray = rgbToArray(color2);
if (!startArray && !endArray) {
return null;
}
if (!startArray) {
if (color2) {
onUpdate(color2);
}
return () => {};
}
if (!endArray) {
if (color1) {
onUpdate(color1);
}
return () => {};
}
if (startArray.length !== 3 || endArray.length !== 3) {
throw new Error('Invalid RGB color format: colors must have exactly 3 components');
}
const startTime = performance.now();
let animationFrameId: number | null = null;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / durationMs, 1);
const interpolated = startArray.map((start, index) => {
const end = endArray[index];
return start + (end - start) * progress;
});
const result = listToRgbString(interpolated);
if (result) {
onUpdate(result);
}
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate);
}
};
animationFrameId = requestAnimationFrame(animate);
return () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
};
}

View File

@@ -93,7 +93,7 @@ export default (source: From): PlayingFrom => {
case FromOptions.favorite:
return {
name: 'Favorite tracks',
name: 'Favorite Tracks',
icon: HeartSvg,
location: {
name: Routes.favoriteTracks,

View File

@@ -62,6 +62,8 @@ const scrollerItems = computed(() => {
]
);
console.log(maxAbumCards.value)
const groups = Math.ceil(storeitems.value.length / maxAbumCards.value);
for (let i = 0; i < groups; i++) {
items.push({
@@ -108,4 +110,3 @@ onBeforeRouteLeave(() => {
height: 100%;
}
</style>
@/stores/pages/itemlist

View File

@@ -7,7 +7,7 @@
{{ collection.collection?.name }} <span><PencilSvg height="0.8rem" width="0.8rem" /></span
></span>
</template>
<template #description v-if="collection.collection?.extra.description">
<template v-if="collection.collection?.extra.description" #description>
<span @click="updatePage"> {{ collection.collection?.extra.description }} </span>
</template>
<template #right>

View File

@@ -1,10 +1,10 @@
<template>
<CardGridPage :page="itemtype" :items="items" :fetch_callback="() => loadMore()">
<CardGridPage :page="itemtype" :items="items" :fetch_callback="() => loadMore()" :more-items="waitingForMore">
<template #header>
<GenericHeader>
<template #name
>Favorite <span style="text-transform: capitalize">{{ itemtype }}s</span></template
>
>Favorite <span style="text-transform: capitalize">{{ itemtype }}s</span>
</template>
<template #description
>You have {{ itemtype == 'album' ? albumCount : artistCount }} favorited
{{ itemtype + (items.length == 1 ? '' : 's') }}</template
@@ -25,11 +25,20 @@ import { getFavAlbums, getFavArtists } from '@/requests/favorite'
import CardGridPage from './SearchView/CardGridPage.vue'
import GenericHeader from '@/components/shared/GenericHeader.vue'
let albumCount = 0
let artistCount = 0
let waitingForMore = false
const route = useRoute()
const albumCount = ref(0)
const artistCount = ref(0)
const waitingForMore = computed(() => {
// return true if the count is less than the length of the items
if (route.name == Routes.favoriteAlbums) {
return albumCount.value > 0 && albums.value.length < albumCount.value
}
return artistCount.value > 0 && artists.value.length < artistCount.value
})
const albums: Ref<Album[]> = ref([])
const artists: Ref<Artist[]> = ref([])
@@ -49,39 +58,39 @@ const items = computed(() => {
return artists.value
})
async function loadMore() {
async function loadMore(initialize?: boolean) {
let counter: number
if (itemtype.value == 'artist') {
counter = artistCount
counter = artistCount.value
} else {
counter = albumCount
counter = albumCount.value
}
if (waitingForMore || (counter && counter <= items.value.length)) return
// if (waitingForMore || (counter && counter <= items.value.length)) return
if (!initialize && !waitingForMore.value) return
waitingForMore = true
const start = items.value.length ? 50 + items.value.length : 0
const data = await (itemtype.value == 'artist' ? getFavArtists(start, 50) : getFavAlbums(start, 50))
// start at the end of the current items
const limit = 50
const start = items.value.length
const data = await (itemtype.value == 'artist' ? getFavArtists(start, limit) : getFavAlbums(start, limit))
if (data.total !== -1) {
if (itemtype.value == 'artist') {
artistCount = data.total
artistCount.value = data.total
} else {
albumCount = data.total
albumCount.value = data.total
}
}
if (itemtype.value == 'artist') {
artists.value.push(...(data.artists as Artist[]))
artists.value.push(...(data as any).artists)
} else {
albums.value.push(...(data.albums as Album[]))
albums.value.push(...(data as any).albums)
}
waitingForMore = false
}
onMounted(async () => {
await loadMore()
await loadMore(true)
})
</script>

View File

@@ -18,7 +18,7 @@
class="scroller"
style="height: 100%"
>
<template #before v-if="is_alt_layout || isMedium || isSmall">
<template v-if="is_alt_layout || isMedium || isSmall" #before>
<Folder />
</template>

View File

@@ -1,79 +1,89 @@
<template>
<div v-if="queue.currenttrack" class="lyricsinfo" :style="{ background: bgColor }">
<RouterLink
:to="{
name: Routes.album,
params: {
albumhash: queue.currenttrack.albumhash,
},
}"
>
<img :src="paths.images.thumb.small + queue.currenttrack.image" class="shadow-sm" />
</RouterLink>
<div v-if="queue.currenttrack" class="lyricsinfo">
<RouterLink
:to="{
name: Routes.album,
params: {
albumhash: queue.currenttrack.albumhash,
},
}"
>
<ImageLoader
:image="paths.images.thumb.small + queue.currenttrack.image"
:blurhash="queue.currenttrack.blurhash"
:duration="1000"
img-class="rounded-xsm shadow-sm"
/>
</RouterLink>
<div class="text">
<div class="title ellip">{{ queue.currenttrack.title }}</div>
<ArtistName :artists="queue.currenttrack.artists" :albumartists="queue.currenttrack.albumartists" />
<div class="text">
<TextLoader :text="queue.currenttrack.title" :duration="1000" :fade-duration="1000" class="" />
<ArtistName :artists="queue.currenttrack.artists" :albumartists="queue.currenttrack.albumartists" />
</div>
<div class="right">
<div v-if="lyrics.lyrics.length && !lyrics.synced" class="lyricstype">unsynced</div>
</div>
</div>
<div class="right">
<div v-if="lyrics.lyrics.length && !lyrics.synced" class="lyricstype">unsynced</div>
</div>
</div>
</template>
<script setup lang="ts">
import useLyrics from "@/stores/lyrics";
import useQueue from "@/stores/queue";
import useLyrics from '@/stores/lyrics'
import useQueue from '@/stores/queue'
import ArtistName from "@/components/shared/ArtistName.vue";
import { paths } from "@/config";
import { Routes } from "@/router";
import ArtistName from '@/components/shared/ArtistName.vue'
import { paths } from '@/config'
import { Routes } from '@/router'
import ImageLoader from '@/components/shared/ImageLoader.vue'
import TextLoader from '@/components/shared/TextLoader.vue'
const queue = useQueue();
const lyrics = useLyrics();
defineProps<{
bgColor: string;
}>();
const queue = useQueue()
const lyrics = useLyrics()
</script>
<style lang="scss">
.lyricsinfo {
padding: 2rem 0 1rem 0;
font-size: 1rem;
display: grid;
grid-template-columns: max-content 1fr max-content;
gap: $medium;
align-items: center;
position: sticky;
top: 0;
z-index: 1;
padding: 2rem 0 1rem 0;
font-size: 1rem;
display: grid;
grid-template-columns: max-content 1fr max-content;
gap: $medium;
align-items: center;
@include allPhones {
padding: $large 0;
margin-bottom: -$small;
}
@include allPhones {
padding: $large 0;
margin-bottom: -$small;
}
img {
display: block;
height: 2.5rem;
border-radius: $smaller;
}
.image-loader {
display: block;
width: 2.5rem;
height: 2.5rem;
border-radius: $smaller;
.title {
font-size: 0.85rem;
}
img {
height: 2.5rem;
}
}
.artist {
font-size: 0.8rem;
}
.text-loader {
height: 1rem;
font-size: 0.8rem;
}
.lyricstype {
border-radius: $smaller;
font-size: 12px;
padding: $smaller $small;
background-color: $white;
color: $black;
}
// .title {
// font-size: 0.85rem;
// }
.artist {
font-size: 0.8rem;
}
.lyricstype {
border-radius: $smaller;
font-size: 12px;
padding: $smaller $small;
background-color: $white;
color: $black;
}
}
</style>

View File

@@ -1,158 +1,221 @@
<template>
<div class="lyricsview content-page">
<div
v-if="queue.currenttrack"
id="lyricscontent"
:style="{ background: bgColor }"
class="content-page rounded"
@wheel.passive="onScroll"
>
<LyricsHead :bg-color="bgColor" />
<div v-if="lyrics.synced" class="synced">
<div id="lyricsline--1"></div>
<div
v-for="(line, index) in lyrics.lyrics"
:id="`lyricsline-${index}`"
:key="line.time"
class="line"
:class="{
currentLine: index == lyrics.currentLine,
seenLine: index < lyrics.currentLine,
opacity_25: index <= lyrics.currentLine - 3,
opacity_5: index == lyrics.currentLine - 2,
opacity_75: index == lyrics.currentLine - 1,
}"
@click="queue.seek(line.time / 1000)"
>
{{ line.text }}
<div v-if="queue.currenttrack && lyrics.lyrics.length" id="lyricscontent" @wheel.passive="onScroll">
<LyricsHead />
<div id="scrollbale">
<div v-if="lyrics.lyrics.length && lyrics.synced" id="np-lyrics-synced">
<div id="lyricsline--1"></div>
<div
v-for="(line, index) in lyrics.lyrics"
:id="`lyricsline-${index}`"
:key="line.time"
class="line"
:class="{
currentLine: index == lyrics.currentLine,
seenLine: index < lyrics.currentLine,
opacity_25: index <= lyrics.currentLine - 3,
opacity_5: index == lyrics.currentLine - 2,
opacity_75: index == lyrics.currentLine - 1,
}"
@click="queue.seek(line.time / 1000)"
>
{{ line.text }}
</div>
<div v-if="lyrics.copyright && lyrics.lyrics" class="copyright">
{{ lyrics.copyright }}
</div>
<div class="spacer"></div>
</div>
<div v-if="!lyrics.synced" class="unsynced">
<div id="lyricsline--1"></div>
<div v-for="(line, index) in lyrics.lyrics" :key="index" class="line">
{{ line }}
</div>
<div class="spacer"></div>
</div>
</div>
</div>
<div v-if="!lyrics.synced" class="unsynced">
<div id="lyricsline--1"></div>
<div v-for="(line, index) in lyrics.lyrics" :key="index" class="line">
{{ line }}
</div>
</div>
<div v-if="lyrics.copyright && lyrics.lyrics" class="copyright">
{{ lyrics.copyright }}
</div>
<div v-if="!lyrics.lyrics || lyrics.lyrics.length == 0" class="nolyrics">
</div>
<div v-if="!lyrics.lyrics || lyrics.lyrics.length == 0" class="nolyrics">
<LyricsHead />
<div>You don't have</div>
<div>the lyrics for this song</div>
<!-- <div class="trackinfo">
{{ queue.currenttrack.title }}
</div> -->
<PluginFind v-if="settings.use_lyrics_plugin" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { computed, onMounted } from 'vue'
import useColors from "@/stores/colors";
import useLyrics from "@/stores/lyrics";
import useQueue from "@/stores/queue";
import useSettings from "@/stores/settings";
import useLyrics from '@/stores/lyrics'
import useQueue from '@/stores/queue'
import useSettings from '@/stores/settings'
import useColor from '@/stores/colors'
import { getShift } from "@/utils/colortools/shift";
import LyricsHead from './Head.vue'
import PluginFind from './Plugins/Find.vue'
import { getBackgroundColor, getShift } from '@/utils/colortools/shift'
import LyricsHead from "./Head.vue";
import PluginFind from "./Plugins/Find.vue";
const queue = useQueue()
const colors = useColor()
const lyrics = useLyrics()
const settings = useSettings()
const queue = useQueue();
const lyrics = useLyrics();
const colors = useColors();
const settings = useSettings();
const textColor = computed(() => {
return getBackgroundColor(colors.darkVibrant)
})
const seenLineColor = computed(() => {
return getShift(textColor.value, [30, -70])
})
const nextLineColor = computed(() => {
return getShift(textColor.value, [10, -25])
})
const onScroll = (e: Event) => {
lyrics.setUserScrolled(true);
};
const bgColor = computed(() => {
return getShift(colors.theme2, [-20, -20]);
});
lyrics.setUserScrolled(true)
}
function fetchLyrics() {
lyrics.getLyrics();
lyrics.getLyrics()
}
onMounted(() => {
if (!queue.currenttrack) return;
fetchLyrics();
lyrics.scrollToCurrentLine();
});
if (!queue.currenttrack) return
fetchLyrics()
lyrics.scrollToCurrentLine()
})
</script>
<style lang="scss">
.lyricsview {
height: 100%;
padding-bottom: 1rem;
#lyricscontent,
.nolyrics {
font-weight: 700;
font-size: 3rem;
}
#lyricscontent {
padding: 0 2rem;
padding-bottom: 4rem;
height: 100%;
overflow: scroll;
background-color: rgb(122, 122, 122);
scroll-margin-top: 15rem;
font-weight: 700;
font-size: 3rem;
position: relative;
overflow-x: hidden;
@include hideScrollbars;
padding: 0 2rem;
position: inherit;
overflow: scroll;
scroll-margin-top: 15rem;
overflow: hidden;
height: 100%;
text-wrap: balance;
@include allPhones {
font-size: 2rem !important;
padding: 0 1.5rem;
}
.nolyrics {
color: rgba(255, 255, 255, 0.521);
margin-bottom: 1rem;
}
.line {
margin-top: 1rem;
color: #000;
cursor: pointer;
width: fit-content;
opacity: 1;
transition: opacity 2s ease-in-out;
&:hover {
color: white;
.lyricsinfo {
width: min(100%, 54rem);
margin: 0 auto;
}
}
.currentLine {
color: white;
}
#scrollbale {
overflow: auto;
// margin: 0 auto;
// in case the scrollbar is visible, move it to the far right
margin-right: -2rem;
padding-right: 2rem;
@include hideScrollbars;
}
.seenLine {
color: rgba(255, 255, 255, 0.774);
}
@include allPhones {
font-size: 2rem !important;
padding: 0 1.5rem;
}
#lyricsline--1 {
margin-top: 1rem;
}
display: grid;
grid-template-rows: max-content 1fr;
.opacity_75 {
opacity: 0.9;
}
#np-lyrics-synced {
height: 100%;
max-width: 54rem;
padding-bottom: 6rem;
margin: 0 auto;
}
.opacity_5 {
opacity: 0.8;
}
#np-lyrics-synced::after,
.unsynced::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 6rem;
background: linear-gradient(to top, #000, transparent);
}
.opacity_25 {
opacity: 0.7;
}
.line {
margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.314);
cursor: pointer;
text-align: center;
width: fit-content;
opacity: 1;
transition: opacity 2s ease-in-out;
.copyright {
font-size: 12px;
padding-top: 2rem;
opacity: 0.9;
text-transform: uppercase;
}
&:hover {
color: white;
}
}
.currentLine {
color: white !important;
}
.seenLine {
color: rgba(255, 255, 255, 0.822);
}
#lyricsline-0 {
margin-top: 0;
}
.opacity_75 {
opacity: 0.9;
}
.opacity_5 {
opacity: 0.8;
}
.opacity_25 {
opacity: 0.7;
}
.copyright {
font-size: 12px;
opacity: 0.65;
text-transform: uppercase;
}
.spacer {
height: 8rem;
}
}
.nolyrics {
color: rgba(255, 255, 255, 0.521);
z-index: 10;
position: inherit;
height: 100%;
padding: 0 2rem;
display: grid;
place-content: center;
@include allPhones {
font-size: 1.75rem;
}
button {
width: fit-content;
}
.trackinfo {
font-size: 1rem;
opacity: 0.7;
margin-top: 1rem;
font-weight: normal;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<CardGridPage page="mix" :items="items" :showNoItemsComponent="false">
<CardGridPage page="mix" :items="items" :show-no-items-component="false">
<template #header>
<GenericHeader>
<template #name>
@@ -9,12 +9,12 @@
{{ items.length + savedItems.length }} mixes Based on artists you have been listening to
</template>
<template #after v-if="savedItems.length">
<template v-if="savedItems.length" #after>
<h2>Saved mixes</h2>
<div v-for="item in savedItemComponents" :key="item.id">
<component :is="item.component" :type="item.props.type" :items="item.props.items" />
</div>
<h2 class="othertitle" v-if="items.length">Other {{ $route.params.type }} mixes</h2>
<h2 v-if="items.length" class="othertitle">Other {{ $route.params.type }} mixes</h2>
</template>
</GenericHeader>
</template>

View File

@@ -1,84 +1,177 @@
<template>
<div
v-if="$route.params.tab == 'home'"
class="now-playing-view v-scroll-page"
:class="{ isSmall, isMedium }"
>
<DynamicScroller
:items="scrollerItems"
:min-item-size="64"
class="scroller"
style="height: 100%"
>
<template #before>
<Header />
</template>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[item.props]"
:data-index="index"
>
<component
:is="item.component"
:key="index"
v-bind="item.props"
@playThis="playFromQueue(item.props.index - 1)"
></component>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div>
<div class="now-playing-view content-page" :class="{ isSmall, isMedium }" style="height: 100%">
<div class="now-playing-content rounded">
<div
class="npbgimage"
:style="{
backgroundImage: `url(${paths.images.thumb.small + queue.currenttrack?.albumhash + '.webp'})`,
}"
></div>
<!-- Whatever the f**k this is!! -->
<div
class="npbggradient"
:style="{
background: `linear-gradient(16deg, #000a 2%, #000 60%, ${
// top right
rgba(darkVibrant, 0.25)
}), linear-gradient(-35deg, ${
// bottom right
// rgba(colors.darkVibrant, 0.85)
'#000'
} 10%,
${
// center to top right
rgba(colors.darkMuted, 0.75) || '#000000'
} 60%, ${
// top left
rgba(darkVibrant, 0.25)
})`,
}"
></div>
<component :is="routeMap[$route.params.tab as keyof typeof routeMap].component"> </component>
</div>
<!-- <div class="tracklist">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ut repudiandae accusamus dolorum impedit sapiente deleniti deserunt magni repellendus, aperiam ducimus accusantium esse quas. Repellendus id enim atque quaerat minus distinctio?
</div> -->
<!-- <DynamicScroller :items="scrollerItems" :min-item-size="64" class="scroller" style="height: 100%">
<template #before> </template>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[item.props]"
:data-index="index"
>
<component
:is="item.component"
:key="index"
v-bind="item.props"
@playThis="playFromQueue(item.props.index - 1)"
></component>
</DynamicScrollerItem>
</template>
</DynamicScroller> -->
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { ScrollerItem } from "@/interfaces";
import { defineAsyncComponent, ref } from 'vue'
import useQueueStore from "@/stores/queue";
import useTracklist from "@/stores/queue/tracklist";
import { isMedium, isSmall } from "@/stores/content-width";
import { paths } from '@/config'
import useColor from '@/stores/colors'
import useQueueStore from '@/stores/queue'
import { transitionColor } from '@/utils/colortools'
import { isMedium, isSmall } from '@/stores/content-width'
import Header from "@/components/NowPlaying/Header.vue";
import SongItem from "@/components/shared/SongItem.vue";
import updatePageTitle from "@/utils/updatePageTitle";
const colors = useColor()
const queue = useQueueStore()
const darkMuted = ref<string>('rgb(0, 0, 0)')
const darkVibrant = ref<string>('rgb(0, 0, 0)')
const queue = useQueueStore();
const store = useTracklist();
function playFromQueue(index: number) {
queue.play(index);
const routeMap = {
home: {
component: defineAsyncComponent(() => import('@/components/NowPlaying/HomeScreen.vue')),
class: 'np-route-view',
},
lyrics: { component: defineAsyncComponent(() => import('@/views/LyricsView/main.vue')) },
}
const scrollerItems = computed(() => {
const items: ScrollerItem[] = [];
// watch for changes to the colors.darkVibrant using pinia watcher and transition the currentColor to the new color
colors.$subscribe((_mutation, state) => {
if (darkVibrant.value !== state.darkVibrant) {
transitionColor(darkVibrant.value, state.darkVibrant, 5000, color => {
darkVibrant.value = color
})
const trackComponents = store.tracklist.map((track, index) => {
track.index = index; // used in context menu to remove from queue
return {
id: index,
component: SongItem,
props: {
track,
index: index + 1,
isCurrent: index === queue.currentindex,
isCurrentPlaying: index === queue.currentindex && queue.playing,
isQueueTrack: true,
source: store.from.type,
},
};
});
if (darkMuted.value !== state.darkMuted) {
transitionColor(darkMuted.value, state.darkMuted, 5000, color => {
darkMuted.value = color
})
}
}
})
return items.concat(trackComponents);
});
/**
* Converts a color string to an rgba string
* Example: rgb(255, 255, 255) -> rgba(255, 255, 255, 1)
* @param color - The color to convert to rgba
* @param transparency - The amount of transparency to add
* @returns The rgba color string
*/
function rgba(color: string, transparency: number) {
return color.replace('rgb', 'rgba').replace(')', `, ${transparency})`)
}
onMounted(() => updatePageTitle("Now Playing"));
// function playFromQueue(index: number) {
// queue.play(index)
// }
// const scrollerItems = computed(() => {
// const items: ScrollerItem[] = []
// const trackComponents = store.tracklist.map((track, index) => {
// track.index = index // used in context menu to remove from queue
// return {
// id: index,
// component: SongItem,
// props: {
// track,
// index: index + 1,
// isCurrent: index === queue.currentindex,
// isCurrentPlaying: index === queue.currentindex && queue.playing,
// isQueueTrack: true,
// source: store.from.type,
// },
// }
// })
// return items.concat(trackComponents)
// })
</script>
<style lang="scss">
.now-playing-view {
height: 100%;
.now-playing-view .now-playing-content {
// umm ... I think there's a padding 4rem on parent
height: calc(100% + 2rem);
justify-content: center;
overflow: hidden;
position: relative;
@include allPhones {
height: calc(100% + 3rem);
}
.np-route-view {
z-index: 10;
width: 100%;
height: 100%;
}
.npbgimage,
.npbggradient {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.npbgimage {
background-size: 800px;
background-position: -10px -10px;
$brightness: 1;
$blur-amount: 100px;
filter: blur($blur-amount) brightness($brightness);
-webkit-filter: blur($blur-amount) brightness($brightness);
-moz-filter: blur($blur-amount) brightness($brightness);
-ms-filter: blur($blur-amount) brightness($brightness);
-o-filter: blur($blur-amount) brightness($brightness);
}
.npbggradient {
background-color: transparent;
}
}
</style>

185
src/views/Onboarding.vue Normal file
View File

@@ -0,0 +1,185 @@
<template>
<div class="onboarding pad-lg rounded-sm shadow-lg">
<component
:is="steps[currentStepIndex].component"
v-bind="steps[currentStepIndex]?.props?.() ?? {}"
v-on="steps[currentStepIndex]?.events ?? {}"
/>
<div v-if="showProgressBar" class="progressbar">
<div
v-for="(step, index) in steps"
:key="step.name"
class="dot"
:class="{ active: index === currentStepIndex, complete: index < currentStepIndex }"
>
<div class="dot-inner"></div>
</div>
</div>
<div v-if="errorText" class="errorbox rounded-md">
<ErrorSvg />
<div>{{ errorText }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import useModal from '@/stores/modal'
import { router, Routes } from '@/router'
import useInterface from '@/stores/interface'
import { Waiter } from '@/composables/waiter'
import { addRootDirs } from '@/requests/settings/rootdirs'
import ErrorSvg from '@/assets/icons/toast/error.svg'
import Finish from '@/components/Onboarding/Finish.vue'
import Welcome from '@/components/Onboarding/Welcome.vue'
import Account from '@/components/Onboarding/Account.vue'
import RootDirs from '@/components/Onboarding/RootDirs.vue'
const errorText = ref('')
const userhome = ref('')
const showProgressBar = ref(true)
const UI = useInterface()
const { onboardingStep: currentStepIndex } = storeToRefs(UI)
const steps = [
{
name: 'welcome',
component: Welcome,
events: {
continue: handleContinue,
},
},
{
name: 'account',
component: Account,
events: {
accountCreated: handleAccountCreated,
error: handleError,
},
},
{
name: 'dirconfig',
component: RootDirs,
props: () => ({ userhome: userhome.value }),
events: {
setRootDirs: handleRootDirs,
error: handleError,
},
},
{ name: 'finish', component: Finish, events: { finish: handleFinish } },
]
function handleAccountCreated(user_home_path: string) {
console.log('user_home_path', user_home_path)
userhome.value = user_home_path
console.log('userhome', userhome.value)
currentStepIndex.value++
}
async function handleRootDirs(dirs: string[]) {
errorText.value = ''
currentStepIndex.value++
await addRootDirs(dirs, [])
}
function handleContinue() {
errorText.value = ''
currentStepIndex.value++
}
function handleError(error: string) {
errorText.value = error
}
async function handleFinish() {
UI.setHideUi(false)
// INFO: In case the login dialog has been triggered
useModal().resetModal()
Waiter.resolve(Waiter.keys.ONBOARDING_COMPLETE)
return await router.push({
name: Routes.Home,
})
}
onMounted(() => {
const step = router.currentRoute.value.params.step as string
if (step) {
currentStepIndex.value = steps.findIndex(s => s.name === step) || 0
showProgressBar.value = false
}
})
</script>
<style lang="scss">
.onboarding {
background-color: $gray;
// border: solid 1px $gray5;
height: 30rem;
width: 50rem;
position: absolute;
top: 25rem;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
// disable selection
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
.btn-continue {
background-color: $blue;
border-radius: 4rem;
padding: 1.25rem 2rem;
}
.progressbar {
display: flex;
gap: $small;
position: absolute;
bottom: -2rem;
left: 50%;
transform: translateX(-50%);
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
background-color: $gray;
}
.dot.active {
background-color: $gray3;
}
}
.errorbox {
position: absolute;
width: 100%;
top: 33.5rem;
min-height: 1rem;
left: 50%;
transform: translateX(-50%);
color: $red;
background-color: $gray;
padding: 1rem;
display: grid;
grid-template-columns: 2rem 1fr;
gap: $small;
align-items: center;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<NoItems
v-if="showNoItemsComponent"
:title="`No ${page} results`"
:description="desc"
:description="'Results should appear here'"
:icon="SearchSvg"
:flag="!items.length"
v-if="showNoItemsComponent"
/>
<div class="v-scroll-page" style="height: 100%">
<DynamicScroller style="height: 100%" class="scroller" :min-item-size="64" :items="scrollerItems">
@@ -13,6 +13,7 @@
</template>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:key="item.id"
:item="item"
:active="active"
:size-dependencies="[item.props]"
@@ -28,7 +29,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import useSearchStore from '@/stores/search'
import { maxAbumCards } from '@/stores/content-width'
import SearchSvg from '@/assets/icons/search.svg'
@@ -42,16 +42,9 @@ const props = defineProps<{
items: any[]
outside_route?: boolean
showNoItemsComponent?: boolean
moreItems?: boolean
}>()
const search = useSearchStore()
const desc = computed(() =>
search.query === ''
? `Start typing to search for ${props.page}s`
: `Results for '${search.query}' should appear here`
)
const scrollerItems = computed(() => {
let maxCards = maxAbumCards.value
@@ -73,9 +66,7 @@ const scrollerItems = computed(() => {
})
}
const moreItems = props.page === 'album' ? search.albums.more : search.artists.more
if (props.fetch_callback && moreItems) {
if (props.fetch_callback && props.moreItems) {
items.push({
id: Math.random(),
component: AlbumsFetcher,

View File

@@ -55,6 +55,7 @@ const component = computed(() => {
page: "album",
items: search.albums.value,
fetch_callback: search.loadAlbums,
moreItems: search.albums.more
},
};
@@ -65,6 +66,7 @@ const component = computed(() => {
page: "artist",
items: search.artists.value,
fetch_callback: search.loadArtists,
moreItems: search.artists.more
},
};

View File

@@ -10,6 +10,9 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'
const path = require("path");
export default defineConfig({
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
},
base: "./",
plugins: [
vue(),
@@ -97,6 +100,8 @@ export default defineConfig({
],
resolve: {
alias: {
util: "util/",
stream: "stream-browserify",
"@": path.resolve(__dirname, "src"),
},
},

View File

@@ -2102,6 +2102,11 @@ bl@^4.0.3:
inherits "^2.0.4"
readable-stream "^3.4.0"
blurhash@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.5.tgz#efde729fc14a2f03571a6aa91b49cba80d1abe4b"
integrity sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==
bmp-js@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233"