56 Commits

Author SHA1 Message Date
wanji
4a34922552 pass blurhash to image loader 2025-12-17 11:14:07 +03:00
wanji
84bbafea73 fix image height 2025-12-17 11:08:38 +03:00
wanji
768df2daf4 blurhash draft 2 2025-12-17 10:51:14 +03:00
wanji
736134a1d5 blurhash draft 1 2025-12-16 11:37:35 +03:00
wanji
f7080d3adf fix statitem album url construction 2025-12-13 04:13:57 +03:00
wanji
a39d7a4c2f fix card size mismatch on first load in album/artist list views 2025-12-13 04:06:45 +03:00
wanji
275f00548a fix bottom bar animation direction
+ animate np image
2025-12-13 03:56:58 +03:00
wanji
1d34001369 fix right sidebar paddings and margins 2025-12-12 03:27:01 +03:00
wanji
0f51584cbb fix now playing mobile responsiveness 2025-12-08 17:20:57 +03:00
wanji
f5e3468c03 fix now playing and lyrics screen
+ new lyrics screen layout
+ fix bottom bar icons
2025-12-08 12:53:13 +03:00
wanji
684a2d6261 move np home into a component 2025-12-02 05:31:19 +03:00
wanji
7543e59b65 use original image in np screen 2025-12-02 04:49:20 +03:00
wanji
78f152d8e4 gradient revision 2 + animated color transition 2025-11-30 10:17:03 +03:00
wanji
da7f5bae7d new gradient revision 1 2025-11-30 08:07:26 +03:00
wanji
c5293a94e5 center np image 2025-11-30 06:36:37 +03:00
wanji
e3866a6ccc create blur + image + gradient bg on now playing 2025-11-28 18:46:51 +03:00
wanji
64462e24be fix: favorite albums loading only 50 items 2025-11-16 12:48:42 +03:00
wanji
9af7e6eaa6 add article aware sorting to settings
new: enable sorting tracks by filename in folder view
2025-10-20 19:38:56 +03:00
cwilvx
a4cb04d261 reuse rootdirs component in settings 2025-10-07 20:01:31 +03:00
cwilvx
6fd93a759d add finish component
- use sse to show progress in finish compoent
- note: review file list for changes
2025-09-20 16:48:45 +03:00
cwilvx
60e557aefd create initial onboarding draft 2025-09-13 03:25:24 +03:00
cwilvx
cf2d9537ff fix: double click on tracks is now working
+ fix see all link not being shown in favorites and artist page
2025-08-14 21:14:18 +03:00
cwilvx
6f4a59f971 Add collections display to backup UI and refactor url config 2025-06-17 23:06:45 +03:00
cwilvx
7b21853f97 conditionally upgrade requests on https 2025-05-13 21:17:59 +03:00
cwilvx
663dbd2a7c upgrade insecure requests 2025-05-13 00:15:16 +03:00
cwilvx
c7a0b5ab7e remove console.log 2025-05-10 18:27:24 +03:00
cwilvx
ad8eeb7a2a try: dynamic host resolving 2025-05-10 17:49:07 +03:00
cwilvx
e799c96872 add settings item to toggle playlists in folder view 2025-04-21 21:41:33 +03:00
cwilvx
234aed54d7 fix: favorites card zindex issue 2025-04-03 14:21:27 +03:00
cwilvx
574d7fd5e7 fix artist page track limit 2025-03-15 22:52:40 +03:00
cwilvx
4a1106d784 fix ending silence null error 2025-03-13 09:49:54 +03:00
cwilvx
d9f7e5fb14 fix favorites card 2025-03-10 12:23:38 +03:00
cwilvx
571c4a5264 disable: checking if lyrics exist 2025-03-03 11:35:33 +03:00
cwilvx
e71bc7164c cleanup page -> collectoin 2025-03-01 16:55:23 +03:00
cwilvx
77f18ac640 move to #000 2025-02-28 20:34:52 +03:00
cwilvx
78d57a64b9 fix repeat mode 2025-02-27 23:59:20 +03:00
cwilvx
ff502521e8 fix: remove traces of "page" 2025-02-27 13:31:41 +03:00
cwilvx
7caa70b9d6 remove console logs 2025-02-26 14:39:28 +03:00
cwilvx
cc3b372090 update inline fav icon defaults 2025-02-26 00:10:06 +03:00
cwilvx
c297f75132 improve: inline heart icon
+ rename pages to collections
2025-02-25 23:29:05 +03:00
cwilvx
7c954ef805 fix: artists not showing on search artist tab 2025-02-25 21:22:59 +03:00
cwilvx
9222e94b6c fix artist header ambient 2025-01-31 12:01:43 +03:00
cwilvx
54c165b64a fix: remove item from page 2025-01-31 11:25:55 +03:00
Mungai Njoroge
591509ebaf introducing pages
Pages
2025-01-29 12:43:20 +03:00
cwilvx
80a0bdbbf1 remove root dir draft settings 2025-01-29 12:30:42 +03:00
cwilvx
2e27da3f9f fix cardscroller 2025-01-29 12:28:51 +03:00
cwilvx
74bf8f5d78 context menu on artist 2025-01-29 12:05:27 +03:00
cwilvx
bfdefc37fd album card context menu 2025-01-29 11:43:02 +03:00
cwilvx
44a877b9c9 enable delete 2025-01-28 10:44:52 +03:00
cwilvx
db93fd554e first draft 2025-01-28 09:17:37 +03:00
cwilvx
40a7ad084c revert bottom bar test bitrate 2025-01-28 06:41:47 +03:00
cwilvx
e44aa01d63 move /home to /nothome 2025-01-07 23:21:44 +03:00
cwilvx
192e705890 add explicit flag 2025-01-06 00:18:34 +03:00
cwilvx
50f92b65ab revert default settings page 2024-12-30 21:10:07 +03:00
cwilvx
a5aea45cd6 Merge branch 'lastfm' 2024-12-30 21:01:04 +03:00
Mungai Njoroge
cc93fe7419 Merge pull request #39 from swingmx/another-one
Recommendations and misc stuff
2024-12-28 16:04:52 +03:00
120 changed files with 5871 additions and 2418 deletions

View File

@@ -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 ✅

View File

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

View File

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

View File

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

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

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

After

Width:  |  Height:  |  Size: 979 B

View File

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

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 839 B

View File

@@ -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

View 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

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 741 B

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -17,7 +17,14 @@
}"
class="np-image rounded-sm no-scroll"
>
<img :src="paths.images.thumb.small + queue.currenttrack?.image" alt="" />
<!-- <img :src="paths.images.thumb.small + queue.currenttrack?.image" alt="" /> -->
<ImageLoader
:image="paths.images.thumb.small + queue.currenttrack?.image"
:blurhash="queue.currenttrack.blurhash"
:duration="1000"
img-class="rounded-sm"
style="width: 48px; aspect-ratio: 1;"
/>
<div class="expandicon">
<ExpandSvg />
</div>
@@ -25,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>

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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[]>([])

View File

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

View File

@@ -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;

View File

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

View File

@@ -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'

View 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>

View File

@@ -1,20 +1,20 @@
<template>
<div
v-auto-animate
class="settingsmodal"
:class="{
isSmallPhone,
}"
v-auto-animate
>
<Sidebar
v-if="!(isSmallPhone && showContent)"
:current-group="(currentGroup as SettingGroup)"
@set-tab="tab => (currentTab = tab)"
v-if="!(isSmallPhone && showContent)"
/>
<div class="content" v-if="showContent">
<div class="head" v-auto-animate>
<div v-if="showContent" class="content">
<div v-auto-animate class="head">
<div class="h2">
<button class="back" v-if="isSmallPhone" @click="handleGoBack">
<button v-if="isSmallPhone" class="back" @click="handleGoBack">
<ArrowSvg />
</button>
{{ currentGroup?.title }}
@@ -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;

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;

View File

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

View File

@@ -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>

View File

@@ -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;
}
}

View File

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

View File

@@ -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>

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

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

View File

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

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

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

View File

@@ -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,

View File

@@ -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),
]
}

View File

@@ -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'),
]
}

View File

@@ -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'),
},
],
}

View File

@@ -105,4 +105,6 @@ export interface DBSettings {
lastfmApiKey: string;
lastfmApiSecret: string;
lastfmSessionKey: string;
showPlaylistsInFolderView: boolean;
artistArticleAwareSorting: boolean;
}

View File

@@ -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) => {
// }

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {
@@ -103,3 +103,21 @@ export async function sendPairRequest() {
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
View 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
}

View File

@@ -32,6 +32,7 @@ export async function getBackups() {
playlists: number
scrobbles: number
favorites: number
collections: number
date: string
}

View File

@@ -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) {

View File

@@ -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 }

View File

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

View File

@@ -12,7 +12,7 @@ import folderlistmode from './folderlistmode'
import layout from './layout'
import nowPlaying from './now-playing-group'
import rootDirSettings from './root-dirs'
import separators from './separators'
import artistSettings from './artists'
import sidebarSettings from './sidebar'
import tracks from './tracks'
// icons
@@ -50,7 +50,7 @@ export const library = {
show_if: loggedInUserIsAdmin,
groups: [
{
title: "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

View File

@@ -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]

View File

@@ -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
]

View File

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

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

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

View File

@@ -29,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]
}
}
}

View File

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

View File

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

View File

@@ -1,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;
},
},
});
})

View 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
},
},
})

View File

@@ -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
}

View File

@@ -23,6 +23,7 @@ export default defineStore('Queue', {
playing: false,
/** Whether track has been triggered manually */
manual: true,
direction: <'up' | 'down'>('up'),
}),
actions: {
setPlaying(val: boolean) {
@@ -44,6 +45,8 @@ export default defineStore('Queue', {
const { tracklist } = useTracklist()
if (tracklist.length === 0) return
this.direction = index > this.currentindex ? 'up' : 'down'
this.playing = true
this.currentindex = index
this.manual = manual
@@ -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
}

View File

@@ -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: {

View File

@@ -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[]>[],

Some files were not shown because too many files have changed in this diff Show More