Compare commits
56 Commits
lastfm
...
onboarding
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a34922552 | ||
|
|
84bbafea73 | ||
|
|
768df2daf4 | ||
|
|
736134a1d5 | ||
|
|
f7080d3adf | ||
|
|
a39d7a4c2f | ||
|
|
275f00548a | ||
|
|
1d34001369 | ||
|
|
0f51584cbb | ||
|
|
f5e3468c03 | ||
|
|
684a2d6261 | ||
|
|
7543e59b65 | ||
|
|
78f152d8e4 | ||
|
|
da7f5bae7d | ||
|
|
c5293a94e5 | ||
|
|
e3866a6ccc | ||
|
|
64462e24be | ||
|
|
9af7e6eaa6 | ||
|
|
a4cb04d261 | ||
|
|
6fd93a759d | ||
|
|
60e557aefd | ||
|
|
cf2d9537ff | ||
|
|
6f4a59f971 | ||
|
|
7b21853f97 | ||
|
|
663dbd2a7c | ||
|
|
c7a0b5ab7e | ||
|
|
ad8eeb7a2a | ||
|
|
e799c96872 | ||
|
|
234aed54d7 | ||
|
|
574d7fd5e7 | ||
|
|
4a1106d784 | ||
|
|
d9f7e5fb14 | ||
|
|
571c4a5264 | ||
|
|
e71bc7164c | ||
|
|
77f18ac640 | ||
|
|
78d57a64b9 | ||
|
|
ff502521e8 | ||
|
|
7caa70b9d6 | ||
|
|
cc3b372090 | ||
|
|
c297f75132 | ||
|
|
7c954ef805 | ||
|
|
9222e94b6c | ||
|
|
54c165b64a | ||
|
|
591509ebaf | ||
|
|
80a0bdbbf1 | ||
|
|
2e27da3f9f | ||
|
|
74bf8f5d78 | ||
|
|
bfdefc37fd | ||
|
|
44a877b9c9 | ||
|
|
db93fd554e | ||
|
|
40a7ad084c | ||
|
|
e44aa01d63 | ||
|
|
192e705890 | ||
|
|
50f92b65ab | ||
|
|
a5aea45cd6 | ||
|
|
cc93fe7419 |
2
TODO.md
@@ -4,7 +4,6 @@
|
||||
- Check out the mobile sidebar and navbar
|
||||
- Remove old settings page files
|
||||
- Fix: track loading indicator in bottom bar
|
||||
|
||||
- Unfuck javascript controlled responsiveness
|
||||
|
||||
- Redesign the album page header for mobile
|
||||
@@ -14,7 +13,6 @@
|
||||
- Add trailing slash to folder url accessed from the breadcrumb
|
||||
- Clip the browseable items on the homepage
|
||||
- Fix: The responsiveness glitch between 900px - 964px 😅
|
||||
- Fix: Queue repeat
|
||||
- Make All Albums/Artists view sort banner sticky
|
||||
|
||||
# DONE ✅
|
||||
|
||||
@@ -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",
|
||||
@@ -24,7 +25,7 @@
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"qr-code-styling": "^1.6.0-rc.1",
|
||||
"v-wave": "^1.5.0",
|
||||
"vue": "^v3.2.45",
|
||||
"vue": "^v3.5.13",
|
||||
"vue-boring-avatars": "^1.4.0",
|
||||
"vue-debounce": "^3.0.2",
|
||||
"vue-router": "^4.1.3",
|
||||
|
||||
208
public/workers/sse-events.js
Normal 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(),
|
||||
})
|
||||
})
|
||||
255
src/App.vue
@@ -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
@@ -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 |
@@ -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 |
4
src/assets/icons/explicit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.84421 21.8972H18.0295C20.5685 21.8972 21.8737 20.5919 21.8737 18.0914V3.82921C21.8737 1.32656 20.5685 0.0234375 18.0295 0.0234375H3.84421C1.31484 0.0234375 0 1.31695 0 3.82921V18.0914C0 20.6016 1.31484 21.8972 3.84421 21.8972Z" fill="#aeaeaf"/>
|
||||
<path d="M8.24921 16.3608C7.44976 16.3608 7.04688 15.8618 7.04688 15.0368V6.67026C7.04688 5.84948 7.45187 5.34839 8.24921 5.34839H13.795C14.3777 5.34839 14.7607 5.68026 14.7607 6.26643C14.7607 6.8376 14.3777 7.19619 13.795 7.19619H9.33695V9.92808H13.5377C14.0824 9.92808 14.4464 10.2356 14.4464 10.7923C14.4464 11.3222 14.0824 11.6255 13.5377 11.6255H9.33695V14.513H13.795C14.3777 14.513 14.7607 14.8545 14.7607 15.4406C14.7607 16.0118 14.3777 16.3608 13.795 16.3608H8.24921Z" fill="#111111"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 831 B |
4
src/assets/icons/pencil.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.42607 18.5857L16.593 5.42412L14.344 3.16546L1.1674 16.3366L0.0267015 19.0816C-0.10197 19.4303 0.258496 19.8049 0.592479 19.6708L3.42607 18.5857ZM17.715 4.32139L18.9829 3.07476C19.6122 2.44546 19.6378 1.7482 19.0703 1.16906L18.6128 0.709452C18.0454 0.139922 17.3439 0.200625 16.7125 0.808593L15.4467 2.06273L17.715 4.32139Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
4
src/assets/icons/subtract.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -57,6 +57,7 @@ $g-border: solid 1px $gray5;
|
||||
.b-bar {
|
||||
grid-area: bottombar;
|
||||
border-top: $g-border;
|
||||
// background-color: $bars;
|
||||
}
|
||||
|
||||
.content-page {
|
||||
@@ -127,7 +128,8 @@ $g-border: solid 1px $gray5;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
background-color: $gray;
|
||||
// background-color: $bars;
|
||||
border-bottom: $g-border;
|
||||
}
|
||||
|
||||
.vue-recycle-scroller,
|
||||
@@ -147,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));
|
||||
|
||||
@@ -1,270 +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;
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ $content-padding-bottom: 2rem;
|
||||
$black: #181a1c;
|
||||
$white: #ffffffde;
|
||||
|
||||
$gray: #1c1c1e;
|
||||
$gray: #1a1919;
|
||||
$gray1: #8e8e93;
|
||||
$gray2: #636366;
|
||||
$gray3: #48484a;
|
||||
$gray4: #3a3a3c;
|
||||
$gray5: #2c2c2e;
|
||||
$body: #111111;
|
||||
$body: #000f;
|
||||
|
||||
$red: #f7635c;
|
||||
$blue: #0a84ff;
|
||||
@@ -41,6 +41,7 @@ $brown: #ac8e68;
|
||||
$indigo: #5e5ce6;
|
||||
$teal: rgb(64, 200, 224);
|
||||
$lightbrown: #ebca89;
|
||||
$bars: #111111;
|
||||
|
||||
$primary: $gray4;
|
||||
$accent: $gray1;
|
||||
|
||||
@@ -1,170 +1,177 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!on_sidebar"
|
||||
class="artist-header-ambient rounded-lg"
|
||||
:class="{ isSmallPhone }"
|
||||
style="height: 100%; width: 100%"
|
||||
:style="{
|
||||
boxShadow: !useCircularImage ? (colors.bg.length ? `0 .5rem 2rem ${colors.bg}` : undefined) : undefined,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
ref="artistheader"
|
||||
class="artist-page-header rounded-lg no-scroll"
|
||||
:class="{ isSmallPhone, useCircularImage }"
|
||||
:style="{
|
||||
height: `${isSmallPhone ? '25rem' : containerHeight}`,
|
||||
}"
|
||||
>
|
||||
<Info :artist="artist" :use-circular-image="useCircularImage" />
|
||||
<div
|
||||
class="artist-img no-select"
|
||||
:style="{
|
||||
height: containerHeight,
|
||||
}"
|
||||
>
|
||||
<img id="artist-avatar" :src="paths.images.artist.large + artist.image" @load="store.setBgColor" />
|
||||
<div class="headparent">
|
||||
<div
|
||||
v-if="!on_sidebar"
|
||||
class="artist-header-ambient rounded-lg"
|
||||
:class="{ isSmallPhone }"
|
||||
:style="{
|
||||
boxShadow: !useCircularImage ? (colors.bg.length ? `0 .5rem 2rem ${colors.bg}` : undefined) : undefined,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
ref="artistheader"
|
||||
class="artist-page-header rounded-lg no-scroll"
|
||||
:class="{ isSmallPhone, useCircularImage }"
|
||||
:style="{
|
||||
height: `${isSmallPhone ? '25rem' : containerHeight}`,
|
||||
}"
|
||||
>
|
||||
<Info :artist="artist" :use-circular-image="useCircularImage" />
|
||||
<div
|
||||
class="artist-img no-select"
|
||||
:style="{
|
||||
height: containerHeight,
|
||||
}"
|
||||
>
|
||||
<img id="artist-avatar" :src="paths.images.artist.large + artist.image" @load="store.setBgColor" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!useCircularImage"
|
||||
class="gradient"
|
||||
:style="{
|
||||
backgroundImage: colors.bg
|
||||
? `linear-gradient(${gradientDirection}, transparent ${
|
||||
isSmall ? 60 : gradientTransparentWidth - (width < 700 ? 40 : width < 900 ? 20 : 10)
|
||||
}%,
|
||||
${colors.bg} ${gradientWidth}%,
|
||||
${colors.bg} 100%)`
|
||||
: '',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!useCircularImage"
|
||||
class="gradient"
|
||||
:style="{
|
||||
backgroundImage: colors.bg
|
||||
? `linear-gradient(${gradientDirection}, transparent ${
|
||||
isSmall ? 60 : gradientTransparentWidth - (width < 700 ? 40 : width < 900 ? 20 : 10)
|
||||
}%,
|
||||
${colors.bg} ${gradientWidth}%,
|
||||
${colors.bg} 100%)`
|
||||
: '',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { Ref, computed, onMounted, ref } from "vue";
|
||||
import { onBeforeRouteUpdate } from "vue-router";
|
||||
import useSettingsStore from '@/stores/settings'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Ref, computed, onMounted, ref } from 'vue'
|
||||
import { onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
import { paths } from "@/config";
|
||||
import updatePageTitle from "@/utils/updatePageTitle";
|
||||
import { paths } from '@/config'
|
||||
import updatePageTitle from '@/utils/updatePageTitle'
|
||||
|
||||
import { isSmall } from "@/stores/content-width";
|
||||
import useArtistStore from "@/stores/pages/artist";
|
||||
import Info from "./HeaderComponents/Info.vue";
|
||||
import { isSmall } from '@/stores/content-width'
|
||||
import useArtistStore from '@/stores/pages/artist'
|
||||
import Info from './HeaderComponents/Info.vue'
|
||||
|
||||
const image_width_px = 450;
|
||||
const store = useArtistStore();
|
||||
const settings = useSettingsStore();
|
||||
const image_width_px = 450
|
||||
const store = useArtistStore()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const props = defineProps<{
|
||||
on_sidebar?: boolean;
|
||||
}>();
|
||||
on_sidebar?: boolean
|
||||
}>()
|
||||
|
||||
const { info: artist, colors } = storeToRefs(store);
|
||||
const { info: artist, colors } = storeToRefs(store)
|
||||
|
||||
function updateTitle() {
|
||||
props.on_sidebar ? () => {} : updatePageTitle(artist.value.name);
|
||||
props.on_sidebar ? () => {} : updatePageTitle(artist.value.name)
|
||||
}
|
||||
|
||||
onMounted(() => updateTitle());
|
||||
onBeforeRouteUpdate(() => updateTitle());
|
||||
onMounted(() => updateTitle())
|
||||
onBeforeRouteUpdate(() => updateTitle())
|
||||
|
||||
const artistheader: Ref<HTMLElement | null> = ref(null);
|
||||
const { width } = useElementSize(artistheader);
|
||||
const artistheader: Ref<HTMLElement | null> = ref(null)
|
||||
const { width } = useElementSize(artistheader)
|
||||
|
||||
const gradientTransparentWidth = computed(() => Math.floor((image_width_px / (width.value || 1)) * 100));
|
||||
const gradientTransparentWidth = computed(() => Math.floor((image_width_px / (width.value || 1)) * 100))
|
||||
|
||||
const isSmallPhone = computed(() => width.value <= 660);
|
||||
const useCircularImage = computed(() => !isSmallPhone.value && settings.useCircularArtistImg);
|
||||
const isSmallPhone = computed(() => width.value <= 660)
|
||||
const useCircularImage = computed(() => !isSmallPhone.value && settings.useCircularArtistImg)
|
||||
|
||||
const gradientDirection = computed(() => (isSmallPhone.value ? "210deg" : "to left"));
|
||||
const gradientDirection = computed(() => (isSmallPhone.value ? '210deg' : 'to left'))
|
||||
|
||||
const gradientWidth = computed(() => {
|
||||
return isSmallPhone.value ? "80" : Math.min(gradientTransparentWidth.value, 50);
|
||||
});
|
||||
return isSmallPhone.value ? '80' : Math.min(gradientTransparentWidth.value, 50)
|
||||
})
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
return useCircularImage.value ? "13rem" : "18rem";
|
||||
});
|
||||
return useCircularImage.value ? '13rem' : '18rem'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.headparent {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.artist-header-ambient {
|
||||
height: 17rem;
|
||||
position: absolute;
|
||||
opacity: 0.25;
|
||||
margin-right: -1rem;
|
||||
height: 18rem;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.artist-page-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 450px;
|
||||
position: relative;
|
||||
|
||||
.artist-img {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
order: 1;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
}
|
||||
|
||||
&.useCircularImage {
|
||||
grid-template-columns: min-content 1fr;
|
||||
|
||||
.artist-img {
|
||||
padding: 1rem;
|
||||
order: -1;
|
||||
z-index: 10;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
height: calc(100% - 0rem);
|
||||
width: unset;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gradient {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(to left, transparent 10%, $gray 50%, $gray 100%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.isSmallPhone {
|
||||
background-image: linear-gradient(210deg, transparent 20%, $gray 80%, $gray 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&.isSmallPhone {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 450px;
|
||||
position: relative;
|
||||
|
||||
.artist-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
height: 100% !important;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
order: 1;
|
||||
|
||||
img {
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
}
|
||||
|
||||
&.useCircularImage {
|
||||
grid-template-columns: min-content 1fr;
|
||||
|
||||
.artist-img {
|
||||
padding: 1rem;
|
||||
order: -1;
|
||||
z-index: 10;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
height: calc(100% - 0rem);
|
||||
width: unset;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gradient {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(to left, transparent 10%, $gray 50%, $gray 100%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
|
||||
&.isSmallPhone {
|
||||
background-image: linear-gradient(210deg, transparent 20%, $gray 80%, $gray 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&.isSmallPhone {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
position: relative;
|
||||
|
||||
.artist-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
height: 100% !important;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,7 +63,6 @@ function handleFav() {
|
||||
|
||||
<style lang="scss">
|
||||
.b-bar {
|
||||
background-color: rgb(22, 22, 22);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content 1fr;
|
||||
align-items: center;
|
||||
|
||||
@@ -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,14 +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>
|
||||
<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 || []"
|
||||
@@ -50,21 +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
|
||||
@@ -82,6 +100,10 @@ defineEmits<{
|
||||
line-height: 1.2;
|
||||
margin-right: $medium;
|
||||
|
||||
.text-loader {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.np-image {
|
||||
position: relative;
|
||||
height: 3rem;
|
||||
@@ -181,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>
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
<Volume />
|
||||
<button
|
||||
class="repeat"
|
||||
:class="{ 'repeat-disabled': settings.no_repeat }"
|
||||
:title="settings.repeat_all ? 'Repeat all' : settings.no_repeat ? 'No repeat' : 'Repeat one'"
|
||||
:class="{ 'repeat-disabled': settings.repeat == 'none' }"
|
||||
:title="settings.repeat == 'all' ? 'Repeat all' : settings.repeat == 'one' ? 'Repeat one' : 'No repeat'"
|
||||
@click="settings.toggleRepeatMode"
|
||||
>
|
||||
<RepeatOneSvg v-if="settings.repeat_one" />
|
||||
<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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
97
src/components/NowPlaying/HomeScreen.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
18
src/components/NowPlaying/TrackContext.vue
Normal 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>
|
||||
131
src/components/Onboarding/Account.vue
Normal 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>
|
||||
352
src/components/Onboarding/FilePicker.vue
Normal 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>
|
||||
124
src/components/Onboarding/Finish.vue
Normal 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>
|
||||
302
src/components/Onboarding/RootDirs.vue
Normal 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>
|
||||
52
src/components/Onboarding/Welcome.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -94,7 +94,7 @@ const res_type = computed(() => {
|
||||
type It = Album & Artist & Track
|
||||
|
||||
const item = computed(() => {
|
||||
return top_results.value.top_result.item as It
|
||||
return top_results.value.top_result as It
|
||||
})
|
||||
|
||||
const context_menu_showing = ref(false)
|
||||
@@ -106,7 +106,7 @@ function showMenu(e: MouseEvent) {
|
||||
|
||||
<style lang="scss">
|
||||
.top-result-item {
|
||||
background-color: $gray5;
|
||||
background-color: $gray;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -80,6 +80,10 @@ onMounted(() => {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr max-content;
|
||||
|
||||
.scroller {
|
||||
padding-bottom: 0rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#tracks-results .morexx {
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<div class="item__favorites">
|
||||
{{ backup.favorites }} favorite{{ backup.favorites !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
•
|
||||
<div class="item__collections">
|
||||
{{ backup.collections }} collection{{ backup.collections !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
@@ -55,6 +59,7 @@ interface Backup {
|
||||
playlists: number
|
||||
scrobbles: number
|
||||
favorites: number
|
||||
collections: number
|
||||
date: string
|
||||
}
|
||||
const backups = ref<Backup[]>([])
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="statitem" :class="props.icon">
|
||||
<div class="statitem" :class="props.icon" :style="dynamicBackgroundStyle">
|
||||
<svg
|
||||
class="noise"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -35,7 +35,7 @@
|
||||
surfaceScale="21"
|
||||
specularConstant="1.7"
|
||||
specularExponent="20"
|
||||
lighting-color="#7957A8"
|
||||
lighting-color="transparent"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
@@ -50,20 +50,20 @@
|
||||
<rect width="700" height="700" fill="transparent"></rect>
|
||||
<rect width="700" height="700" fill="#7957a8" filter="url(#nnnoise-filter)"></rect>
|
||||
</svg>
|
||||
<div class="itemcontent">
|
||||
<div class="itemcontent" :style="{ color: textColor }">
|
||||
<div class="count ellip2" :title="formattedValue">{{ formattedValue }}</div>
|
||||
<div class="title">{{ text }}</div>
|
||||
</div>
|
||||
|
||||
<component :is="icon" class="staticon" v-if="!props.icon.startsWith('top')" />
|
||||
<component :is="icon" v-if="!props.icon.startsWith('top')" class="staticon" :style="{ color: textColor }" />
|
||||
<router-link
|
||||
v-if="props.icon.startsWith('top') && props.image"
|
||||
:to="{
|
||||
name: Routes.album,
|
||||
params: {
|
||||
albumhash: props.image?.replace('.webp', ''),
|
||||
albumhash: props.image.split('?pathhash=')[0]?.replace('.webp', ''),
|
||||
},
|
||||
}"
|
||||
v-if="props.icon.startsWith('top') && props.image"
|
||||
>
|
||||
<img class="staticon statimage shadow-sm" :src="paths.images.thumb.small + props.image" alt="" />
|
||||
</router-link>
|
||||
@@ -81,6 +81,11 @@ import SparklesSvg from '@/assets/icons/sparkles.svg'
|
||||
|
||||
import { paths } from '@/config'
|
||||
import { Routes } from '@/router'
|
||||
import useArtistStore from '@/stores/pages/artist'
|
||||
import useAlbumStore from '@/stores/pages/album'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getTextColor } from '@/utils/colortools/shift'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
@@ -89,6 +94,13 @@ const props = defineProps<{
|
||||
image?: string
|
||||
}>()
|
||||
|
||||
// Get current route and colors from stores
|
||||
const route = useRoute()
|
||||
const artistStore = useArtistStore()
|
||||
const albumStore = useAlbumStore()
|
||||
const { colors: artistColors } = storeToRefs(artistStore)
|
||||
const { colors: albumColors } = storeToRefs(albumStore)
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.icon) {
|
||||
case 'streams':
|
||||
@@ -110,6 +122,61 @@ const icon = computed(() => {
|
||||
const formattedValue = computed(() => {
|
||||
return props.value.toLocaleString()
|
||||
})
|
||||
|
||||
// Determine which dynamic color to use based on current route
|
||||
const dynamicColor = computed(() => {
|
||||
switch (route.name) {
|
||||
// Album-related pages should use album colors
|
||||
case Routes.album:
|
||||
return albumColors.value?.bg || null
|
||||
|
||||
// Artist-related pages should use artist colors
|
||||
case Routes.artist:
|
||||
return artistColors.value?.bg || null
|
||||
|
||||
// All other pages should use default colors
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Default hardcoded background styles
|
||||
const defaultBackgroundStyles = computed(() => {
|
||||
switch (props.icon) {
|
||||
case 'streams':
|
||||
return 'linear-gradient(to top, #c79081 0%, #dfa579 100%)'
|
||||
case 'playtime':
|
||||
return 'linear-gradient(-225deg, #3d4e81 0%, #5753c9 48%, #6e7ff3 100%)'
|
||||
case 'trackcount':
|
||||
return 'linear-gradient(to top, #6a66b9 0%, #7777db 52%, #7b7bd4 100%)'
|
||||
case 'toptrack':
|
||||
return 'linear-gradient(-225deg, #65379b 0%, #6750b3 53%, #6457c6 100%)'
|
||||
default:
|
||||
return 'linear-gradient(to top right, rgb(120, 76, 129), #9643da91, rgb(132, 80, 228))'
|
||||
}
|
||||
})
|
||||
|
||||
// Computed style that uses dynamic color or falls back to hardcoded
|
||||
const dynamicBackgroundStyle = computed(() => {
|
||||
if (dynamicColor.value) {
|
||||
return {
|
||||
backgroundColor: dynamicColor.value,
|
||||
backgroundImage: 'none',
|
||||
}
|
||||
}
|
||||
return {
|
||||
backgroundImage: defaultBackgroundStyles.value,
|
||||
}
|
||||
})
|
||||
|
||||
// Computed text color based on background using the same logic as headers
|
||||
const textColor = computed(() => {
|
||||
if (dynamicColor.value) {
|
||||
return getTextColor(dynamicColor.value)
|
||||
}
|
||||
// Return default white color when using gradients
|
||||
return '#ffffff'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -121,25 +188,10 @@ const formattedValue = computed(() => {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
|
||||
// Default background - will be overridden by dynamic styles
|
||||
background-image: linear-gradient(to top right, rgb(120, 76, 129), #9643da91, rgb(132, 80, 228));
|
||||
position: relative;
|
||||
|
||||
&.streams {
|
||||
background-image: linear-gradient(to top, #c79081 0%, #dfa579 100%);
|
||||
}
|
||||
|
||||
&.playtime {
|
||||
background-image: linear-gradient(-225deg, #3d4e81 0%, #5753c9 48%, #6e7ff3 100%);
|
||||
}
|
||||
|
||||
&.trackcount {
|
||||
background-image: linear-gradient(to top, #6a66b9 0%, #7777db 52%, #7b7bd4 100%);
|
||||
}
|
||||
|
||||
&.toptrack {
|
||||
background-image: linear-gradient(-225deg, #65379b 0%, #6750b3 53%, #6457c6 100%);
|
||||
}
|
||||
|
||||
.itemcontent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -57,6 +57,10 @@ onMounted(async () => {
|
||||
date.value = res.data.dates
|
||||
}
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
@hideModal="hideModal"
|
||||
@setTitle="setTitle"
|
||||
/>
|
||||
<CrudPage
|
||||
v-if="modal.component == modal.options.page"
|
||||
v-bind="modal.props"
|
||||
@hideModal="hideModal"
|
||||
@setTitle="setTitle"
|
||||
/>
|
||||
<UpdatePlaylist
|
||||
v-if="modal.component == modal.options.updatePlaylist"
|
||||
v-bind="modal.props"
|
||||
@@ -37,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>
|
||||
@@ -49,6 +55,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import AuthLogin from './modals/AuthLogin.vue'
|
||||
import ConfirmModal from './modals/ConfirmModal.vue'
|
||||
import CrudPage from './modals/CrudPage.vue'
|
||||
import NewPlaylist from './modals/NewPlaylist.vue'
|
||||
import RootDirsPrompt from './modals/RootDirsPrompt.vue'
|
||||
import SetRootDirs from './modals/SetRootDirs.vue'
|
||||
|
||||
82
src/components/modals/CrudPage.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<form action="" v-if="delete">
|
||||
<br>
|
||||
<div>Are you sure you want to delete this collection?</div>
|
||||
<br />
|
||||
<button @click.prevent="submit" class="critical">Yes, Delete</button>
|
||||
</form>
|
||||
<form class="playlist-modal" @submit.prevent="submit" v-else>
|
||||
<label for="name">Collection name</label>
|
||||
<br />
|
||||
<input type="search" class="rounded-sm" id="name" :value="collection?.name" />
|
||||
<br />
|
||||
<label for="description">Description</label>
|
||||
<br />
|
||||
<input type="search" class="rounded-sm" id="description" :value="collection?.extra.description" />
|
||||
<br /><br />
|
||||
<button type="submit">{{ collection ? 'Update' : 'Create' }}</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Collection } from '@/interfaces'
|
||||
import { createNewCollection, deleteCollection, updateCollection } from '@/requests/collections'
|
||||
import { router } from '@/router'
|
||||
import { NotifType, Notification } from '@/stores/notification'
|
||||
|
||||
const props = defineProps<{
|
||||
collection?: Collection
|
||||
hash?: string
|
||||
type?: string
|
||||
extra?: any
|
||||
delete?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'hideModal'): void
|
||||
(e: 'setTitle', title: string): void
|
||||
}>()
|
||||
|
||||
emit('setTitle', (props.collection ? (props.delete ? 'Delete' : 'Update') : 'New') + ' Collection')
|
||||
|
||||
async function submit(e: Event) {
|
||||
if (props.delete && props.collection) {
|
||||
const deleted = await deleteCollection(props.collection.id)
|
||||
if (deleted) {
|
||||
new Notification('Collection deleted', NotifType.Success)
|
||||
emit('hideModal')
|
||||
router.push('/')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
const name = (e.target as any).elements['name'].value
|
||||
const description = (e.target as any).elements['description'].value
|
||||
|
||||
// If the page is null, we are creating a new page
|
||||
if (props.collection == null) {
|
||||
const created = await createNewCollection(name, description, [
|
||||
{
|
||||
hash: props.hash as string,
|
||||
type: props.type as string,
|
||||
extra: props.extra,
|
||||
},
|
||||
])
|
||||
|
||||
if (created) {
|
||||
new Notification('New collection created', NotifType.Success)
|
||||
emit('hideModal')
|
||||
}
|
||||
} else {
|
||||
const updatedPage = await updateCollection(props.collection, name, description)
|
||||
|
||||
if (updatedPage) {
|
||||
props.collection.name = updatedPage.name
|
||||
props.collection.extra.description = updatedPage.extra.description
|
||||
new Notification('Collection updated', NotifType.Success)
|
||||
emit('hideModal')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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 }}
|
||||
@@ -59,11 +59,13 @@ const currentGroup = computed(() => {
|
||||
// select default tab
|
||||
for (const group of settingGroups) {
|
||||
for (const settings of group.groups) {
|
||||
if (settings.title === 'Last.fm') {
|
||||
if (settings.title === 'Appearance') {
|
||||
return settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const showContent = computed(() => {
|
||||
@@ -81,6 +83,7 @@ $modalheight: 38rem;
|
||||
.settingsmodal {
|
||||
display: grid;
|
||||
grid-template-columns: 15rem 1fr;
|
||||
height: 100%;
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
params: { albumhash: album.albumhash },
|
||||
}"
|
||||
class="album-card"
|
||||
@contextmenu.prevent="showMenu"
|
||||
:class="{ 'context-menu-open': contextMenuFlag }"
|
||||
>
|
||||
<div class="with-img rounded-sm no-scroll">
|
||||
<div
|
||||
@@ -56,7 +58,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Routes } from '@/router'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { Album } from '../../interfaces'
|
||||
@@ -66,9 +68,11 @@ import { playSources } from '@/enums'
|
||||
import useAlbumStore from '@/stores/pages/album'
|
||||
import { paths } from '../../config'
|
||||
import MasterFlag from './MasterFlag.vue'
|
||||
import { showAlbumContextMenu } from '@/helpers/contextMenuHandler'
|
||||
|
||||
const imguri = paths.images.thumb.medium
|
||||
const route = useRoute()
|
||||
const contextMenuFlag = ref(false)
|
||||
const imguri = paths.images.thumb.medium
|
||||
|
||||
const props = defineProps<{
|
||||
album: Album
|
||||
@@ -94,6 +98,10 @@ const artists = computed(() => {
|
||||
|
||||
return albumartists
|
||||
})
|
||||
|
||||
function showMenu(e: MouseEvent) {
|
||||
showAlbumContextMenu(e, contextMenuFlag, props.album)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -105,6 +113,10 @@ const artists = computed(() => {
|
||||
height: max-content;
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
&.context-menu-open {
|
||||
background-color: $gray5;
|
||||
}
|
||||
|
||||
.with-img {
|
||||
position: relative;
|
||||
|
||||
@@ -130,10 +142,6 @@ const artists = computed(() => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
img {
|
||||
/* border-radius: 0 0 $medium $medium; Not sure why this one was added, fugly with animation */
|
||||
}
|
||||
|
||||
.gradient {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
},
|
||||
}"
|
||||
class="artist-card"
|
||||
@contextmenu.prevent="showContextMenu"
|
||||
:class="{ 'context-menu-open': contextMenuFlag }"
|
||||
>
|
||||
<div class="image circular">
|
||||
<img class="artist-image circular" :src="imguri + artist.image" />
|
||||
@@ -38,12 +40,19 @@ import { Routes } from '@/router'
|
||||
|
||||
import { playSources } from '@/enums'
|
||||
import PlayBtn from './PlayBtn.vue'
|
||||
import { ref } from 'vue'
|
||||
import { showArtistContextMenu } from '@/helpers/contextMenuHandler'
|
||||
|
||||
const imguri = paths.images.artist.medium
|
||||
const contextMenuFlag = ref(false)
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
artist: Artist
|
||||
}>()
|
||||
|
||||
const showContextMenu = (e: MouseEvent) => {
|
||||
showArtistContextMenu(e, contextMenuFlag, props.artist.artisthash, props.artist.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -59,6 +68,10 @@ defineProps<{
|
||||
height: max-content;
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
&.context-menu-open {
|
||||
background-color: $gray5;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
:size="size || 80"
|
||||
:name="name"
|
||||
:square="false"
|
||||
:variant="'beam'"
|
||||
:colors="['#3a3a3c']"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,36 +1,66 @@
|
||||
<template>
|
||||
<div v-if="type == 'album'" class="cardlistrow">
|
||||
<AlbumCard v-for="item in items" :key="item.albumhash" class="hlistitem" :album="(item as Album)" />
|
||||
</div>
|
||||
<div v-else-if="type == 'artist'" class="cardlistrow">
|
||||
<ArtistCard v-for="item in items" :key="item.artisthash" class="hlistitem" :artist="(item as Artist)" />
|
||||
</div>
|
||||
<div v-else-if="type == 'mix'" class="cardlistrow">
|
||||
<MixCard v-for="item in items" :key="item.sourcehash" class="hlistitem" :mix="(item as Mix)" />
|
||||
</div>
|
||||
<div class="cardlistrow">
|
||||
<component :is="item.component" v-for="item in items" :key="item.key" v-bind="item.props" class="hlistitem" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Album, Artist, Mix } from "@/interfaces";
|
||||
import AlbumCard from "./AlbumCard.vue";
|
||||
import ArtistCard from "./ArtistCard.vue";
|
||||
import MixCard from "../Mixes/MixCard.vue";
|
||||
import { Album, Artist, Mix } from '@/interfaces'
|
||||
import AlbumCard from './AlbumCard.vue'
|
||||
import ArtistCard from './ArtistCard.vue'
|
||||
import MixCard from '../Mixes/MixCard.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
type: string | "album" | "artist" | "mix";
|
||||
items: Album[] | Artist[] | Mix[];
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
items: Album[] | Artist[] | Mix[]
|
||||
}>()
|
||||
|
||||
const items = computed(() => {
|
||||
return props.items.map((item: any) => {
|
||||
const i = {
|
||||
component: <any>null,
|
||||
props: {},
|
||||
key: '',
|
||||
}
|
||||
|
||||
switch (item['type']) {
|
||||
case 'album':
|
||||
i.component = AlbumCard
|
||||
i.key = item.albumhash
|
||||
i.props = {
|
||||
album: item,
|
||||
}
|
||||
break
|
||||
case 'artist':
|
||||
i.component = ArtistCard
|
||||
i.key = item.artisthash
|
||||
i.props = {
|
||||
artist: item,
|
||||
}
|
||||
break
|
||||
case 'mix':
|
||||
i.component = MixCard
|
||||
i.key = item.sourcehash
|
||||
i.props = {
|
||||
mix: item,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return i
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.cardlistrow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
|
||||
padding-bottom: 2rem;
|
||||
z-index: -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
|
||||
padding-bottom: 2rem;
|
||||
z-index: -1;
|
||||
|
||||
@include mediumPhones {
|
||||
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
||||
}
|
||||
@include mediumPhones {
|
||||
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
<div class="cardscroller">
|
||||
<div class="rinfo">
|
||||
<div class="rtitle">
|
||||
<b>{{ title }}</b>
|
||||
<SeeAll v-if="route && itemlist.length >= maxAbumCards" :route="route" :text="seeAllText" />
|
||||
<b>
|
||||
<RouterLink :to="route || ''">
|
||||
{{ title }}
|
||||
</RouterLink>
|
||||
</b>
|
||||
<!-- INFO: This SEE ALL is shown when there's no description. Eg. in favorites page -->
|
||||
<SeeAll
|
||||
v-if="!description && route && itemlist.length >= maxAbumCards"
|
||||
:route="route"
|
||||
:text="seeAllText"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="description" class="rdesc">
|
||||
{{ description }}
|
||||
<RouterLink :to="route || ''">
|
||||
{{ description }}
|
||||
</RouterLink>
|
||||
<!-- INFO: This SEE ALL is shown when there's a description. Eg. in the home page -->
|
||||
<SeeAll v-if="route && itemlist.length >= maxAbumCards" :route="route" :text="seeAllText" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="recentitems">
|
||||
@@ -84,7 +97,7 @@ function getComponent(type: string) {
|
||||
return FolderCard
|
||||
case 'playlist':
|
||||
return PlaylistCard
|
||||
case 'favorite_tracks':
|
||||
case 'favorite':
|
||||
return FavoritesCard
|
||||
case 'mix':
|
||||
return MixCard
|
||||
@@ -121,7 +134,7 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
|
||||
return {
|
||||
playlist: item.item,
|
||||
}
|
||||
case 'favorite_tracks':
|
||||
case 'favorite':
|
||||
return {
|
||||
item: item.item,
|
||||
}
|
||||
@@ -165,6 +178,9 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
|
||||
.rdesc {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.747);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,80 +1,126 @@
|
||||
<template>
|
||||
<RouterLink :to="{ name: Routes.favoriteTracks }" class="favoritescard rounded">
|
||||
<div class="img">
|
||||
<svg width="100" height="100" viewBox="0 0 28 28" fill="#ff453a" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.9912 22.1445C14.2197 22.1445 14.5449 21.9775 14.8086 21.8105C19.7217 18.6465 22.8682 14.9375 22.8682 11.1758C22.8682 7.9502 20.6445 5.7002 17.8408 5.7002C16.0918 5.7002 14.7822 6.66699 13.9912 8.11719C13.2178 6.67578 11.8994 5.7002 10.1504 5.7002C7.34668 5.7002 5.11426 7.9502 5.11426 11.1758C5.11426 14.9375 8.26074 18.6465 13.1738 21.8105C13.4463 21.9775 13.7715 22.1445 13.9912 22.1445Z"
|
||||
/>
|
||||
</svg>
|
||||
<PlayBtn :source="playSources.favorite" />
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="rhelp playlist">
|
||||
<span class="help">PLAYLIST</span>
|
||||
<span class="time">{{ item.time }}</span>
|
||||
</div>
|
||||
<div class="title">Favorite Tracks</div>
|
||||
<div class="fcount">
|
||||
<b>{{ item.count + ` Track${item.count == 1 ? "" : "s"}` }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: Routes.favoriteTracks }" class="favoritescard rounded">
|
||||
<div class="img">
|
||||
<div class="blur" :style="{ backgroundImage: `url(${paths.images.thumb.small + item.image})` }"></div>
|
||||
</div>
|
||||
<div class="overlay">
|
||||
<PlayBtn :source="playSources.favorite" />
|
||||
<svg
|
||||
class="heart"
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 28 28"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
:style="{ color: color }"
|
||||
<path
|
||||
d="M13.9912 22.1445C14.2197 22.1445 14.5449 21.9775 14.8086 21.8105C19.7217 18.6465 22.8682 14.9375 22.8682 11.1758C22.8682 7.9502 20.6445 5.7002 17.8408 5.7002C16.0918 5.7002 14.7822 6.66699 13.9912 8.11719C13.2178 6.67578 11.8994 5.7002 10.1504 5.7002C7.34668 5.7002 5.11426 7.9502 5.11426 11.1758C5.11426 14.9375 8.26074 18.6465 13.1738 21.8105C13.4463 21.9775 13.7715 22.1445 13.9912 22.1445Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="rhelp playlist">
|
||||
<span class="help">PLAYLIST</span>
|
||||
<span class="time">{{ item.time }}</span>
|
||||
</div>
|
||||
<div class="title">Favorite Tracks</div>
|
||||
<div class="fcount">
|
||||
<b>{{ item.count + ` Track${item.count == 1 ? '' : 's'}` }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { playSources } from "@/enums";
|
||||
import { Routes } from "@/router";
|
||||
import PlayBtn from "../shared/PlayBtn.vue";
|
||||
import { paths } from '@/config'
|
||||
import { Routes } from '@/router'
|
||||
import { playSources } from '@/enums'
|
||||
import PlayBtn from '../shared/PlayBtn.vue'
|
||||
|
||||
defineProps<{
|
||||
item: any;
|
||||
}>();
|
||||
item: {
|
||||
time: string
|
||||
count: number
|
||||
image: string
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.favoritescard {
|
||||
padding: $medium;
|
||||
|
||||
.img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
background-color: $gray5;
|
||||
border-radius: $small;
|
||||
margin-bottom: $medium;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-image: linear-gradient(37deg, $gray5, $gray, $gray);
|
||||
justify-content: center;
|
||||
padding: $medium;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
position: absolute;
|
||||
width: 4rem;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.img,
|
||||
.overlay {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: $small;
|
||||
margin-bottom: $medium;
|
||||
}
|
||||
|
||||
.fcount {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.img {
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray4;
|
||||
.blur {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(37deg, $gray5, $gray, $gray);
|
||||
// background-image: url('http://localhost:1980/img/thumbnail/xsmall/e74d8c49e8d6340f.webp?pathhash=24bf8142d7150965');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: brightness(0.5) blur(15px);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
$size: calc(100% - $medium * 2);
|
||||
position: absolute;
|
||||
top: $medium;
|
||||
left: $medium;
|
||||
width: $size;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heart {
|
||||
color: $pink;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(-1rem);
|
||||
position: absolute;
|
||||
width: 4rem;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
.fcount {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $gray4;
|
||||
|
||||
.play-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(-1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
grid-template-columns: 1fr max-content;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.after {
|
||||
margin-top: 2rem;
|
||||
margin-left: -$medium;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<button
|
||||
v-wave
|
||||
class="heart-button circular"
|
||||
:class="{ favorited: state }"
|
||||
:style="{
|
||||
color: color ? getTextColor(color) : '',
|
||||
}"
|
||||
|
||||
235
src/components/shared/ImageLoader.vue
Normal 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>
|
||||
@@ -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('')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@ defineProps<{
|
||||
font-weight: 600;
|
||||
margin-left: $smaller;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.75;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
<div
|
||||
class="songlist-item rounded-sm"
|
||||
:class="[{ current: isCurrent() }, { contexton: context_menu_showing }]"
|
||||
@dblclick.prevent="emitUpdate"
|
||||
@dblclick="emitUpdate"
|
||||
@contextmenu.prevent="showMenu"
|
||||
>
|
||||
<TrackIndex v-if="!isSmall" :index="index" :is_fav="is_fav" @add-to-fav="addToFav(track.trackhash)" />
|
||||
<TrackIndex
|
||||
v-if="!isSmall"
|
||||
:index="index"
|
||||
:is_fav="is_fav"
|
||||
:show-inline-fav-icon="settings.showInlineFavIcon"
|
||||
@add-to-fav="addToFav(track.trackhash)"
|
||||
/>
|
||||
|
||||
<TrackTitle
|
||||
:track="track"
|
||||
:is_current="isCurrent()"
|
||||
@@ -23,10 +30,13 @@
|
||||
/>
|
||||
<TrackDuration
|
||||
:duration="track.duration || 0"
|
||||
@showMenu="showMenu"
|
||||
:help_text="track.help_text"
|
||||
:is_fav="is_fav"
|
||||
:showFavIcon="!isFavoritesPage"
|
||||
:showInlineFavIcon="settings.showInlineFavIcon"
|
||||
:highlightFavoriteTracks="settings.highlightFavoriteTracks"
|
||||
@showMenu="showMenu"
|
||||
@toggleFav="addToFav(track.trackhash)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -47,7 +57,9 @@ import TrackAlbum from './SongItem/TrackAlbum.vue'
|
||||
import TrackDuration from './SongItem/TrackDuration.vue'
|
||||
import TrackIndex from './SongItem/TrackIndex.vue'
|
||||
import TrackTitle from './SongItem/TrackTitle.vue'
|
||||
import useSettings from '@/stores/settings'
|
||||
|
||||
const settings = useSettings()
|
||||
const context_menu_showing = ref(false)
|
||||
|
||||
const queue = useQueueStore()
|
||||
@@ -131,9 +143,9 @@ const isFavoritesPage = route.path.startsWith('/favorites')
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray5;
|
||||
background-color: $gray;
|
||||
|
||||
.index {
|
||||
.index.ready {
|
||||
.text {
|
||||
transition-delay: 400ms;
|
||||
|
||||
@@ -157,6 +169,10 @@ const isFavoritesPage = route.path.startsWith('/favorites')
|
||||
.song-duration.help-text {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.options-and-duration .heart-icon.showInlineFavIcon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.index {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="options-and-duration">
|
||||
<div v-if="is_fav && showFavIcon !== false" class="heart-icon is-favorited">
|
||||
<div
|
||||
v-if="showInlineFavIcon"
|
||||
class="heart-icon"
|
||||
:class="{ showInlineFavIcon, 'is_fav': is_fav && highlightFavoriteTracks }"
|
||||
@click.stop="$emit('toggleFav')"
|
||||
>
|
||||
<HeartSvg :state="is_fav" :no_emit="true" />
|
||||
</div>
|
||||
<div class="song-duration" :class="{ has_help_text: help_text }">{{ formatSeconds(duration) }}</div>
|
||||
@@ -21,12 +26,15 @@ import HeartSvg from '../HeartSvg.vue'
|
||||
defineProps<{
|
||||
duration: number
|
||||
is_fav: boolean
|
||||
showInlineFavIcon: boolean
|
||||
highlightFavoriteTracks: boolean
|
||||
showFavIcon?: boolean
|
||||
help_text?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'showMenu', event: MouseEvent): void
|
||||
(e: 'toggleFav'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -39,23 +47,24 @@ defineEmits<{
|
||||
margin-right: $small;
|
||||
position: relative;
|
||||
|
||||
@include allPhones {
|
||||
gap: $small;
|
||||
}
|
||||
|
||||
@include mediumPhones {
|
||||
> .heart-icon.is-favorited {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .heart-icon.is-favorited {
|
||||
display: block;
|
||||
.heart-icon {
|
||||
display: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease-out;
|
||||
transform: scale(0.8);
|
||||
margin-right: $small;
|
||||
|
||||
svg {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
@include mediumPhones {
|
||||
display: none;
|
||||
@@ -66,6 +75,10 @@ defineEmits<{
|
||||
}
|
||||
}
|
||||
|
||||
.heart-icon.is_fav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
font-size: small;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
class="index t-center ellip"
|
||||
@click.prevent="$emit('addToFav')"
|
||||
@dblclick.prevent.stop="() => {}"
|
||||
:class="{ 'ready': !showInlineFavIcon }"
|
||||
>
|
||||
<div class="text">
|
||||
{{ index }}
|
||||
</div>
|
||||
<div class="heart-icon">
|
||||
<div class="heart-icon" v-if="!showInlineFavIcon">
|
||||
<HeartSvg :state="is_fav" :no_emit="true" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,6 +20,7 @@ import HeartSvg from "../HeartSvg.vue";
|
||||
defineProps<{
|
||||
index: number | string;
|
||||
is_fav: boolean | undefined;
|
||||
showInlineFavIcon: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
@@ -53,7 +55,6 @@ defineEmits<{
|
||||
transition: all 0.2s;
|
||||
transform: translateX(-1.5rem);
|
||||
|
||||
|
||||
button {
|
||||
border: none;
|
||||
width: 2rem;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<span class="title ellip">
|
||||
{{ track.title }}
|
||||
</span>
|
||||
<ExplicitIcon class="explicit-icon" v-if="track.explicit" />
|
||||
<MasterFlag :bitrate="track.bitrate" />
|
||||
</div>
|
||||
<div class="isSmallArtists">
|
||||
@@ -40,6 +41,7 @@ const imguri = paths.images.thumb.small;
|
||||
|
||||
import ArtistName from "../ArtistName.vue";
|
||||
import MasterFlag from "../MasterFlag.vue";
|
||||
import ExplicitIcon from "@/assets/icons/explicit.svg";
|
||||
|
||||
import { paths } from "@/config";
|
||||
|
||||
@@ -59,6 +61,10 @@ defineEmits<{
|
||||
position: relative;
|
||||
align-items: center;
|
||||
|
||||
.explicit-icon {
|
||||
margin-left: $small;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
margin-right: $medium;
|
||||
display: flex;
|
||||
|
||||
@@ -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<{
|
||||
|
||||
167
src/components/shared/TextLoader.vue
Normal 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>
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const development = import.meta.env.DEV
|
||||
|
||||
export function getBaseUrl() {
|
||||
const base_url = window.location.origin
|
||||
|
||||
if (!development) {
|
||||
return base_url
|
||||
return ''
|
||||
}
|
||||
|
||||
const base_url = window.location.origin
|
||||
const splits = base_url.split(':')
|
||||
return base_url.replace(splits[splits.length - 1], '1980')
|
||||
}
|
||||
|
||||
const base_url = getBaseUrl()
|
||||
axios.defaults.baseURL = base_url
|
||||
|
||||
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/',
|
||||
@@ -31,7 +35,8 @@ const imageRoutes = {
|
||||
|
||||
export const paths = {
|
||||
api: {
|
||||
favorites: base_url + '/favorites',
|
||||
onboardingData: '/onboarding-data',
|
||||
favorites: '/favorites',
|
||||
get favAlbums() {
|
||||
return this.favorites + '/albums'
|
||||
},
|
||||
@@ -50,15 +55,15 @@ export const paths = {
|
||||
get removeFavorite() {
|
||||
return this.favorites + '/remove'
|
||||
},
|
||||
artist: base_url + '/artist',
|
||||
lyrics: base_url + '/lyrics',
|
||||
plugins: base_url + '/plugins',
|
||||
artist: '/artist',
|
||||
lyrics: '/lyrics',
|
||||
plugins: '/plugins',
|
||||
get mixes() {
|
||||
return this.plugins + '/mixes'
|
||||
},
|
||||
|
||||
// Single album
|
||||
album: base_url + '/album',
|
||||
album: '/album',
|
||||
get albumartists() {
|
||||
return this.album + '/artists'
|
||||
},
|
||||
@@ -72,12 +77,12 @@ export const paths = {
|
||||
return this.album + '/other-versions'
|
||||
},
|
||||
folder: {
|
||||
base: base_url + '/folder',
|
||||
showInFiles: base_url + '/folder/show-in-files',
|
||||
base: '/folder',
|
||||
showInFiles: '/folder/show-in-files',
|
||||
},
|
||||
dir_browser: base_url + '/folder/dir-browser',
|
||||
dir_browser: '/folder/dir-browser',
|
||||
playlist: {
|
||||
base: base_url + '/playlists',
|
||||
base: '/playlists',
|
||||
get new() {
|
||||
return this.base + '/new'
|
||||
},
|
||||
@@ -85,8 +90,11 @@ export const paths = {
|
||||
return this.base + '/artists'
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
base: '/collections',
|
||||
},
|
||||
search: {
|
||||
base: base_url + '/search',
|
||||
base: '/search',
|
||||
get top() {
|
||||
return this.base + '/top?q='
|
||||
},
|
||||
@@ -104,13 +112,13 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
base: base_url + '/logger',
|
||||
base: '/logger',
|
||||
get logTrack() {
|
||||
return this.base + '/track/log'
|
||||
},
|
||||
},
|
||||
getall: {
|
||||
base: base_url + '/getall',
|
||||
base: '/getall',
|
||||
get albums() {
|
||||
return this.base + '/albums'
|
||||
},
|
||||
@@ -119,7 +127,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
base: base_url + '/colors',
|
||||
base: '/colors',
|
||||
get album() {
|
||||
return this.base + '/album'
|
||||
},
|
||||
@@ -142,9 +150,9 @@ export const paths = {
|
||||
return this.base + '/update'
|
||||
},
|
||||
},
|
||||
files: base_url + '/file',
|
||||
files: '/file',
|
||||
home: {
|
||||
base: base_url + '/home',
|
||||
base: '/nothome',
|
||||
get recentlyAdded() {
|
||||
return this.base + '/recents/added'
|
||||
},
|
||||
@@ -183,7 +191,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
backups: {
|
||||
base: base_url + '/backup',
|
||||
base: '/backup',
|
||||
get get_backups() {
|
||||
return this.base + '/list'
|
||||
},
|
||||
@@ -198,7 +206,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
base: base_url + '/logger',
|
||||
base: '/logger',
|
||||
get topArtists() {
|
||||
return this.base + '/top-artists'
|
||||
},
|
||||
@@ -216,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,
|
||||
|
||||
@@ -1,56 +1,109 @@
|
||||
import useModal from "@/stores/modal";
|
||||
import useAlbum from "@/stores/pages/album";
|
||||
import useTracklist from "@/stores/queue/tracklist";
|
||||
import { router, Routes } from '@/router'
|
||||
|
||||
import { Option, Playlist } from "@/interfaces";
|
||||
import { addAlbumToPlaylist } from "@/requests/playlists";
|
||||
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
|
||||
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
|
||||
import useAlbum from '@/stores/pages/album'
|
||||
import useCollection from '@/stores/pages/collections'
|
||||
import useTracklist from '@/stores/queue/tracklist'
|
||||
|
||||
export default async () => {
|
||||
const album = useAlbum();
|
||||
import { getAlbumTracks } from '@/requests/album'
|
||||
import { addOrRemoveItemFromCollection } from '@/requests/collections'
|
||||
import { addAlbumToPlaylist } from '@/requests/playlists'
|
||||
|
||||
const play_next = <Option>{
|
||||
label: "Play next",
|
||||
action: () => {
|
||||
const tracks = album.tracks.filter(
|
||||
(track) => !track.is_album_disc_number
|
||||
);
|
||||
useTracklist().insertAfterCurrent(tracks);
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
};
|
||||
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
|
||||
import { Album, Collection, Option, Playlist, Track } from '@/interfaces'
|
||||
import { get_find_on_social, getAddToCollectionOptions, getAddToPlaylistOptions } from './utils'
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: "Add to queue",
|
||||
action: () => {
|
||||
const tracks = album.tracks.filter(
|
||||
(track) => !track.is_album_disc_number
|
||||
);
|
||||
useTracklist().addTracks(tracks);
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
};
|
||||
export default async (album?: Album) => {
|
||||
const albumStore = useAlbum()
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
const store = album;
|
||||
addAlbumToPlaylist(playlist, store.info.albumhash);
|
||||
};
|
||||
if (!album) {
|
||||
album = albumStore.info
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: "Add to Playlist",
|
||||
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
albumhash: album.info.albumhash,
|
||||
playlist_name: album.info.title,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
};
|
||||
const play_next = <Option>{
|
||||
label: 'Play next',
|
||||
action: async () => {
|
||||
let tracks: Track[] = []
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
get_find_on_social(),
|
||||
];
|
||||
};
|
||||
if (album) {
|
||||
tracks = await getAlbumTracks(album.albumhash)
|
||||
} else {
|
||||
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
|
||||
}
|
||||
|
||||
useTracklist().insertAfterCurrent(tracks)
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
}
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: 'Add to queue',
|
||||
action: async () => {
|
||||
let tracks: Track[] = []
|
||||
|
||||
if (album) {
|
||||
tracks = await getAlbumTracks(album.albumhash)
|
||||
} else {
|
||||
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
|
||||
}
|
||||
|
||||
useTracklist().addTracks(tracks)
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
}
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addAlbumToPlaylist(playlist, album.albumhash)
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: 'Add to Playlist',
|
||||
children: () =>
|
||||
getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
albumhash: album.albumhash,
|
||||
playlist_name: album.title,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const addToPageAction = (page: Collection) => {
|
||||
addOrRemoveItemFromCollection(page.id, album, 'album', 'add')
|
||||
}
|
||||
|
||||
const add_to_page: Option = {
|
||||
label: 'Add to Collection',
|
||||
children: () =>
|
||||
getAddToCollectionOptions(addToPageAction, {
|
||||
collection: null,
|
||||
hash: album.albumhash,
|
||||
type: 'album',
|
||||
extra: {},
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const remove_from_page: Option = {
|
||||
label: 'Remove item',
|
||||
action: async () => {
|
||||
const success = await addOrRemoveItemFromCollection(
|
||||
parseInt(router.currentRoute.value.params.collection as string),
|
||||
album,
|
||||
'album',
|
||||
'remove'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
useCollection().removeLocalItem(album, 'album')
|
||||
}
|
||||
},
|
||||
icon: DeleteIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
...[router.currentRoute.value.name === Routes.Page ? remove_from_page : add_to_page],
|
||||
get_find_on_social('album', '', album),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,54 +1,101 @@
|
||||
import modal from "@/stores/modal";
|
||||
import useTracklist from "@/stores/queue/tracklist";
|
||||
import { Routes, router } from '@/router'
|
||||
|
||||
import { getArtistTracks } from "@/requests/artists";
|
||||
import { addArtistToPlaylist } from "@/requests/playlists";
|
||||
import useCollection from '@/stores/pages/collections'
|
||||
import useTracklist from '@/stores/queue/tracklist'
|
||||
|
||||
import { Option, Playlist } from "@/interfaces";
|
||||
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
|
||||
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
|
||||
import { getArtistTracks } from '@/requests/artists'
|
||||
import { addOrRemoveItemFromCollection } from '@/requests/collections'
|
||||
import { addArtistToPlaylist } from '@/requests/playlists'
|
||||
|
||||
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
|
||||
import { Artist, Collection, Option, Playlist } from '@/interfaces'
|
||||
import { getAddToCollectionOptions, getAddToPlaylistOptions, get_find_on_social } from './utils'
|
||||
|
||||
export default async (artisthash: string, artistname: string) => {
|
||||
const play_next = <Option>{
|
||||
label: "Play next",
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then((tracks) => {
|
||||
const store = useTracklist();
|
||||
store.insertAfterCurrent(tracks);
|
||||
});
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
};
|
||||
const play_next = <Option>{
|
||||
label: 'Play next',
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then(tracks => {
|
||||
const store = useTracklist()
|
||||
store.insertAfterCurrent(tracks)
|
||||
})
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
}
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: "Add to queue",
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then((tracks) => {
|
||||
const store = useTracklist();
|
||||
store.addTracks(tracks);
|
||||
});
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
};
|
||||
const add_to_queue = <Option>{
|
||||
label: 'Add to queue',
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then(tracks => {
|
||||
const store = useTracklist()
|
||||
store.addTracks(tracks)
|
||||
})
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
}
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addArtistToPlaylist(playlist, artisthash);
|
||||
};
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addArtistToPlaylist(playlist, artisthash)
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: "Add to Playlist",
|
||||
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
artisthash,
|
||||
playlist_name: `This is ${artistname}`,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
};
|
||||
const add_to_playlist: Option = {
|
||||
label: 'Add to Playlist',
|
||||
children: () =>
|
||||
getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
artisthash,
|
||||
playlist_name: `This is ${artistname}`,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
get_find_on_social("artist"),
|
||||
];
|
||||
};
|
||||
const addToCollectionAction = (collection: Collection) => {
|
||||
addOrRemoveItemFromCollection(
|
||||
collection.id,
|
||||
{
|
||||
artisthash,
|
||||
} as Artist,
|
||||
'artist',
|
||||
'add'
|
||||
)
|
||||
}
|
||||
|
||||
const add_to_page: Option = {
|
||||
label: 'Add to Collection',
|
||||
children: () =>
|
||||
getAddToCollectionOptions(addToCollectionAction, {
|
||||
collection: null,
|
||||
hash: artisthash,
|
||||
type: 'artist',
|
||||
extra: {},
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const remove_from_collection: Option = {
|
||||
label: 'Remove item',
|
||||
action: async () => {
|
||||
const success = await addOrRemoveItemFromCollection(
|
||||
parseInt(router.currentRoute.value.params.collection as string),
|
||||
{
|
||||
artisthash,
|
||||
} as Artist,
|
||||
'artist',
|
||||
'remove'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
useCollection().removeLocalItem({ artisthash } as Artist, 'artist')
|
||||
}
|
||||
},
|
||||
icon: DeleteIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
...[router.currentRoute.value.name === Routes.Page ? remove_from_collection : add_to_page],
|
||||
get_find_on_social('artist'),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import modal from '@/stores/modal'
|
||||
import useAlbum from '@/stores/pages/album'
|
||||
import useArtist from '@/stores/pages/artist'
|
||||
|
||||
import { SearchIcon } from '@/icons'
|
||||
import { Option, Playlist } from '@/interfaces'
|
||||
import { Album, Collection, Option, Playlist } from '@/interfaces'
|
||||
import { getAllCollections } from '@/requests/collections'
|
||||
import { getAllPlaylists } from '@/requests/playlists'
|
||||
|
||||
export const separator: Option = {
|
||||
type: 'separator',
|
||||
}
|
||||
|
||||
export function get_new_playlist_option(
|
||||
new_playlist_modal_props: any = {}
|
||||
): Option {
|
||||
export function get_new_playlist_option(new_playlist_modal_props: any = {}): Option {
|
||||
return {
|
||||
label: 'New playlist',
|
||||
action: () => {
|
||||
@@ -21,6 +19,15 @@ export function get_new_playlist_option(
|
||||
}
|
||||
}
|
||||
|
||||
export function get_new_collection_option(new_collection_modal_props: any = {}): Option {
|
||||
return {
|
||||
label: 'New Collection',
|
||||
action: () => {
|
||||
modal().showCollectionModal(new_collection_modal_props)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type action = (playlist: Playlist) => void
|
||||
|
||||
/**
|
||||
@@ -29,10 +36,7 @@ type action = (playlist: Playlist) => void
|
||||
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
|
||||
* @returns A list of options to be used in a context menu
|
||||
*/
|
||||
export async function getAddToPlaylistOptions(
|
||||
addToPlaylist: action,
|
||||
new_playlist_modal_props: any = {}
|
||||
) {
|
||||
export async function getAddToPlaylistOptions(addToPlaylist: action, new_playlist_modal_props: any = {}) {
|
||||
const new_playlist = get_new_playlist_option(new_playlist_modal_props)
|
||||
const p = await getAllPlaylists(true)
|
||||
|
||||
@@ -44,7 +48,7 @@ export async function getAddToPlaylistOptions(
|
||||
|
||||
let playlists = <Option[]>[]
|
||||
|
||||
playlists = p.map((playlist) => {
|
||||
playlists = p.map(playlist => {
|
||||
return <Option>{
|
||||
label: playlist.name,
|
||||
action: () => {
|
||||
@@ -56,20 +60,45 @@ export async function getAddToPlaylistOptions(
|
||||
return [...items, separator, ...playlists]
|
||||
}
|
||||
|
||||
export const get_find_on_social = (page = 'album', query = '') => {
|
||||
/**
|
||||
*
|
||||
* @param addToPlaylist Function to be called when a playlist is selected
|
||||
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
|
||||
* @returns A list of options to be used in a context menu
|
||||
*/
|
||||
export async function getAddToCollectionOptions(
|
||||
addToCollection: (collection: Collection) => void,
|
||||
new_page_modal_props: any = {}
|
||||
) {
|
||||
const new_page = get_new_collection_option(new_page_modal_props)
|
||||
const data = await getAllCollections()
|
||||
|
||||
let items = [new_page]
|
||||
|
||||
if (data.length === 0) {
|
||||
return items
|
||||
}
|
||||
|
||||
let collections = <Option[]>[]
|
||||
|
||||
collections = data.map(collection => {
|
||||
return <Option>{
|
||||
label: collection.name,
|
||||
action: () => {
|
||||
addToCollection(collection)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [...items, separator, ...collections]
|
||||
}
|
||||
|
||||
export const get_find_on_social = (page = 'album', query = '', album?: Album) => {
|
||||
const is_album = page === 'album'
|
||||
const getAlbumSearchTerm = () => {
|
||||
const store = useAlbum()
|
||||
|
||||
return `${store.info.title} - ${store.info.albumartists
|
||||
.map((a) => a.name)
|
||||
.join(', ')}`
|
||||
return `${album?.title} - ${album?.albumartists.map(a => a.name).join(', ')}`
|
||||
}
|
||||
const search_term = query
|
||||
? query
|
||||
: is_album
|
||||
? getAlbumSearchTerm()
|
||||
: useArtist().info.name
|
||||
const search_term = query ? query : is_album ? getAlbumSearchTerm() : useArtist().info.name
|
||||
|
||||
return <Option>{
|
||||
label: 'Search on',
|
||||
@@ -77,67 +106,36 @@ export const get_find_on_social = (page = 'album', query = '') => {
|
||||
children: async () => [
|
||||
{
|
||||
label: 'Google',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.google.com/search?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.google.com/search?q=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'YouTube',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.youtube.com/results?search_query=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.youtube.com/results?search_query=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Spotify',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://open.spotify.com/search/${search_term}/${page}s`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://open.spotify.com/search/${search_term}/${page}s`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Tidal',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://listen.tidal.com/search/${page}s?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://listen.tidal.com/search/${page}s?q=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Apple Music',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://music.apple.com/search?term=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://music.apple.com/search?term=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Deezer',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.deezer.com/search/${search_term}/${page}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.deezer.com/search/${search_term}/${page}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Wikipedia',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
window.open(`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Last.fm',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.last.fm/search/${page}s?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.last.fm/search/${page}s?q=${search_term}`, '_blank'),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -105,4 +105,6 @@ export interface DBSettings {
|
||||
lastfmApiKey: string;
|
||||
lastfmApiSecret: string;
|
||||
lastfmSessionKey: string;
|
||||
showPlaylistsInFolderView: boolean;
|
||||
artistArticleAwareSorting: boolean;
|
||||
}
|
||||
|
||||
@@ -1,88 +1,78 @@
|
||||
import { Store } from "pinia";
|
||||
import { Ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Store } from 'pinia'
|
||||
import { Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { ContextSrc } from "@/enums";
|
||||
import { Track } from "@/interfaces";
|
||||
import useContextStore from "@/stores/context";
|
||||
import { ContextSrc } from '@/enums'
|
||||
import { Album, Track } from '@/interfaces'
|
||||
import useContextStore from '@/stores/context'
|
||||
|
||||
import albumContextItems from "@/context_menus/album";
|
||||
import artistContextItems from "@/context_menus/artist";
|
||||
import folderContextItems from "@/context_menus/folder";
|
||||
import trackContextItems from "@/context_menus/track";
|
||||
import queueContextItems from "@/context_menus/queue";
|
||||
import albumContextItems from '@/context_menus/album'
|
||||
import artistContextItems from '@/context_menus/artist'
|
||||
import folderContextItems from '@/context_menus/folder'
|
||||
import trackContextItems from '@/context_menus/track'
|
||||
import queueContextItems from '@/context_menus/queue'
|
||||
|
||||
let stop_prev_watcher = () => {};
|
||||
let stop_prev_watcher = () => {}
|
||||
|
||||
function flagWatcher(menu: Store, flag: Ref<boolean>) {
|
||||
stop_prev_watcher();
|
||||
stop_prev_watcher()
|
||||
|
||||
if (flag.value) {
|
||||
return (flag.value = false);
|
||||
}
|
||||
if (flag.value) {
|
||||
return (flag.value = false)
|
||||
}
|
||||
|
||||
// watch for context menu visibility and reset flag
|
||||
stop_prev_watcher = menu.$subscribe((mutation, state) => {
|
||||
//@ts-ignore
|
||||
flag.value = state.visible;
|
||||
});
|
||||
// watch for context menu visibility and reset flag
|
||||
stop_prev_watcher = menu.$subscribe((mutation, state) => {
|
||||
//@ts-ignore
|
||||
flag.value = state.visible
|
||||
})
|
||||
}
|
||||
|
||||
export const showTrackContextMenu = (
|
||||
e: MouseEvent,
|
||||
track: Track,
|
||||
flag: Ref<boolean>,
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
const options = () => trackContextItems(track);
|
||||
export const showTrackContextMenu = (e: MouseEvent, track: Track, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore()
|
||||
const options = () => trackContextItems(track)
|
||||
|
||||
menu.showContextMenu(e, options, ContextSrc.Track);
|
||||
menu.showContextMenu(e, options, ContextSrc.Track)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore();
|
||||
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>, album?: Album) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => albumContextItems();
|
||||
menu.showContextMenu(e, options, ContextSrc.AlbumHeader);
|
||||
const options = () => albumContextItems(album)
|
||||
menu.showContextMenu(e, options, ContextSrc.AlbumHeader)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showFolderContextMenu = (
|
||||
e: MouseEvent,
|
||||
flag: Ref<boolean>,
|
||||
source: ContextSrc,
|
||||
path: string
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
export const showFolderContextMenu = (e: MouseEvent, flag: Ref<boolean>, source: ContextSrc, path: string) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => folderContextItems(path);
|
||||
menu.showContextMenu(e, options, source);
|
||||
const options = () => folderContextItems(path)
|
||||
menu.showContextMenu(e, options, source)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showArtistContextMenu = (
|
||||
e: MouseEvent,
|
||||
flag: Ref<boolean>,
|
||||
artisthash: string,
|
||||
artistname: string
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
export const showArtistContextMenu = (e: MouseEvent, flag: Ref<boolean>, artisthash: string, artistname: string) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => artistContextItems(artisthash, artistname);
|
||||
menu.showContextMenu(e, options, ContextSrc.ArtistHeader);
|
||||
const options = () => artistContextItems(artisthash, artistname)
|
||||
menu.showContextMenu(e, options, ContextSrc.ArtistHeader)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showQueueContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore();
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => queueContextItems();
|
||||
menu.showContextMenu(e, options, ContextSrc.Queue);
|
||||
const options = () => queueContextItems()
|
||||
menu.showContextMenu(e, options, ContextSrc.Queue)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
// export const showAlbumCardContextMenu = (e: MouseEvent, flag: Ref<boolean>, album: Album) => {
|
||||
|
||||
// }
|
||||
|
||||
@@ -84,12 +84,10 @@ export async function playFromFolderCard(folderpath: string) {
|
||||
export async function playFromFavorites(track: Track | undefined) {
|
||||
const queue = useQueue()
|
||||
const tracklist = useTracklist()
|
||||
console.log(track)
|
||||
|
||||
// if our tracklist is not from favorites, we need to fetch the favorites
|
||||
if (tracklist.from.type !== FromOptions.favorite) {
|
||||
const res = await getFavTracks(0, -1)
|
||||
console.log(res)
|
||||
tracklist.setFromFav(res.tracks)
|
||||
}
|
||||
|
||||
@@ -99,7 +97,6 @@ export async function playFromFavorites(track: Track | undefined) {
|
||||
index = tracklist.tracklist.findIndex(t => t.trackhash === track?.trackhash)
|
||||
}
|
||||
|
||||
console.log(tracklist.tracklist)
|
||||
queue.play(index)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Track extends AlbumDisc {
|
||||
trackhash: string
|
||||
filetype: string
|
||||
is_favorite: boolean
|
||||
explicit: boolean
|
||||
type?: string
|
||||
|
||||
og_title: string
|
||||
og_album: string
|
||||
@@ -40,6 +42,9 @@ export interface Track extends AlbumDisc {
|
||||
trend: 'rising' | 'falling' | 'stable'
|
||||
is_new: boolean
|
||||
}
|
||||
|
||||
color?: string
|
||||
blurhash?: string
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
@@ -66,6 +71,7 @@ export interface Album {
|
||||
type?: string
|
||||
|
||||
color?: string
|
||||
blurhash?: string
|
||||
copyright?: string
|
||||
help_text?: string
|
||||
time?: string
|
||||
@@ -117,6 +123,7 @@ export interface HomePageItem {
|
||||
items: { type: string; item?: any; with_helptext?: boolean }[]
|
||||
path?: string
|
||||
seeAllText?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
@@ -126,11 +133,13 @@ export interface Artist {
|
||||
trackcount: number
|
||||
albumcount: number
|
||||
duration: number
|
||||
color: string
|
||||
color?: string
|
||||
blurhash?: string
|
||||
is_favorite?: boolean
|
||||
help_text?: string
|
||||
time?: string
|
||||
genres: Genre[]
|
||||
type?: string
|
||||
|
||||
// available in charts
|
||||
trend?: {
|
||||
@@ -178,6 +187,15 @@ export interface Playlist {
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: number
|
||||
name: string
|
||||
items: (Album | Artist | Mix | Playlist)[]
|
||||
extra: {
|
||||
description: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Radio {
|
||||
name: string
|
||||
image: string
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Album, Artist, Genre, StatItem, Track } from '@/interfaces'
|
||||
import { NotifType, useToast } from '@/stores/notification'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
export const getArtistData = async (hash: string, limit: number = 5, albumlimit: number = 7) => {
|
||||
export const getArtistData = async (hash: string, limit: number = 15, albumlimit: number = 7) => {
|
||||
interface ArtistData {
|
||||
artist: Artist
|
||||
tracks: Track[]
|
||||
@@ -19,7 +19,7 @@ export const getArtistData = async (hash: string, limit: number = 5, albumlimit:
|
||||
|
||||
const { data, error, status } = await useAxios({
|
||||
method: 'GET',
|
||||
url: paths.api.artist + `/${hash}?limit=${limit}&albumlimit=${albumlimit}`,
|
||||
url: paths.api.artist + `/${hash}?tracklimit=${limit}&albumlimit=${albumlimit}`,
|
||||
})
|
||||
|
||||
if (status == 404) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import useAxios from './useAxios'
|
||||
import { User, UserSimplified } from '@/interfaces'
|
||||
|
||||
export async function getAllUsers<T extends boolean>(simple: T = true as T) {
|
||||
interface res {
|
||||
interface Response {
|
||||
users: T extends true ? UserSimplified[] : User[]
|
||||
settings: { [key: string]: any }
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export async function getAllUsers<T extends boolean>(simple: T = true as T) {
|
||||
})
|
||||
|
||||
if (res.status === 200) {
|
||||
return res.data as res
|
||||
return res.data as Response
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
138
src/requests/collections.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { paths } from '@/config'
|
||||
import { Album, Artist, Collection, Mix, Playlist } from '@/interfaces'
|
||||
import { Notification, NotifType } from '@/stores/notification'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
const { base: baseCollectionUrl } = paths.api.collections
|
||||
|
||||
export async function getAllCollections() {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return data as Collection[]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function getCollection(collection_id: string) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}`,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return data as Collection
|
||||
}
|
||||
|
||||
export async function createNewCollection(
|
||||
name: string,
|
||||
description: string,
|
||||
items?: { hash: string; type: string; extra: any }[]
|
||||
) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl,
|
||||
props: {
|
||||
name,
|
||||
description,
|
||||
items,
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (status == 201) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function updateCollection(collection: Collection, name: string, description: string) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection.id}`,
|
||||
props: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
method: 'PUT',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return data as Collection
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function addOrRemoveItemFromCollection(
|
||||
collection_id: number,
|
||||
item: Album | Artist | Mix | Playlist,
|
||||
type: string,
|
||||
command: 'add' | 'remove'
|
||||
) {
|
||||
const payload = {
|
||||
type: type,
|
||||
hash: '',
|
||||
extra: {},
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'album':
|
||||
payload.hash = (item as Album).albumhash
|
||||
break
|
||||
case 'artist':
|
||||
payload.hash = (item as Artist).artisthash
|
||||
break
|
||||
case 'mix':
|
||||
payload.hash = (item as Mix).sourcehash
|
||||
break
|
||||
case 'playlist':
|
||||
payload.hash = (item as Playlist).id.toString()
|
||||
break
|
||||
}
|
||||
|
||||
if (payload.hash === '') {
|
||||
throw new Error('Invalid item type. Item not added to collection.')
|
||||
}
|
||||
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}/items`,
|
||||
props: {
|
||||
item: payload,
|
||||
},
|
||||
method: command == 'add' ? 'POST' : 'DELETE',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
new Notification(
|
||||
`${payload.type[0].toUpperCase() + payload.type.slice(1)} ${
|
||||
command == 'add' ? 'added' : 'removed'
|
||||
} to page`,
|
||||
NotifType.Success
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (status == 400) {
|
||||
new Notification(`${payload.type[0].toUpperCase() + payload.type.slice(1)} already in collection`, NotifType.Error)
|
||||
return false
|
||||
}
|
||||
|
||||
new Notification('Failed: ' + data.error, NotifType.Error)
|
||||
return false
|
||||
}
|
||||
|
||||
export async function deleteCollection(collection_id: number) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export async function getBackups() {
|
||||
playlists: number
|
||||
scrobbles: number
|
||||
favorites: number
|
||||
collections: number
|
||||
date: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { FetchProps } from '@/interfaces'
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import useModal from '@/stores/modal'
|
||||
|
||||
import useLoaderStore from '@/stores/loader'
|
||||
import { logoutUser } from './auth'
|
||||
|
||||
const development = import.meta.env.DEV
|
||||
|
||||
export function getBaseUrl() {
|
||||
const base_url = window.location.origin
|
||||
|
||||
if (!development) {
|
||||
return base_url
|
||||
}
|
||||
|
||||
const splits = base_url.split(':')
|
||||
return base_url.replace(splits[splits.length - 1], '1980')
|
||||
if (window.location.protocol === 'https:') {
|
||||
const meta = document.createElement('meta');
|
||||
meta.httpEquiv = 'Content-Security-Policy';
|
||||
meta.content = 'upgrade-insecure-requests';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
|
||||
axios.defaults.baseURL = getBaseUrl()
|
||||
|
||||
export default async (args: FetchProps, withCredentials: boolean = true) => {
|
||||
const on_ngrok = args.url.includes('ngrok')
|
||||
const ngrok_config = {
|
||||
@@ -61,7 +53,7 @@ export default async (args: FetchProps, withCredentials: boolean = true) => {
|
||||
try {
|
||||
isSignatureError = error.response.data.msg == 'Signature verification failed'
|
||||
} catch (error) {
|
||||
console.log('Error:', error)
|
||||
console.error('Error:', error)
|
||||
}
|
||||
|
||||
if (error.response?.status === 422 && isSignatureError) {
|
||||
|
||||
@@ -1,261 +1,279 @@
|
||||
import { createRouter, createWebHashHistory, RouterOptions } from "vue-router";
|
||||
import { createRouter, createWebHashHistory, RouterOptions } from 'vue-router'
|
||||
|
||||
import state from "@/composables/state";
|
||||
import useAlbumPageStore from "@/stores/pages/album";
|
||||
import useFolderPageStore from "@/stores/pages/folder";
|
||||
import usePlaylistPageStore from "@/stores/pages/playlist";
|
||||
import usePlaylistListPageStore from "@/stores/pages/playlists";
|
||||
import useArtistPageStore from "@/stores/pages/artist";
|
||||
import state from '@/composables/state'
|
||||
import useAlbumPageStore from '@/stores/pages/album'
|
||||
import useFolderPageStore from '@/stores/pages/folder'
|
||||
import usePlaylistPageStore from '@/stores/pages/playlist'
|
||||
import usePlaylistListPageStore from '@/stores/pages/playlists'
|
||||
import useArtistPageStore from '@/stores/pages/artist'
|
||||
|
||||
|
||||
import HomeView from "@/views/HomeView";
|
||||
const Lyrics = () => import("@/views/LyricsView");
|
||||
const ArtistView = () => import("@/views/ArtistView");
|
||||
const NotFound = () => import("@/views/NotFound.vue");
|
||||
const NowPlaying = () => import("@/views/NowPlaying");
|
||||
const SearchView = () => import("@/views/SearchView");
|
||||
const AlbumList = () => import("@/views/AlbumListView");
|
||||
const FolderView = () => import("@/views/FolderView.vue");
|
||||
const FavoritesView = () => import("@/views/Favorites.vue");
|
||||
const SettingsView = () => import("@/views/SettingsView.vue");
|
||||
const AlbumView = () => import("@/views/AlbumView/index.vue");
|
||||
const ArtistTracksView = () => import("@/views/ArtistTracks.vue");
|
||||
const PlaylistListView = () => import("@/views/PlaylistList.vue");
|
||||
const FavoriteTracks = () => import("@/views/FavoriteTracks.vue");
|
||||
const PlaylistView = () => import("@/views/PlaylistView/index.vue");
|
||||
const ArtistDiscographyView = () => import("@/views/ArtistDiscography.vue");
|
||||
const FavoriteCardScroller = () => import("@/views/FavoriteCardScroller.vue");
|
||||
const StatsView = () => import("@/views/Stats/main.vue");
|
||||
const MixView = () => import("@/views/MixView.vue");
|
||||
const MixListView = () => import("@/views/MixListView.vue");
|
||||
import HomeView from '@/views/HomeView'
|
||||
const Lyrics = () => import('@/views/LyricsView')
|
||||
const ArtistView = () => import('@/views/ArtistView')
|
||||
const NotFound = () => import('@/views/NotFound.vue')
|
||||
const NowPlaying = () => import('@/views/NowPlaying')
|
||||
const SearchView = () => import('@/views/SearchView')
|
||||
const AlbumList = () => import('@/views/AlbumListView')
|
||||
const FolderView = () => import('@/views/FolderView.vue')
|
||||
const FavoritesView = () => import('@/views/Favorites.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const AlbumView = () => import('@/views/AlbumView/index.vue')
|
||||
const ArtistTracksView = () => import('@/views/ArtistTracks.vue')
|
||||
const PlaylistListView = () => import('@/views/PlaylistList.vue')
|
||||
const FavoriteTracks = () => import('@/views/FavoriteTracks.vue')
|
||||
const PlaylistView = () => import('@/views/PlaylistView/index.vue')
|
||||
const ArtistDiscographyView = () => import('@/views/ArtistDiscography.vue')
|
||||
const FavoriteCardScroller = () => import('@/views/FavoriteCardScroller.vue')
|
||||
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",
|
||||
name: "FolderView",
|
||||
component: FolderView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
await useFolderPageStore()
|
||||
.fetchAll(to.params.path, true)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/folder/:path',
|
||||
name: 'FolderView',
|
||||
component: FolderView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
await useFolderPageStore()
|
||||
.fetchAll(to.params.path, true)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const playlists = {
|
||||
path: "/playlists",
|
||||
name: "PlaylistList",
|
||||
component: PlaylistListView,
|
||||
beforeEnter: async () => {
|
||||
state.loading.value = true;
|
||||
await usePlaylistListPageStore()
|
||||
.fetchAll()
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/playlists',
|
||||
name: 'PlaylistList',
|
||||
component: PlaylistListView,
|
||||
beforeEnter: async () => {
|
||||
state.loading.value = true
|
||||
await usePlaylistListPageStore()
|
||||
.fetchAll()
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const playlistView = {
|
||||
path: "/playlist/:pid",
|
||||
name: "PlaylistView",
|
||||
component: PlaylistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
await usePlaylistPageStore()
|
||||
.fetchAll(to.params.pid)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/playlist/:pid',
|
||||
name: 'PlaylistView',
|
||||
component: PlaylistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
await usePlaylistPageStore()
|
||||
.fetchAll(to.params.pid)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const albumView = {
|
||||
path: "/albums/:albumhash",
|
||||
name: "AlbumView",
|
||||
component: AlbumView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
const store = useAlbumPageStore();
|
||||
path: '/albums/:albumhash',
|
||||
name: 'AlbumView',
|
||||
component: AlbumView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
const store = useAlbumPageStore()
|
||||
|
||||
await store.fetchTracksAndArtists(to.params.albumhash).then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
await store.fetchTracksAndArtists(to.params.albumhash).then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const artistView = {
|
||||
path: "/artists/:hash",
|
||||
name: "ArtistView",
|
||||
component: ArtistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
path: '/artists/:hash',
|
||||
name: 'ArtistView',
|
||||
component: ArtistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
|
||||
await useArtistPageStore()
|
||||
.getData(to.params.hash)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
await useArtistPageStore()
|
||||
.getData(to.params.hash)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const NowPlayingView = {
|
||||
path: "/nowplaying/:tab",
|
||||
name: "NowPlaying",
|
||||
component: NowPlaying,
|
||||
};
|
||||
path: '/nowplaying/:tab',
|
||||
name: 'NowPlaying',
|
||||
component: NowPlaying,
|
||||
}
|
||||
|
||||
const LyricsView = {
|
||||
path: "/lyrics",
|
||||
name: "LyricsView",
|
||||
component: Lyrics,
|
||||
};
|
||||
path: '/lyrics',
|
||||
name: 'LyricsView',
|
||||
component: Lyrics,
|
||||
}
|
||||
|
||||
const ArtistTracks = {
|
||||
path: "/artists/:hash/tracks",
|
||||
name: "ArtistTracks",
|
||||
component: ArtistTracksView,
|
||||
};
|
||||
path: '/artists/:hash/tracks',
|
||||
name: 'ArtistTracks',
|
||||
component: ArtistTracksView,
|
||||
}
|
||||
|
||||
const artistDiscography = {
|
||||
path: "/artists/:hash/discography/:type",
|
||||
name: "ArtistDiscographyView",
|
||||
component: ArtistDiscographyView,
|
||||
};
|
||||
path: '/artists/:hash/discography/:type',
|
||||
name: 'ArtistDiscographyView',
|
||||
component: ArtistDiscographyView,
|
||||
}
|
||||
|
||||
const settings = {
|
||||
path: "/settings/:tab",
|
||||
name: "SettingsView",
|
||||
component: SettingsView,
|
||||
};
|
||||
path: '/settings/:tab',
|
||||
name: 'SettingsView',
|
||||
component: SettingsView,
|
||||
}
|
||||
|
||||
const search = {
|
||||
path: "/search/:page",
|
||||
name: "SearchView",
|
||||
component: SearchView,
|
||||
};
|
||||
path: '/search/:page',
|
||||
name: 'SearchView',
|
||||
component: SearchView,
|
||||
}
|
||||
|
||||
const favorites = {
|
||||
path: "/favorites",
|
||||
name: "FavoritesView",
|
||||
component: FavoritesView,
|
||||
};
|
||||
path: '/favorites',
|
||||
name: 'FavoritesView',
|
||||
component: FavoritesView,
|
||||
}
|
||||
|
||||
const favoriteAlbums = {
|
||||
path: "/favorites/albums",
|
||||
name: "FavoriteAlbums",
|
||||
component: FavoriteCardScroller,
|
||||
};
|
||||
path: '/favorites/albums',
|
||||
name: 'FavoriteAlbums',
|
||||
component: FavoriteCardScroller,
|
||||
}
|
||||
|
||||
const favoriteArtists = {
|
||||
path: "/favorites/artists",
|
||||
name: "FavoriteArtists",
|
||||
component: FavoriteCardScroller,
|
||||
};
|
||||
path: '/favorites/artists',
|
||||
name: 'FavoriteArtists',
|
||||
component: FavoriteCardScroller,
|
||||
}
|
||||
|
||||
const favoriteTracks = {
|
||||
path: "/favorites/tracks",
|
||||
name: "FavoriteTracks",
|
||||
component: FavoriteTracks,
|
||||
};
|
||||
path: '/favorites/tracks',
|
||||
name: 'FavoriteTracks',
|
||||
component: FavoriteTracks,
|
||||
}
|
||||
|
||||
const notFound = {
|
||||
name: "NotFound",
|
||||
path: "/:pathMatch(.*)",
|
||||
component: NotFound,
|
||||
};
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)',
|
||||
component: NotFound,
|
||||
}
|
||||
|
||||
const Home = {
|
||||
path: "/",
|
||||
name: "Home",
|
||||
component: HomeView,
|
||||
};
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: HomeView,
|
||||
}
|
||||
|
||||
const AlbumListView = {
|
||||
path: "/albums",
|
||||
name: "AlbumListView",
|
||||
component: AlbumList,
|
||||
};
|
||||
path: '/albums',
|
||||
name: 'AlbumListView',
|
||||
component: AlbumList,
|
||||
}
|
||||
|
||||
const Stats = {
|
||||
path: "/stats",
|
||||
name: "StatsView",
|
||||
component: StatsView,
|
||||
};
|
||||
path: '/stats',
|
||||
name: 'StatsView',
|
||||
component: StatsView,
|
||||
}
|
||||
|
||||
const ArtistListView = {
|
||||
...AlbumListView,
|
||||
path: "/artists",
|
||||
name: "ArtistListView",
|
||||
};
|
||||
...AlbumListView,
|
||||
path: '/artists',
|
||||
name: 'ArtistListView',
|
||||
}
|
||||
|
||||
const Mix = {
|
||||
path: "/mix/:mixid",
|
||||
name: "MixView",
|
||||
component: MixView,
|
||||
};
|
||||
path: '/mix/:mixid',
|
||||
name: 'MixView',
|
||||
component: MixView,
|
||||
}
|
||||
|
||||
const MixList = {
|
||||
path: "/mixes/:type",
|
||||
name: "MixListView",
|
||||
component: MixListView,
|
||||
};
|
||||
path: '/mixes/:type',
|
||||
name: 'MixListView',
|
||||
component: MixListView,
|
||||
}
|
||||
|
||||
const PageView = {
|
||||
path: '/collections/:collection',
|
||||
name: 'Collection',
|
||||
component: Collection,
|
||||
}
|
||||
|
||||
const OnboardingView = {
|
||||
path: '/onboarding/:step?',
|
||||
name: 'Onboarding',
|
||||
alias: ['/manconfig/:step?'],
|
||||
component: Onboarding,
|
||||
}
|
||||
|
||||
const routes = [
|
||||
folder,
|
||||
playlists,
|
||||
playlistView,
|
||||
albumView,
|
||||
artistView,
|
||||
artistDiscography,
|
||||
settings,
|
||||
search,
|
||||
notFound,
|
||||
ArtistTracks,
|
||||
favorites,
|
||||
favoriteAlbums,
|
||||
favoriteTracks,
|
||||
favoriteArtists,
|
||||
NowPlayingView,
|
||||
Home,
|
||||
AlbumListView,
|
||||
ArtistListView,
|
||||
LyricsView,
|
||||
Stats,
|
||||
Mix,
|
||||
MixList,
|
||||
];
|
||||
folder,
|
||||
playlists,
|
||||
playlistView,
|
||||
albumView,
|
||||
artistView,
|
||||
artistDiscography,
|
||||
settings,
|
||||
search,
|
||||
notFound,
|
||||
ArtistTracks,
|
||||
favorites,
|
||||
favoriteAlbums,
|
||||
favoriteTracks,
|
||||
favoriteArtists,
|
||||
NowPlayingView,
|
||||
Home,
|
||||
AlbumListView,
|
||||
ArtistListView,
|
||||
LyricsView,
|
||||
Stats,
|
||||
Mix,
|
||||
MixList,
|
||||
PageView,
|
||||
OnboardingView,
|
||||
]
|
||||
|
||||
const Routes = {
|
||||
folder: folder.name,
|
||||
playlists: playlists.name,
|
||||
playlist: playlistView.name,
|
||||
album: albumView.name,
|
||||
artist: artistView.name,
|
||||
artistDiscography: artistDiscography.name,
|
||||
settings: settings.name,
|
||||
search: search.name,
|
||||
notFound: notFound.name,
|
||||
artistTracks: ArtistTracks.name,
|
||||
favorites: favorites.name,
|
||||
favoriteAlbums: favoriteAlbums.name,
|
||||
favoriteTracks: favoriteTracks.name,
|
||||
favoriteArtists: favoriteArtists.name,
|
||||
nowPlaying: NowPlayingView.name,
|
||||
Home: Home.name,
|
||||
AlbumList: AlbumListView.name,
|
||||
ArtistList: ArtistListView.name,
|
||||
Lyrics: LyricsView.name,
|
||||
Stats: Stats.name,
|
||||
Mix: Mix.name,
|
||||
MixList: MixList.name,
|
||||
};
|
||||
folder: folder.name,
|
||||
playlists: playlists.name,
|
||||
playlist: playlistView.name,
|
||||
album: albumView.name,
|
||||
artist: artistView.name,
|
||||
artistDiscography: artistDiscography.name,
|
||||
settings: settings.name,
|
||||
search: search.name,
|
||||
notFound: notFound.name,
|
||||
artistTracks: ArtistTracks.name,
|
||||
favorites: favorites.name,
|
||||
favoriteAlbums: favoriteAlbums.name,
|
||||
favoriteTracks: favoriteTracks.name,
|
||||
favoriteArtists: favoriteArtists.name,
|
||||
nowPlaying: NowPlayingView.name,
|
||||
Home: Home.name,
|
||||
AlbumList: AlbumListView.name,
|
||||
ArtistList: ArtistListView.name,
|
||||
Lyrics: LyricsView.name,
|
||||
Stats: Stats.name,
|
||||
Mix: Mix.name,
|
||||
MixList: MixList.name,
|
||||
Page: PageView.name,
|
||||
Onboarding: OnboardingView.name,
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
mode: "hash",
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
} as RouterOptions);
|
||||
mode: 'hash',
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
} as RouterOptions)
|
||||
|
||||
export { router, Routes };
|
||||
export { router, Routes }
|
||||
|
||||
@@ -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]
|
||||
@@ -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: "Root directories",
|
||||
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
|
||||
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { SettingType } from "../enums";
|
||||
import { Setting } from "@/interfaces/settings";
|
||||
import { SettingType } from '../enums'
|
||||
import { Setting } from '@/interfaces/settings'
|
||||
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import useSettingsStore from '@/stores/settings'
|
||||
|
||||
const settings = useSettingsStore;
|
||||
const settings = useSettingsStore
|
||||
|
||||
const disable_np_img: Setting = {
|
||||
title: "Hide album art from the left sidebar",
|
||||
type: SettingType.binary,
|
||||
state: () => !settings().use_np_img,
|
||||
action: () => settings().toggleUseNPImg(),
|
||||
show_if: () => !settings().is_alt_layout,
|
||||
};
|
||||
title: 'Hide album art from the left sidebar',
|
||||
type: SettingType.binary,
|
||||
state: () => !settings().use_np_img,
|
||||
action: () => settings().toggleUseNPImg(),
|
||||
show_if: () => !settings().is_alt_layout,
|
||||
}
|
||||
|
||||
const showNowPlayingOnTabTitle: Setting = {
|
||||
title: "Show Now Playing track on tab title",
|
||||
desc: "Replace current page info with Now Playing track info",
|
||||
type: SettingType.binary,
|
||||
state: () => settings().nowPlayingTrackOnTabTitle,
|
||||
action: () => settings().toggleNowPlayingTrackOnTabTitle(),
|
||||
};
|
||||
title: 'Show Now Playing track on tab title',
|
||||
desc: 'Replace current page info with Now Playing track info',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().nowPlayingTrackOnTabTitle,
|
||||
action: () => settings().toggleNowPlayingTrackOnTabTitle(),
|
||||
}
|
||||
|
||||
const showInlineFavIcon: Setting = {
|
||||
title: 'Show inline favorite icon',
|
||||
desc: 'Show the favorite button next to the track duration',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().showInlineFavIcon,
|
||||
action: () => settings().toggleShowInlineFavIcon(),
|
||||
}
|
||||
|
||||
const highlightFavoriteTracks: Setting = {
|
||||
title: 'Highlight favorite tracks',
|
||||
desc: 'Always show the favorite button for favorited tracks',
|
||||
type: SettingType.binary,
|
||||
state: () => settings()._highlightFavoriteTracks,
|
||||
action: () => settings().toggleHighlightFavoriteTracks(),
|
||||
show_if: () => settings().showInlineFavIcon,
|
||||
}
|
||||
|
||||
export default [disable_np_img, showNowPlayingOnTabTitle];
|
||||
export default [disable_np_img, showNowPlayingOnTabTitle, showInlineFavIcon, highlightFavoriteTracks]
|
||||
|
||||
@@ -1,63 +1,74 @@
|
||||
import { Setting } from "@/interfaces/settings";
|
||||
import {
|
||||
addRootDirs as editRootDirs,
|
||||
triggerScan,
|
||||
} from "@/requests/settings/rootdirs";
|
||||
import { SettingType } from "../enums";
|
||||
import { manageRootDirsStrings as data } from "../strings";
|
||||
import { Setting } from '@/interfaces/settings'
|
||||
import { addRootDirs as editRootDirs, triggerScan } from '@/requests/settings/rootdirs'
|
||||
import { SettingType } from '../enums'
|
||||
import { manageRootDirsStrings as data } from '../strings'
|
||||
|
||||
import useModalStore from "@/stores/modal";
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import useModalStore from '@/stores/modal'
|
||||
import settings from '@/stores/settings'
|
||||
import { router, Routes } from '@/router'
|
||||
|
||||
const text = data.settings;
|
||||
const text = data.settings
|
||||
|
||||
const change_root_dirs: Setting = {
|
||||
title: text.change,
|
||||
type: SettingType.button,
|
||||
state: null,
|
||||
button_text: () =>
|
||||
`\xa0 \xa0 ${
|
||||
useSettingsStore().root_dirs.length ? "Modify" : "Configure"
|
||||
} \xa0 \xa0`,
|
||||
action: () => useModalStore().showRootDirsPromptModal(),
|
||||
};
|
||||
title: text.change,
|
||||
type: SettingType.button,
|
||||
state: null,
|
||||
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 = {
|
||||
title: text.list_root_dirs,
|
||||
type: SettingType.root_dirs,
|
||||
state: () =>
|
||||
useSettingsStore().root_dirs.map((d) => ({
|
||||
title: d,
|
||||
action: () => {
|
||||
editRootDirs([], [d]).then((all_dirs) => {
|
||||
useSettingsStore().setRootDirs(all_dirs);
|
||||
});
|
||||
},
|
||||
})),
|
||||
defaultAction: () => {},
|
||||
action: () => triggerScan(),
|
||||
};
|
||||
title: text.list_root_dirs,
|
||||
type: SettingType.root_dirs,
|
||||
state: () =>
|
||||
settings().root_dirs.map(d => ({
|
||||
title: d,
|
||||
action: () => {
|
||||
editRootDirs([], [d]).then(all_dirs => {
|
||||
settings().setRootDirs(all_dirs)
|
||||
})
|
||||
},
|
||||
})),
|
||||
defaultAction: () => {},
|
||||
action: () => triggerScan(),
|
||||
}
|
||||
|
||||
const enable_scans: Setting = {
|
||||
title: "Enable periodic scans",
|
||||
type: SettingType.binary,
|
||||
state: () => useSettingsStore().enablePeriodicScans,
|
||||
action: () => useSettingsStore().togglePeriodicScans(),
|
||||
};
|
||||
const show_playlists_in_folders: Setting = {
|
||||
title: 'Show playlists in folder view',
|
||||
desc: 'Browse playlists and favorites in folders screen (meant for mobile app)',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().show_playlists_in_folders,
|
||||
action: () => settings().toggleShowPlaylistsInFolders(),
|
||||
}
|
||||
|
||||
const useWatchdog: Setting = {
|
||||
title: "Watch root dirs for new music",
|
||||
experimental: true,
|
||||
type: SettingType.binary,
|
||||
state: () => useSettingsStore().enableWatchDog,
|
||||
action: () => useSettingsStore().toggleWatchdog(),
|
||||
};
|
||||
// const enable_scans: Setting = {
|
||||
// title: "Enable periodic scans",
|
||||
// type: SettingType.binary,
|
||||
// state: () => useSettingsStore().enablePeriodicScans,
|
||||
// action: () => useSettingsStore().togglePeriodicScans(),
|
||||
// };
|
||||
|
||||
const periodicScanInterval: Setting = {
|
||||
title: "Periodic scan interval (minutes)",
|
||||
type: SettingType.free_number_input,
|
||||
state: () => useSettingsStore().periodicInterval,
|
||||
action: (newValue: number) => useSettingsStore().updatePeriodicInterval(newValue),
|
||||
};
|
||||
// const useWatchdog: Setting = {
|
||||
// title: "Watch root dirs for new music",
|
||||
// experimental: true,
|
||||
// type: SettingType.binary,
|
||||
// state: () => useSettingsStore().enableWatchDog,
|
||||
// action: () => useSettingsStore().toggleWatchdog(),
|
||||
// };
|
||||
|
||||
export default [change_root_dirs, list_root_dirs, useWatchdog, enable_scans, periodicScanInterval];
|
||||
// const periodicScanInterval: Setting = {
|
||||
// title: "Periodic scan interval (minutes)",
|
||||
// type: SettingType.free_number_input,
|
||||
// state: () => useSettingsStore().periodicInterval,
|
||||
// action: (newValue: number) => useSettingsStore().updatePeriodicInterval(newValue),
|
||||
// };
|
||||
|
||||
export default [
|
||||
change_root_dirs,
|
||||
list_root_dirs,
|
||||
show_playlists_in_folders,
|
||||
// useWatchdog, enable_scans, periodicScanInterval
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -29,10 +29,12 @@ 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()) {
|
||||
const key = Object.keys(item)[0]
|
||||
keys.push(key)
|
||||
// @ts-ignore
|
||||
homepageData[key] = item[key]
|
||||
// @ts-ignore
|
||||
@@ -41,6 +43,18 @@ export default defineStore('homepage', () => {
|
||||
homepageData[key].path = routes[key]
|
||||
// @ts-ignore
|
||||
homepageData[key].seeAllText = seeAllTexts[key]
|
||||
|
||||
if (item[key].url) {
|
||||
// @ts-ignore
|
||||
homepageData[key].path = item[key].url
|
||||
}
|
||||
}
|
||||
|
||||
// remove keys not in response
|
||||
for (const key in homepageData) {
|
||||
if (!keys.includes(key)) {
|
||||
delete homepageData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,86 +1,103 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { defineStore } from 'pinia'
|
||||
import useUI from '@/stores/interface'
|
||||
|
||||
export enum ModalOptions {
|
||||
newPlaylist,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
SetIP,
|
||||
rootDirsPrompt,
|
||||
setRootDirs,
|
||||
saveFolderAsPlaylist,
|
||||
login,
|
||||
settings
|
||||
newPlaylist,
|
||||
page,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
SetIP,
|
||||
rootDirsPrompt,
|
||||
setRootDirs,
|
||||
saveFolderAsPlaylist,
|
||||
login,
|
||||
settings,
|
||||
}
|
||||
|
||||
export default defineStore("newModal", {
|
||||
state: () => ({
|
||||
title: "",
|
||||
options: ModalOptions,
|
||||
component: <any>null,
|
||||
props: <any>{},
|
||||
visible: false,
|
||||
}),
|
||||
actions: {
|
||||
showModal(modalOption: ModalOptions, props: any = {}) {
|
||||
this.component = modalOption;
|
||||
this.visible = true;
|
||||
this.props = props;
|
||||
export default defineStore('newModal', {
|
||||
state: () => ({
|
||||
title: '',
|
||||
options: ModalOptions,
|
||||
component: <any>null,
|
||||
props: <any>{},
|
||||
visible: false,
|
||||
}),
|
||||
actions: {
|
||||
showModal(modalOption: ModalOptions, props: any = {}) {
|
||||
this.component = modalOption
|
||||
this.visible = true
|
||||
this.props = props
|
||||
},
|
||||
showNewPlaylistModal(props: any = {}) {
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showCollectionModal(props: any = {}) {
|
||||
this.showModal(ModalOptions.page, props)
|
||||
},
|
||||
showSaveFolderAsPlaylistModal(path: string) {
|
||||
const playlist_name = path.split('/').pop()
|
||||
const props = {
|
||||
playlist_name,
|
||||
path,
|
||||
}
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showSaveArtistAsPlaylistModal(name: string, artisthash: string) {
|
||||
const props = {
|
||||
artisthash,
|
||||
playlist_name: `This is ${name}`,
|
||||
}
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showSaveQueueAsPlaylistModal(name: string) {
|
||||
const props = {
|
||||
is_queue: true,
|
||||
playlist_name: name,
|
||||
}
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showEditPlaylistModal() {
|
||||
this.showModal(ModalOptions.updatePlaylist)
|
||||
},
|
||||
showDeletePlaylistModal(pid: number) {
|
||||
const props = {
|
||||
pid: pid,
|
||||
}
|
||||
this.showModal(ModalOptions.deletePlaylist, props)
|
||||
},
|
||||
showSetIPModal() {
|
||||
this.showModal(ModalOptions.SetIP)
|
||||
},
|
||||
showRootDirsPromptModal() {
|
||||
this.showModal(ModalOptions.rootDirsPrompt)
|
||||
},
|
||||
showSetRootDirsModal() {
|
||||
this.showModal(ModalOptions.setRootDirs)
|
||||
},
|
||||
showLoginModal() {
|
||||
if (useUI().hideUI) {
|
||||
console.log('🙊 showLoginModal but hideUI is true')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🙉 showLoginModal')
|
||||
this.showModal(ModalOptions.login)
|
||||
},
|
||||
showSettingsModal() {
|
||||
this.showModal(ModalOptions.settings)
|
||||
},
|
||||
hideModal() {
|
||||
this.visible = false
|
||||
this.setTitle('')
|
||||
},
|
||||
setTitle(new_title: string) {
|
||||
this.title = new_title
|
||||
},
|
||||
resetModal() {
|
||||
this.visible = false
|
||||
this.title = ''
|
||||
this.props = {}
|
||||
this.component = null
|
||||
},
|
||||
},
|
||||
showNewPlaylistModal(props: any = {}) {
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showSaveFolderAsPlaylistModal(path: string) {
|
||||
const playlist_name = path.split("/").pop();
|
||||
const props = {
|
||||
playlist_name,
|
||||
path,
|
||||
};
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showSaveArtistAsPlaylistModal(name: string, artisthash: string) {
|
||||
const props = {
|
||||
artisthash,
|
||||
playlist_name: `This is ${name}`,
|
||||
};
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showSaveQueueAsPlaylistModal(name: string) {
|
||||
const props = {
|
||||
is_queue: true,
|
||||
playlist_name: name,
|
||||
};
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showEditPlaylistModal() {
|
||||
this.showModal(ModalOptions.updatePlaylist);
|
||||
},
|
||||
showDeletePlaylistModal(pid: number) {
|
||||
const props = {
|
||||
pid: pid,
|
||||
};
|
||||
this.showModal(ModalOptions.deletePlaylist, props);
|
||||
},
|
||||
showSetIPModal() {
|
||||
this.showModal(ModalOptions.SetIP);
|
||||
},
|
||||
showRootDirsPromptModal() {
|
||||
this.showModal(ModalOptions.rootDirsPrompt);
|
||||
},
|
||||
showSetRootDirsModal() {
|
||||
this.showModal(ModalOptions.setRootDirs);
|
||||
},
|
||||
showLoginModal(){
|
||||
this.showModal(ModalOptions.login);
|
||||
},
|
||||
showSettingsModal(){
|
||||
this.showModal(ModalOptions.settings);
|
||||
},
|
||||
hideModal() {
|
||||
this.visible = false;
|
||||
this.setTitle("");
|
||||
},
|
||||
setTitle(new_title: string) {
|
||||
this.title = new_title;
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
30
src/stores/pages/collections.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Album, Artist, Collection } from '@/interfaces'
|
||||
import { getCollection } from '@/requests/collections'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export default defineStore('collections', {
|
||||
state: () => ({
|
||||
collection: <Collection | null>null,
|
||||
}),
|
||||
actions: {
|
||||
async fetchCollection(collection_id: string) {
|
||||
this.collection = await getCollection(collection_id)
|
||||
},
|
||||
async removeLocalItem(item: Album | Artist, type: 'album' | 'artist') {
|
||||
if (!this.collection) return
|
||||
|
||||
if (type == 'album') {
|
||||
this.collection.items = this.collection.items.filter(i => {
|
||||
return (i as Album).albumhash != (item as Album).albumhash
|
||||
})
|
||||
} else {
|
||||
this.collection.items = this.collection.items.filter(i => {
|
||||
return (i as Artist).artisthash != (item as Artist).artisthash
|
||||
})
|
||||
}
|
||||
},
|
||||
clearStore() {
|
||||
this.collection = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import useTracklist from './queue/tracklist'
|
||||
import useSettings from './settings'
|
||||
import useTracker from './tracker'
|
||||
|
||||
import { paths } from '@/config'
|
||||
import { getBaseUrl, paths } from '@/config'
|
||||
import updateMediaNotif from '@/helpers/mediaNotification'
|
||||
import { crossFade } from '@/utils/audio/crossFade'
|
||||
|
||||
@@ -81,15 +81,12 @@ class AudioSource {
|
||||
this.playingSource.pause()
|
||||
}
|
||||
|
||||
async playPlayingSource(
|
||||
trackSilence?: { starting_file: number; ending_file: number }
|
||||
) {
|
||||
async playPlayingSource(trackSilence?: { starting_file: number; ending_file: number }) {
|
||||
const trackDuration = trackSilence
|
||||
? Math.floor(trackSilence.ending_file / 1000 - trackSilence.starting_file / 1000)
|
||||
: null
|
||||
|
||||
if(this.requiredAPBlockBypass)
|
||||
this.applyAPBlockBypass()
|
||||
if (this.requiredAPBlockBypass) this.applyAPBlockBypass()
|
||||
|
||||
await this.playingSource.play().catch(this.handlers.onPlaybackError)
|
||||
navigator.mediaSession.playbackState = 'playing'
|
||||
@@ -110,11 +107,14 @@ class AudioSource {
|
||||
*
|
||||
* this workaround plays the `standbySource` along with the `playingSource` to meet the first condition.
|
||||
*/
|
||||
private applyAPBlockBypass(){
|
||||
private applyAPBlockBypass() {
|
||||
this.standbySource.src = ''
|
||||
this.standbySource.play().then(() => {
|
||||
this.standbySource.pause()
|
||||
}).catch(() => {})
|
||||
this.standbySource
|
||||
.play()
|
||||
.then(() => {
|
||||
this.standbySource.pause()
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
this.requiredAPBlockBypass = false
|
||||
}
|
||||
@@ -127,9 +127,11 @@ export function getUrl(filepath: string, trackhash: string, use_legacy: boolean)
|
||||
use_legacy = true
|
||||
const { streaming_container, streaming_quality } = useSettings()
|
||||
|
||||
return `${paths.api.files}/${trackhash + (use_legacy ? '/legacy' : '')}?filepath=${encodeURIComponent(
|
||||
const url = `${paths.api.files}/${trackhash + (use_legacy ? '/legacy' : '')}?filepath=${encodeURIComponent(
|
||||
filepath
|
||||
)}&container=${streaming_container}&quality=${streaming_quality}`
|
||||
|
||||
return getBaseUrl() + url
|
||||
}
|
||||
|
||||
const audioSource = new AudioSource()
|
||||
@@ -228,9 +230,12 @@ export const usePlayer = defineStore('player', () => {
|
||||
|
||||
const handlePlayErrors = (e: Event | string) => {
|
||||
if (e instanceof DOMException) {
|
||||
if(e.name === 'NotAllowedError') {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
queue.playPause()
|
||||
return toast.showNotification('Tap anywhere in the page and try again (autoplay blocked)', NotifType.Error)
|
||||
return toast.showNotification(
|
||||
'Tap anywhere in the page and try again (autoplay blocked)',
|
||||
NotifType.Error
|
||||
)
|
||||
}
|
||||
|
||||
return toast.showNotification('Player Error: ' + e.message, NotifType.Error)
|
||||
@@ -256,13 +261,13 @@ 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)
|
||||
}
|
||||
// if (!settings.use_lyrics_plugin) {
|
||||
// lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash)
|
||||
// }
|
||||
}
|
||||
|
||||
const onAudioCanPlay = () => {
|
||||
@@ -278,12 +283,14 @@ export const usePlayer = defineStore('player', () => {
|
||||
const { submitData } = tracker
|
||||
submitData()
|
||||
|
||||
console.log('audio ended')
|
||||
console.log(nextAudioData)
|
||||
if (settings.repeat == 'none') {
|
||||
queue.playPause()
|
||||
queue.moveForward()
|
||||
return
|
||||
}
|
||||
|
||||
// INFO: if next audio is not loaded, manually move forward
|
||||
if (nextAudioData.loaded === false) {
|
||||
console.log('next audio not loaded')
|
||||
clearNextAudioData()
|
||||
queue.playNext()
|
||||
}
|
||||
@@ -298,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
|
||||
@@ -343,6 +352,10 @@ export const usePlayer = defineStore('player', () => {
|
||||
|
||||
const silence = e.data
|
||||
|
||||
if (!silence.ending_file) {
|
||||
return
|
||||
}
|
||||
|
||||
nextAudioData.silence.starting_file = silence.starting_file
|
||||
currentAudioData.silence.ending_file = silence.ending_file
|
||||
nextAudioData.loaded = silence !== null
|
||||
@@ -378,7 +391,7 @@ export const usePlayer = defineStore('player', () => {
|
||||
currentAudioData.silence = nextAudioData.silence
|
||||
currentAudioData.filepath = nextAudioData.filepath
|
||||
maxSeekPercent.value = 0
|
||||
audioSource.playPlayingSource(nextAudioData.silence);
|
||||
audioSource.playPlayingSource(nextAudioData.silence)
|
||||
|
||||
clearNextAudioData()
|
||||
queue.moveForward()
|
||||
@@ -389,10 +402,10 @@ export const usePlayer = defineStore('player', () => {
|
||||
const initLoadingNextTrackAudio = () => {
|
||||
const { currentindex } = queue
|
||||
const { length } = tracklist
|
||||
const { repeat_all, repeat_one } = settings
|
||||
const { repeat } = settings
|
||||
|
||||
// if no repeat && is last track, return
|
||||
if (currentindex === length - 1 && !repeat_all && !repeat_one) {
|
||||
if (currentindex === length - 1 && repeat == 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -76,19 +79,23 @@ export default defineStore('Queue', {
|
||||
const { tracklist } = useTracklist()
|
||||
const is_last = this.currentindex === tracklist.length - 1
|
||||
|
||||
if (settings.repeat_one) {
|
||||
if (settings.repeat == 'one') {
|
||||
this.play(this.currentindex, false)
|
||||
return
|
||||
}
|
||||
|
||||
if (settings.repeat_all) {
|
||||
if (settings.repeat == 'all') {
|
||||
this.play(is_last ? 0 : this.currentindex + 1, false)
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -189,9 +196,9 @@ export default defineStore('Queue', {
|
||||
},
|
||||
previndex(): number {
|
||||
const { tracklist } = useTracklist()
|
||||
const { repeat_one } = useSettings()
|
||||
const { repeat } = useSettings()
|
||||
|
||||
if (repeat_one) {
|
||||
if (repeat == 'one') {
|
||||
return this.currentindex
|
||||
}
|
||||
|
||||
@@ -199,9 +206,9 @@ export default defineStore('Queue', {
|
||||
},
|
||||
nextindex(): number {
|
||||
const { tracklist } = useTracklist()
|
||||
const { repeat_one } = useSettings()
|
||||
const { repeat } = useSettings()
|
||||
|
||||
if (repeat_one) {
|
||||
if (repeat == 'one') {
|
||||
return this.currentindex
|
||||
}
|
||||
|
||||
|
||||
@@ -7,25 +7,9 @@ import useQueue from '@/stores/queue'
|
||||
import useSettings from '@/stores/settings'
|
||||
|
||||
import { FromOptions } from '@/enums'
|
||||
import {
|
||||
fromAlbum,
|
||||
fromArtist,
|
||||
fromFav,
|
||||
fromFolder,
|
||||
fromMix,
|
||||
fromPlaylist,
|
||||
fromSearch,
|
||||
Track,
|
||||
} from '@/interfaces'
|
||||
import { fromAlbum, fromArtist, fromFav, fromFolder, fromMix, fromPlaylist, fromSearch, Track } from '@/interfaces'
|
||||
|
||||
export type From =
|
||||
| fromFolder
|
||||
| fromAlbum
|
||||
| fromPlaylist
|
||||
| fromSearch
|
||||
| fromArtist
|
||||
| fromFav
|
||||
| fromMix
|
||||
export type From = fromFolder | fromAlbum | fromPlaylist | fromSearch | fromArtist | fromFav | fromMix
|
||||
|
||||
function shuffle(tracks: Track[]) {
|
||||
const shuffled = tracks.slice()
|
||||
@@ -56,12 +40,6 @@ export default defineStore('tracklist', {
|
||||
this.tracklist.push(...tracklist)
|
||||
}
|
||||
|
||||
const settings = useSettings()
|
||||
|
||||
if (settings.repeat_one) {
|
||||
settings.toggleRepeatMode()
|
||||
}
|
||||
|
||||
const { focusCurrentInSidebar } = useInterface()
|
||||
focusCurrentInSidebar(1000)
|
||||
usePlayer().clearNextAudio()
|
||||
@@ -95,7 +73,13 @@ export default defineStore('tracklist', {
|
||||
|
||||
this.setNewList(tracks)
|
||||
},
|
||||
setFromMix(name: string, id: string, tracks: Track[], sourcehash: string, image: { type: 'mix' | 'track', image: string }) {
|
||||
setFromMix(
|
||||
name: string,
|
||||
id: string,
|
||||
tracks: Track[],
|
||||
sourcehash: string,
|
||||
image: { type: 'mix' | 'track'; image: string }
|
||||
) {
|
||||
this.from = <fromMix>{
|
||||
type: FromOptions.mix,
|
||||
name: name,
|
||||
@@ -137,10 +121,7 @@ export default defineStore('tracklist', {
|
||||
this.insertAt(tracks, this.tracklist.length)
|
||||
|
||||
const Toast = useToast()
|
||||
Toast.showNotification(
|
||||
`Added ${tracks.length} tracks to queue`,
|
||||
NotifType.Success
|
||||
)
|
||||
Toast.showNotification(`Added ${tracks.length} tracks to queue`, NotifType.Success)
|
||||
},
|
||||
insertAt(tracks: Track[], index: number) {
|
||||
this.tracklist.splice(index, 0, ...tracks)
|
||||
@@ -160,14 +141,7 @@ export default defineStore('tracklist', {
|
||||
this.tracklist = shuffle(this.tracklist)
|
||||
},
|
||||
removeByIndex(index: number) {
|
||||
const {
|
||||
currentindex,
|
||||
nextindex,
|
||||
playing,
|
||||
playNext,
|
||||
moveForward,
|
||||
setCurrentIndex,
|
||||
} = useQueue()
|
||||
const { currentindex, nextindex, playing, playNext, moveForward, setCurrentIndex } = useQueue()
|
||||
const player = usePlayer()
|
||||
|
||||
if (this.tracklist.length == 1) {
|
||||
@@ -207,10 +181,7 @@ export default defineStore('tracklist', {
|
||||
this.tracklist.splice(currentindex + 1, 0, ...tracks)
|
||||
|
||||
const Toast = useToast()
|
||||
Toast.showNotification(
|
||||
`Added ${tracks.length} tracks to queue`,
|
||||
NotifType.Success
|
||||
)
|
||||
Toast.showNotification(`Added ${tracks.length} tracks to queue`, NotifType.Success)
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
|
||||
@@ -24,10 +24,7 @@ export default defineStore('search', () => {
|
||||
const currentTab = ref('top')
|
||||
const top_results = reactive({
|
||||
query: '',
|
||||
top_result: {
|
||||
type: <null | string>null,
|
||||
item: <Track | Album | Artist>{},
|
||||
},
|
||||
top_result: <Track | Album | Artist>{},
|
||||
tracks: <Track[]>[],
|
||||
albums: <Album[]>[],
|
||||
artists: <Artist[]>[],
|
||||
|
||||