93 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
cwilvx
56b1ab35d3 lastfm integration 2024-12-30 20:58:46 +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
cwilvx
56d1c9da90 fix mix images 2024-12-26 21:40:15 +03:00
cwilvx
da611f5e8e saving mixes + see all mixes 2024-12-26 17:32:48 +03:00
cwilvx
b9e767b3c3 fix: mix images 2024-12-11 14:20:46 +03:00
cwilvx
1eaf18ae75 handle custom mixes 2024-11-27 12:36:07 +03:00
cwilvx
e420dc3aac fix: playlist card images on home 2024-11-24 18:14:01 +03:00
cwilvx
9b938194a6 fix: logtrack worker url formation 2024-11-24 18:08:54 +03:00
cwilvx
54ab071803 Merge branch 'master' into another-one 2024-11-24 18:08:33 +03:00
skilletfun
b3484b08dd merge #40 from @skilletfun
* Add Play Button to PlaylistCard

* fix: fetch all playlist tracks from playBtn play

---------

Co-authored-by: skilletfun <s.laptev@astralab.ai>
Co-authored-by: cwilvx <geoffreymungai45@gmail.com>
2024-11-24 17:58:57 +03:00
cwilvx
96178c462f fix: link page title setting to toggle 2024-11-22 07:02:28 +03:00
cwilvx
2c4dad299b Merge branch 'master' into another-one 2024-11-21 14:29:41 +03:00
cwilvx
0fcbe03bab make stats component scrollable 2024-11-21 14:28:42 +03:00
Simon
6775b7abaf merge pr #37 from @Simonh2o
~ Added heart for favorited tracks, excluding the /favorites pages (#37) ~
* Added heart for favorited tracks, excluding the /favorites pages

* increased z-index of profile dropdown, some site elements were overlaying it

* very minor changes to btns, inputs, placeholders

* made search icon less intense

* various responsive fixes etc, isSmall on 724px for 'tracks'?

* Changed nav buttons slightly

* small fixes to heart pos, arrow pos context menu, active class for thumb nowplaying

* fixed children context menu cursor always being pointer

* Changes to profile dropdown

* some icons missing active animation, fixed padding play btn, right bar track pos, sidenav toggle

* fixed gradient animation for album and artist cards

* changed dropdown again

* hiding fav icon on lower viewport

* fixed some active click area bugs, and changed left side thumbnail

* right sidebar scrollbar and tracks fix

* adjusted topnav and bottombar sizing, change folder breadcrumb bg

* fixed some track titles for responsive

* playlist page small fixes

* Changes to the notification toasts

* Changed now playing controls responsive

* more space between bottom progress bar and play btns

---------

Co-authored-by: Stannnnn <stann@live.nl>
Co-authored-by: Mungai Njoroge <geoffreymungai45@gmail.com>
2024-11-21 13:57:31 +03:00
cwilvx
275877a258 fix: now playing image not being shown 2024-11-21 13:37:35 +03:00
cwilvx
a2772b3945 force alternate layout from v2.0.0
+ attempt to fix the late silence data issue
2024-11-21 12:33:57 +03:00
Mungai Njoroge
ea48380699 merge #38 from @Type-Delta
Fix various UI and Playback issues on mobile browser
2024-11-21 12:10:39 +03:00
Type-Delta
818c37a6be feat: add seek controll to mediaNotification 2024-11-20 17:14:38 +07:00
Type-Delta
a711007e66 fix: second track being blocked by autoplay policy 2024-11-20 17:13:40 +07:00
cwilvx
4165e13aaa implement card limit on /home 2024-11-17 22:19:23 +03:00
cwilvx
de353bf534 migrate to /home for homepage items 2024-11-17 21:39:30 +03:00
Type-Delta
c2a3fe5725 fix: playback won't start/continue to next track on safari & prevent autoplay blocking on mobile 2024-11-17 09:53:35 +07:00
Type-Delta
32d06850e4 fix: <DynamicScroller> scrolling bug on touchscreen for ArtistView & NowPlaying view 2024-11-17 09:40:13 +07:00
Type-Delta
d9b14c0bf7 fix: arrow icon in file dropdown button for sizing error in safari 2024-11-17 09:05:23 +07:00
Type-Delta
b18b411005 fix: nowplaying view won't show Progress bar on Tablet (vertical) 2024-11-17 09:03:39 +07:00
Type-Delta
79ba8b0f6d fix: file dropdown button display above avatar dropdown menu 2024-11-17 08:59:46 +07:00
Type-Delta
67ca114f7c fix: <body> sizing on safari 2024-11-17 08:56:28 +07:00
cwilvx
43c6638f40 show artist mix image on now playing 2024-11-01 12:22:57 +03:00
cwilvx
0d0d519213 use cloud mix images 2024-10-29 22:40:44 +03:00
cwilvx
ab7075726d update refs 2024-10-29 02:09:51 +03:00
cwilvx
f4117a452f use artist color in mix cover 2024-10-28 16:42:30 +03:00
cwilvx
00f6181cbd first recommendations draft 2024-10-25 23:26:21 +03:00
cwilvx
ed847077ee fix: indexes on fav tracks page 2024-10-21 10:17:49 +03:00
cwilvx
72915c8367 force legacy streaming 2024-10-21 10:01:19 +03:00
cwilvx
866c67a154 fix: view all favorites 2024-10-21 08:45:55 +03:00
cwilvx
387c60165c fix: search loadmore 2024-10-21 08:30:50 +03:00
cwilvx
35aca59508 remove stats file 2024-10-15 15:31:43 +03:00
cwilvx
57bd7c151f show stats in album and artist pages 2024-10-15 15:30:56 +03:00
178 changed files with 10387 additions and 4958 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",
@@ -45,6 +46,7 @@
"typescript": "^5.0.4",
"vite": "^3.0.4",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-pwa": "^0.16.4",
"vite-plugin-singlefile": "^0.13.5",
"vite-svg-loader": "^4.0.0",

View File

@@ -2,7 +2,8 @@ onmessage = (e) => {
const { trackhash, duration, source, timestamp } = e.data;
const is_dev = location.port === "5173";
const base_url = is_dev ? "http://localhost:1980" : location.origin;
const protocol = location.protocol.replace(':', '');
const base_url = is_dev ? `${protocol}://${location.hostname}:1980` : location.origin;
const url = base_url + "/logger/track/log";
fetch(url, {

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

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50374 26.7058C8.13702 26.7058 8.53803 26.3755 9.5421 25.3961L14.0693 20.9202C14.1258 20.8637 14.2249 20.8637 14.2718 20.9202L18.8011 25.3982C19.8073 26.3776 20.2019 26.7058 20.8373 26.7058C21.768 26.7058 22.3411 26.0783 22.3411 25.0272V4.58848C22.3411 2.26489 21.1308 1.03748 18.8285 1.03748H9.51257C7.2082 1.03748 6 2.26489 6 4.58848V25.0272C6 26.0783 6.57304 26.7058 7.50374 26.7058Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50374 26.7058C8.13702 26.7058 8.53803 26.3755 9.5421 25.3961L14.0693 20.9202C14.1258 20.8637 14.2249 20.8637 14.2718 20.9202L18.8011 25.3982C19.8073 26.3776 20.2019 26.7058 20.8373 26.7058C21.768 26.7058 22.3411 26.0783 22.3411 25.0272V4.58848C22.3411 2.26489 21.1308 1.03748 18.8285 1.03748H9.51257C7.2082 1.03748 6 2.26489 6 4.58848V25.0272C6 26.0783 6.57304 26.7058 7.50374 26.7058ZM8.61444 22.9047C8.45459 23.0645 8.27131 23.0134 8.27131 22.7875V4.71317C8.27131 3.77707 8.7417 3.30879 9.69491 3.30879H18.6558C19.5994 3.30879 20.0698 3.77707 20.0698 4.71317V22.7875C20.0698 23.0134 19.894 23.0645 19.7266 22.9047L14.9351 18.2591C14.4483 17.7904 13.8928 17.7904 13.406 18.2591L8.61444 22.9047Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 811 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

@@ -1,3 +1,3 @@
<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="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>

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -0,0 +1,11 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="currentColor" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <path d="M14.131 22.948l-1.172-3.193c0 0-1.912 2.131-4.771 2.131-2.537 0-4.333-2.203-4.333-5.729 0-4.511 2.276-6.125 4.515-6.125 3.224 0 4.245 2.089 5.125 4.772l1.161 3.667c1.161 3.561 3.365 6.421 9.713 6.421 4.548 0 7.631-1.391 7.631-5.068 0-2.968-1.697-4.511-4.844-5.244l-2.344-0.511c-1.624-0.371-2.104-1.032-2.104-2.131 0-1.249 0.985-1.984 2.604-1.984 1.767 0 2.704 0.661 2.865 2.24l3.661-0.444c-0.297-3.301-2.584-4.656-6.323-4.656-3.308 0-6.532 1.251-6.532 5.245 0 2.5 1.204 4.077 4.245 4.807l2.484 0.589c1.865 0.443 2.484 1.224 2.484 2.287 0 1.359-1.323 1.921-3.828 1.921-3.703 0-5.244-1.943-6.124-4.625l-1.204-3.667c-1.541-4.765-4.005-6.531-8.891-6.531-5.287-0.016-8.151 3.385-8.151 9.192 0 5.573 2.864 8.595 8.005 8.595 4.14 0 6.125-1.943 6.125-1.943z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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="30" viewBox="0 0 28 30" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 30" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.367 13.6066C22.377 13.5853 23.1931 12.7884 23.1931 11.7944C23.1931 10.7887 22.3749 9.97054 21.367 9.95671C20.3613 9.945 19.5314 10.7866 19.5314 11.7944C19.5314 12.7905 20.3613 13.6205 21.367 13.6066ZM21.367 18.829C22.3727 18.829 23.1931 17.997 23.1931 16.9891C23.1931 15.9813 22.3749 15.1631 21.367 15.1631C20.3613 15.1631 19.5314 15.9855 19.5314 16.9891C19.5314 17.9948 20.3613 18.829 21.367 18.829ZM11.1511 11.3658C11.6283 11.3658 12.0117 10.9631 12.0117 10.4625C12.0117 9.98742 11.6262 9.59226 11.1511 9.59226C10.6739 9.59226 10.2787 9.98742 10.2787 10.4625C10.2787 10.9631 10.6739 11.3658 11.1511 11.3658ZM13.9634 12.1671C14.4598 12.1671 14.8357 11.7644 14.8357 11.2852C14.8357 10.7866 14.4619 10.4032 13.9634 10.4032C13.4841 10.4032 13.1145 10.7866 13.1145 11.2852C13.1145 11.7644 13.4862 12.1671 13.9634 12.1671ZM16.0873 14.2024C16.5687 14.2024 16.9734 13.819 16.9734 13.3418C16.9734 12.8412 16.5687 12.4364 16.0873 12.4364C15.6122 12.4364 15.2309 12.8412 15.2309 13.3418C15.2309 13.819 15.6122 14.2024 16.0873 14.2024ZM16.8087 16.9327C17.2859 16.9327 17.6693 16.5396 17.6693 16.0624C17.6693 15.5756 17.2859 15.1922 16.8087 15.1922C16.3219 15.1922 15.9384 15.5756 15.9384 16.0624C15.9384 16.5396 16.3219 16.9327 16.8087 16.9327ZM16.0873 19.6767C16.5666 19.6767 16.9734 19.272 16.9734 18.7831C16.9734 18.2963 16.5687 17.9128 16.0873 17.9128C15.6122 17.9128 15.2309 18.2963 15.2309 18.7831C15.2309 19.2741 15.6122 19.6767 16.0873 19.6767ZM13.9634 21.7697C14.4619 21.7697 14.8357 21.3766 14.8357 20.8877C14.8357 20.4084 14.4598 20.0037 13.9634 20.0037C13.4862 20.0037 13.1145 20.4084 13.1145 20.8877C13.1145 21.3766 13.4841 21.7697 13.9634 21.7697ZM11.1511 22.6287C11.6262 22.6287 12.0117 22.2335 12.0117 21.7584C12.0117 21.2578 11.6283 20.8531 11.1511 20.8531C10.6739 20.8531 10.2787 21.2578 10.2787 21.7584C10.2787 22.2335 10.6739 22.6287 11.1511 22.6287ZM8.3271 21.7697C8.80429 21.7697 9.17601 21.3766 9.17601 20.8877C9.17601 20.4084 8.80218 20.0037 8.3271 20.0037C7.82859 20.0037 7.45476 20.4084 7.45476 20.8877C7.45476 21.3766 7.82648 21.7697 8.3271 21.7697ZM6.21069 19.6767C6.68577 19.6767 7.0596 19.2741 7.0596 18.7831C7.0596 18.2963 6.68577 17.9128 6.21069 17.9128C5.7239 17.9128 5.31913 18.2963 5.31913 18.7831C5.31913 19.272 5.7239 19.6767 6.21069 19.6767ZM5.4914 16.9327C5.96858 16.9327 6.35202 16.5396 6.35202 16.0624C6.35202 15.5756 5.96858 15.1922 5.4914 15.1922C5.0121 15.1922 4.62116 15.5756 4.62116 16.0624C4.62116 16.5396 5.0121 16.9327 5.4914 16.9327ZM6.21069 14.2024C6.68577 14.2024 7.0596 13.819 7.0596 13.3418C7.0596 12.8412 6.68577 12.4364 6.21069 12.4364C5.7239 12.4364 5.31913 12.8412 5.31913 13.3418C5.31913 13.819 5.7239 14.2024 6.21069 14.2024ZM8.3271 12.1671C8.80218 12.1671 9.17601 11.7644 9.17601 11.2852C9.17601 10.7866 8.80429 10.4032 8.3271 10.4032C7.82648 10.4032 7.45476 10.7866 7.45476 11.2852C7.45476 11.7644 7.82859 12.1671 8.3271 12.1671ZM11.1511 14.2601C11.6283 14.2601 12.0117 13.867 12.0117 13.3898C12.0117 12.8988 11.6283 12.4941 11.1511 12.4941C10.6739 12.4941 10.2787 12.8988 10.2787 13.3898C10.2787 13.867 10.6739 14.2601 11.1511 14.2601ZM13.7728 15.4003C14.2596 15.4003 14.6334 14.9955 14.6334 14.528C14.6334 14.0508 14.2596 13.6556 13.7728 13.6556C13.2977 13.6556 12.8909 14.0529 12.8909 14.528C12.8909 14.9934 13.2977 15.4003 13.7728 15.4003ZM13.7728 18.5674C14.2617 18.5674 14.6334 18.1819 14.6334 17.7047C14.6334 17.2062 14.2617 16.8131 13.7728 16.8131C13.2956 16.8131 12.8909 17.2062 12.8909 17.7047C12.8909 18.1819 13.2956 18.5674 13.7728 18.5674ZM11.1511 19.7248C11.6262 19.7248 12.0117 19.3179 12.0117 18.8311C12.0117 18.3539 11.6283 17.9609 11.1511 17.9609C10.6739 17.9609 10.2787 18.3539 10.2787 18.8311C10.2787 19.3179 10.6739 19.7248 11.1511 19.7248ZM8.51554 18.5674C8.99273 18.5674 9.39749 18.1819 9.39749 17.7047C9.39749 17.2062 8.99273 16.8131 8.51554 16.8131C8.03835 16.8131 7.65492 17.2062 7.65492 17.7047C7.65492 18.1819 8.03835 18.5674 8.51554 18.5674ZM8.51554 15.4003C8.99062 15.4003 9.39749 14.9934 9.39749 14.528C9.39749 14.0529 8.99062 13.6556 8.51554 13.6556C8.04046 13.6556 7.65492 14.0508 7.65492 14.528C7.65492 14.9955 8.04046 15.4003 8.51554 15.4003ZM11.1511 16.9807C11.6283 16.9807 12.0117 16.5877 12.0117 16.1105C12.0117 15.6333 11.6283 15.2402 11.1511 15.2402C10.6739 15.2402 10.2787 15.6333 10.2787 16.1105C10.2787 16.5877 10.6739 16.9807 11.1511 16.9807ZM22.444 7.38796L23.0962 5.33649L6.82734 0.0527401C6.26344 -0.132415 5.64844 0.186802 5.47711 0.746487C5.28985 1.30406 5.60696 1.91906 6.16664 2.10632L22.444 7.38796ZM3.84421 27.0553H23.9109C26.4499 27.0553 27.7552 25.7597 27.7552 23.2591V8.98734C27.7552 6.48469 26.4499 5.18157 23.9109 5.18157H3.84421C1.31484 5.18157 0 6.48469 0 8.98734V23.2591C0 25.7597 1.31484 27.0553 3.84421 27.0553ZM3.97733 24.7594C2.88772 24.7594 2.29592 24.1952 2.29592 23.0566V9.18773C2.29592 8.04913 2.88772 7.47749 3.97733 7.47749H23.7778C24.8578 7.47749 25.4592 8.04913 25.4592 9.18773V23.0566C25.4592 24.1952 24.8578 24.7594 23.7778 24.7594H3.97733Z"
fill="currentColor" />

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

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

@@ -0,0 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.6785 26.9414C16.0401 26.9414 16.3113 26.6787 16.3762 26.3053C17.2167 19.9648 18.1085 19.0015 24.3609 18.3047C24.746 18.2632 25.0151 17.9782 25.0151 17.6048C25.0151 17.2453 24.7502 16.9624 24.363 16.9146C18.1127 16.1925 17.2462 15.2566 16.3762 8.90648C16.3071 8.53312 16.038 8.28 15.6785 8.28C15.319 8.28 15.0478 8.53312 14.9925 8.90648C14.1499 15.2566 13.2506 16.2199 7.00569 16.9146C6.61101 16.9561 6.34406 17.2411 6.34406 17.6048C6.34406 17.9761 6.6089 18.259 7.00359 18.3047C13.2368 19.1367 14.0819 19.9648 14.9925 26.3053C15.0499 26.6787 15.3211 26.9414 15.6785 26.9414ZM7.575 13.9509C7.81664 13.9509 7.99218 13.7817 8.01984 13.5476C8.43539 10.444 8.52609 10.4334 11.7584 9.82499C11.9808 9.78562 12.1479 9.62179 12.1479 9.38015C12.1479 9.14601 11.9787 8.97047 11.7541 8.94492C8.53242 8.49422 8.42578 8.39507 8.01984 5.23195C7.99218 4.98609 7.81875 4.81687 7.575 4.81687C7.33875 4.81687 7.16531 4.98609 7.13016 5.24578C6.75726 8.34515 6.60258 8.34398 3.39164 8.94492C3.16711 8.98429 3 9.14601 3 9.38015C3 9.63562 3.16711 9.78562 3.44273 9.82499C6.61758 10.328 6.75726 10.4163 7.13016 13.5241C7.16531 13.7817 7.33875 13.9509 7.575 13.9509ZM13.2574 5.76398C13.4203 5.76398 13.5171 5.65758 13.5427 5.5064C13.8794 3.6164 13.8485 3.54164 15.8791 3.17203C16.0324 3.13476 16.1367 3.04219 16.1367 2.87719C16.1367 2.7218 16.0303 2.6175 15.877 2.59195C13.8485 2.24344 13.8773 2.16445 13.5427 0.259686C13.5171 0.106406 13.4203 0 13.2574 0C13.0924 0 12.9977 0.106406 12.9722 0.263905C12.6417 2.14758 12.6684 2.22234 10.6357 2.59195C10.4707 2.61961 10.3781 2.7218 10.3781 2.87719C10.3781 3.04219 10.4707 3.13476 10.642 3.17203C12.6621 3.52898 12.6354 3.60797 12.9722 5.5043C12.9977 5.65758 13.0924 5.76398 13.2574 5.76398Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 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

@@ -1,272 +1,272 @@
$g-border: solid 1px $gray5;
#app-grid {
display: grid;
// grid-template-columns: min-content 1fr 29rem;
grid-template-columns: min-content 1fr;
grid-template-rows: $navheight 1fr 5rem;
grid-template-areas:
"l-sidebar nav"
"l-sidebar content"
"bottombar bottombar";
height: 100%;
border: $g-border;
border-top: none;
border-bottom: none;
margin: 0 auto;
position: relative;
#contentresizer {
margin: 0 $padright 0 $padleft;
}
@include allPhones {
grid-template-columns: 1fr;
display: grid;
// grid-template-columns: min-content 1fr 29rem;
grid-template-columns: min-content 1fr;
grid-template-rows: $navheight 1fr 5.125rem;
grid-template-areas:
"nav"
"content"
"bottombar";
grid-template-rows: auto 1fr auto;
}
'l-sidebar nav'
'l-sidebar content'
'bottombar bottombar';
height: 100%;
border: $g-border;
border-top: none;
border-bottom: none;
margin: 0 auto;
position: relative;
#contentresizer {
margin: 0 $padright 0 $padleft;
}
@include allPhones {
grid-template-columns: 1fr;
grid-template-areas:
'nav'
'content'
'bottombar';
grid-template-rows: auto 1fr auto;
}
}
#acontent {
width: 100%;
grid-area: content;
overflow: hidden;
margin-right: $margright;
width: 100%;
grid-area: content;
overflow: hidden;
margin-right: $margright;
}
.topnav {
grid-area: nav;
height: $navheight;
padding: 1rem $padleft;
padding-right: $padright;
grid-area: nav;
height: $navheight;
padding: 1rem $padleft;
padding-right: $padright;
@include allPhones {
display: flex;
gap: $small;
height: unset;
padding: 6px 8px;
margin: $medium 1rem;
border-radius: 5rem;
background-color: $gray;
}
@include allPhones {
display: flex;
gap: $smaller;
height: unset;
padding: 6px 8px;
margin: $medium 1rem;
border-radius: 5rem;
background-color: $gray;
}
}
.b-bar {
grid-area: bottombar;
border-top: $g-border;
grid-area: bottombar;
border-top: $g-border;
// background-color: $bars;
}
.content-page {
scrollbar-gutter: stable;
padding-left: $padleft;
padding-right: $padright;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
padding-left: $padleft;
padding-right: $padright;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
}
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
}
}
.vue-recycle-scroller__item-wrapper {
overflow: visible !important;
overflow: visible !important;
}
.vue-recycle-scroller {
scrollbar-gutter: stable;
padding-left: $padleft;
scrollbar-gutter: stable;
padding-left: $padleft;
}
.r-sidebar {
grid-area: r-sidebar;
border-left: $g-border;
grid-area: r-sidebar;
border-left: $g-border;
.vue-recycle-scroller {
padding-left: 0;
}
.vue-recycle-scroller {
padding-left: 0;
}
}
// ====== MODIFIERS =======
#app-grid.is_alt_layout {
grid-template-columns: 1fr;
grid-template-rows: max-content 1fr 5rem;
grid-template-areas:
"nav"
"content"
"bottombar";
@include allPhones {
grid-template-columns: 1fr !important;
grid-template-rows: max-content 1fr 9.5rem !important;
grid-template-columns: 1fr;
grid-template-rows: max-content 1fr 5.125rem;
grid-template-areas:
"nav"
"content"
"bottombar" !important;
}
'nav'
'content'
'bottombar';
.vue-recycle-scroller,
.content-page,
.topnav,
#songlist-scroller {
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
.b-bar,
.search-page-top-results {
padding: 0 $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.topnav {
background-color: $gray;
}
.vue-recycle-scroller,
.content-page {
padding-top: 2rem;
}
.search-page-top-results {
padding-bottom: $padbottom;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
}
.lyricsview {
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));
@include allPhones {
grid-template-columns: 1fr !important;
grid-template-rows: max-content 1fr 9.5rem !important;
grid-template-areas:
'nav'
'content'
'bottombar' !important;
}
.vue-recycle-scroller,
.content-page,
.topnav,
#songlist-scroller {
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
.b-bar,
.search-page-top-results {
padding: 0 $alt_layout_pad;
padding: 0 $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.topnav {
// background-color: $bars;
border-bottom: $g-border;
}
.vue-recycle-scroller,
.content-page {
padding-top: 2rem;
}
.search-page-top-results {
padding-bottom: $padbottom;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
}
.lyricsview {
padding-bottom: 2rem;
}
@media only screen and (min-width: 1980px) {
// NOTE: Styles for 1680px and below
$alt_layout_pad: max(2rem, calc((100% - 1680px) / 2));
.vue-recycle-scroller,
.content-page,
.topnav,
#songlist-scroller {
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
}
.b-bar,
.search-page-top-results {
padding: 0 $alt_layout_pad;
}
}
}
}
#app-grid.extendWidth {
padding-right: 0;
border-left: none;
border-right: none;
max-width: 100% !important;
padding-right: 0;
border-left: none;
border-right: none;
max-width: 100% !important;
}
#app-grid.useSidebar {
grid-template-columns: min-content 1fr 28rem;
grid-template-areas:
"l-sidebar nav r-sidebar"
"l-sidebar content r-sidebar"
"bottombar bottombar bottombar";
grid-template-columns: min-content 1fr 28rem;
grid-template-areas:
'l-sidebar nav r-sidebar'
'l-sidebar content r-sidebar'
'bottombar bottombar bottombar';
@include for-desktop-down {
grid-template-columns: min-content 1fr 24rem;
}
@include for-desktop-down {
grid-template-columns: min-content 1fr 24rem;
}
#acontent {
// margin-right: 0 !important;
// padding-right: $medium !important;
}
#acontent {
// margin-right: 0 !important;
// padding-right: $medium !important;
}
}
#app-grid.NoSideBorders {
border-right: none !important;
border-left: none !important;
border-right: none !important;
border-left: none !important;
}
.v-scroll-page {
.scroller {
padding-right: $padright;
height: 100%;
width: 100%;
padding-bottom: $content-padding-bottom;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
.scroller {
padding-right: $padright;
height: 100%;
width: 100%;
padding-bottom: $content-padding-bottom;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
}
}
}
}
.song-title > .isSmallArtists {
display: none;
display: none;
}
.isSmall {
.songlist-item {
grid-template-columns: 2fr 7.5rem !important;
// disable hover on mobile
// to prevent tap effect
&:hover {
background-color: unset;
.album_disc_header {
padding-left: $small;
}
@include mediumPhones {
grid-template-columns: 2fr 2.5rem !important;
gap: $small !important;
.songlist-item {
grid-template-columns: 2fr 7.5rem !important;
// disable hover on mobile
// to prevent tap effect
&:hover {
background-color: unset;
}
@include mediumPhones {
grid-template-columns: 2fr 2.5rem !important;
gap: $small !important;
}
}
}
.song-artists,
.song-album {
display: none !important;
}
.song-artists,
.song-album {
display: none !important;
}
.isSmallArtists {
display: flex !important;
align-items: center;
gap: 4px;
font-size: small;
color: $white;
opacity: 0.67;
}
.isSmallArtists {
display: flex !important;
align-items: center;
gap: 4px;
font-size: small;
color: $white;
opacity: 0.67;
}
}
.isMedium {
// hide album column
.songlist-item {
grid-template-columns: 1.75rem 1.5fr 1fr 7.5rem;
}
// hide album column
.songlist-item {
grid-template-columns: 1.75rem 1.5fr 1fr 7.5rem;
}
.song-album {
display: none !important;
}
.song-album {
display: none !important;
}
}

View File

@@ -1,266 +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;
}
}
// 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

@@ -41,7 +41,9 @@ body {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
height: 100vh;
height: 100dvh;
width: 100vw;
width: 100dvw;
overflow: hidden;
margin: 0;
background-color: $body;

View File

@@ -1,11 +1,20 @@
input[type="number"] {
width: 40px;
padding: 4px 5px;
border-radius: 3px;
input {
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-weight: 500;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
input[type="search"] {
font-family: "SF Compact Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
height: 2.25rem !important;
input[type='number'] {
width: 40px;
padding: 4px 5px;
border-radius: 3px;
}
input[type='search'] {
height: 2.25rem !important;
}

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;
@@ -40,6 +40,8 @@ $purple: #bf5af2;
$brown: #ac8e68;
$indigo: #5e5ce6;
$teal: rgb(64, 200, 224);
$lightbrown: #ebca89;
$bars: #111111;
$primary: $gray4;
$accent: $gray1;
@@ -60,7 +62,7 @@ $separator: $gray4;
$margright: 0;
$padbottom: 4rem;
$maxwidth: 1438px;
$navheight: 4.75rem;
$navheight: 4.5rem;
$cardwidth: 10.75rem;
$maxpadleft: 5rem;

View File

@@ -52,7 +52,7 @@ defineEmits<{
}
}
@include largePhones {
@media only screen and (max-width: 724px) {
padding-left: 0.5rem !important;
}

View File

@@ -45,15 +45,15 @@ const color = computed(() => {
return props.source === "album" ? album.colors.btn : "";
});
const hookAction = async () => {
if (props.source === "album") {
// fetch data to be used in the component below this one.
await album.fetchArtistAlbums();
return;
}
};
// const hookAction = async () => {
// if (props.source === "album") {
// // fetch data to be used in the component below this one.
// await album.fetchArtistAlbums();
// return;
// }
// };
onMounted(hookAction);
// onMounted(hookAction);
</script>
<style lang="scss">

View File

@@ -5,9 +5,7 @@
:style="{
boxShadow:
// hide shadow on small screen
isSmallPhone ? '' : colors.bg
? `0 .5rem 2rem ${colors.bg}`
: '0 .5rem 2rem black',
isSmallPhone ? '' : colors.bg ? `0 .5rem 2rem ${colors.bg}` : '0 .5rem 2rem black',
}"
></div>
<div
@@ -18,14 +16,8 @@
background: isSmallPhone ? '' : colors.bg ? colors.bg : '',
}"
>
<div
class="big-img no-scroll"
:class="`${isHeaderSmall ? 'imgSmall' : ''} shadow-lg rounded-sm`"
>
<img
:src="imguri.thumb.large + album.image"
class="rounded-sm"
/>
<div class="big-img no-scroll" :class="`${isHeaderSmall ? 'imgSmall' : ''} shadow-lg rounded-sm`">
<img :src="imguri.thumb.large + album.image" class="rounded-sm" />
</div>
<Info />
</div>
@@ -135,6 +127,10 @@ useVisibility(albumheaderthing, handleVisibilityState)
}
}
.albumtype {
text-align: center;
}
.title {
font-size: 1.5rem !important;
max-width: fit-content;

View File

@@ -24,7 +24,6 @@ const update = async () => {
};
onMounted(async () => {
console.log("mounted");
props.fetch_callback().then(update);
});

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

@@ -1,63 +1,67 @@
<template>
<div class="artist-top-tracks">
<h3 class="section-title">
{{ title }}
<SeeAll :route="route" />
</h3>
<div class="tracks" :class="{ isSmall, isMedium }">
<SongItem
v-for="(song, index) in tracks"
:key="index"
:track="song"
:index="total ? total - index : index + 1"
:source="source"
@playThis="playHandler(index)"
/>
<div class="artist-top-tracks">
<h3 class="section-title" :class="{ isSmall, isMedium }">
{{ title }}
<SeeAll :route="route" />
</h3>
<div class="tracks" :class="{ isSmall, isMedium }">
<SongItem
v-for="(song, index) in tracks"
:key="index"
:track="song"
:index="total ? total - index : index + 1"
:source="source"
@playThis="playHandler(index)"
/>
</div>
<div v-if="!tracks.length" class="error">No tracks</div>
</div>
<div v-if="!tracks.length" class="error">No tracks</div>
</div>
</template>
<script setup lang="ts">
import { dropSources } from "@/enums";
import { Track } from "@/interfaces";
import { isMedium, isSmall } from "@/stores/content-width";
import SeeAll from "../shared/SeeAll.vue";
import SongItem from "../shared/SongItem.vue";
import { dropSources } from '@/enums'
import { Track } from '@/interfaces'
import { isMedium, isSmall } from '@/stores/content-width'
import SeeAll from '../shared/SeeAll.vue'
import SongItem from '../shared/SongItem.vue'
defineProps<{
tracks: Track[];
route: string;
title: string;
playHandler: (index: number) => void;
source: dropSources;
total?: number;
}>();
tracks: Track[]
route: string
title: string
playHandler: (index: number) => void
source: dropSources
total?: number
}>()
</script>
<style lang="scss">
.artist-top-tracks {
padding-top: 1rem;
padding-top: 1rem;
.section-title {
margin-left: 0;
align-items: baseline;
}
.error {
padding-left: 1rem;
color: $red;
}
h3 {
display: flex;
justify-content: space-between;
padding-left: 1rem !important; // applies to favorite page
padding-right: $small;
@include largePhones {
padding-left: $small !important;
.section-title {
margin-left: 0;
align-items: baseline;
}
.section-title.isSmall {
padding-left: 0.5rem !important;
}
.error {
padding-left: 1rem;
color: $red;
}
h3 {
display: flex;
justify-content: space-between;
padding-left: 1rem !important; // applies to favorite page
padding-right: $small;
@media only screen and (max-width: 724px) {
padding-left: $small !important; // applies to favorite page
}
}
}
}
</style>

View File

@@ -1,219 +1,217 @@
<template>
<div
class="b-bar"
:style="{
paddingLeft: `${settings.is_default_layout ? '1rem' : ''}`,
paddingRight: `${settings.is_default_layout ? '1rem' : ''}`,
}"
>
<LeftGroup @handleFav="handleFav" />
<div class="center">
<div v-if="!isMobile" class="with-time">
<div class="time time-current">
<div class="numbers">
{{ formatSeconds(queue.duration.current || 0) }}
</div>
</div>
<div
class="b-bar"
:style="{
paddingLeft: `${settings.is_default_layout ? '1rem' : ''}`,
paddingRight: `${settings.is_default_layout ? '1rem' : ''}`,
}"
>
<LeftGroup @handleFav="handleFav" />
<div class="center">
<div v-if="!isMobile" class="with-time">
<div class="time time-current">
<div class="numbers">
{{ formatSeconds(queue.duration.current || 0) }}
</div>
</div>
<div class="buttons rounded-sm border">
<HotKeys />
<div class="buttons rounded-sm border">
<HotKeys />
</div>
<div class="time time-full">
<div class="numbers">
{{ formatSeconds(queue.duration.full) }}
</div>
</div>
</div>
<Progress />
</div>
<div class="time time-full">
<div class="numbers">
{{ formatSeconds(queue.duration.full) }}
</div>
</div>
</div>
<Progress />
<RightGroup v-if="!isMobile" @handleFav="handleFav" />
<Navigation v-else />
</div>
<RightGroup v-if="!isMobile" @handleFav="handleFav" />
<Navigation v-else />
</div>
</template>
<script setup lang="ts">
import { favType } from "@/enums";
import favoriteHandler from "@/helpers/favoriteHandler";
import { isMobile } from "@/stores/content-width";
import { formatSeconds } from "@/utils";
import { favType } from '@/enums'
import favoriteHandler from '@/helpers/favoriteHandler'
import { isMobile } from '@/stores/content-width'
import { formatSeconds } from '@/utils'
import useQStore from "@/stores/queue";
import useSettings from "@/stores/settings";
import useQStore from '@/stores/queue'
import useSettings from '@/stores/settings'
import HotKeys from "@/components/LeftSidebar/NP/HotKeys.vue";
import Progress from "@/components/LeftSidebar/NP/Progress.vue";
import Navigation from "@/components/LeftSidebar/NavButtons.vue";
import HotKeys from '@/components/LeftSidebar/NP/HotKeys.vue'
import Progress from '@/components/LeftSidebar/NP/Progress.vue'
import Navigation from '@/components/LeftSidebar/NavButtons.vue'
import LeftGroup from "./Left.vue";
import RightGroup from "./Right.vue";
import LeftGroup from './Left.vue'
import RightGroup from './Right.vue'
const queue = useQStore();
const settings = useSettings();
const queue = useQStore()
const settings = useSettings()
function handleFav() {
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || "",
() => null,
() => null
);
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || '',
() => null,
() => null
)
}
</script>
<style lang="scss">
.b-bar {
background-color: rgb(22, 22, 22);
display: grid;
grid-template-columns: 1fr max-content 1fr;
align-items: center;
z-index: 1;
@include allPhones {
display: flex;
flex-direction: column;
align-items: unset;
gap: $small;
padding: $medium 1rem;
/* Hiding the dot/thumb/handle for readonly input */
/* Webkit browsers, Firefox, IE etc */
&:hover > .center > #progress::-webkit-slider-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-moz-range-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-ms-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
}
button {
background: transparent;
border-radius: $small;
width: 3rem;
transition: background-color 0.2s ease-out, border-color 0.2s ease-out;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
}
display: grid;
grid-template-columns: 1fr max-content 1fr;
align-items: center;
z-index: 1;
@include allPhones {
height: 3rem;
display: flex;
flex-direction: column;
align-items: unset;
gap: $small;
padding: $medium 1rem;
/* Hiding the dot/thumb/handle for readonly input */
/* Webkit browsers, Firefox, IE etc */
&:hover > .center > #progress::-webkit-slider-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-moz-range-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-ms-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
}
@include largePhones {
width: 2.5rem;
height: 2.5rem;
&:nth-child(2) {
width: 3.5rem;
}
}
@include smallestPhones {
&:first-child {
display: none;
}
&:nth-child(2) {
margin-left: $smaller;
}
&:last-child {
display: none;
}
}
}
&:hover {
// INFO: Show the progress bar when hovering over the bottom bar
#progress::-moz-range-thumb {
height: 1rem;
width: 1rem;
}
#progress::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
}
#progress::-ms-thumb {
height: 1rem;
width: 1rem;
}
// INFO: Also show the expand button
.np-image .expandicon {
opacity: 1;
}
}
.with-time {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: flex-end;
height: 2rem;
button {
background: transparent;
}
}
background: transparent;
border-radius: $small;
width: 3rem;
transition: background-color 0.2s ease-out, border-color 0.2s ease-out;
.center {
display: grid;
align-items: center;
gap: $small;
margin-bottom: -$smallest;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
}
width: 30rem;
@include allPhones {
height: 3rem;
}
@media only screen and (max-width: 1080px) {
width: 20rem !important;
@include largePhones {
width: 2.5rem;
height: 2.5rem;
&:nth-child(2) {
width: 3.5rem;
}
}
@include smallestPhones {
&:first-child {
display: none;
}
&:nth-child(2) {
margin-left: $smaller;
}
&:last-child {
display: none;
}
}
}
@include allPhones {
width: 100% !important;
margin: 4px -16px;
user-select: none;
pointer-events: none;
&:hover {
// INFO: Show the progress bar when hovering over the bottom bar
#progress::-moz-range-thumb {
height: 1rem;
width: 1rem;
}
> #progress {
height: 1px !important;
width: 100vw !important;
margin: unset;
}
#progress::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
}
#progress::-ms-thumb {
height: 1rem;
width: 1rem;
}
// INFO: Also show the expand button
.np-image .expandicon {
opacity: 1;
}
}
.time {
font-weight: 500;
font-size: $medium;
.with-time {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: flex-end;
height: 2rem;
.numbers {
background-color: $gray3;
border-radius: $smaller;
padding: 1px $smaller;
font-variant-numeric: tabular-nums;
}
button {
background: transparent;
}
}
}
// hotkey
.buttons {
display: grid;
place-items: center;
transform: scale(1.2);
border: none;
}
.center {
display: grid;
align-items: center;
gap: 0.625rem;
width: 30rem;
@media only screen and (max-width: 1080px) {
width: 20rem !important;
}
@include allPhones {
width: 100% !important;
margin: 4px -16px;
user-select: none;
pointer-events: none;
> #progress {
height: 1px !important;
width: 100vw !important;
margin: unset;
}
}
.time {
font-weight: 500;
font-size: $medium;
.numbers {
background-color: $gray3;
border-radius: $smaller;
padding: 1px $smaller;
font-variant-numeric: tabular-nums;
}
}
}
// hotkey
.buttons {
display: grid;
place-items: center;
transform: scale(1.2);
border: none;
}
}
</style>

View File

@@ -1,178 +1,224 @@
<template>
<div v-auto-animate class="left-group">
<HeartSvg
v-if="settings.use_np_img && !isMobile"
:state="queue.currenttrack?.is_favorite"
@handleFav="$emit('handleFav')"
/>
<RouterLink
v-else
title="Go to Now Playing"
:to="{
name: Routes.nowPlaying,
params: {
tab: 'home',
},
replace: true,
}"
class="np-image rounded-sm no-scroll"
>
<img :src="paths.images.thumb.small + queue.currenttrack?.image" alt="" />
<div class="expandicon">
<ExpandSvg />
</div>
</RouterLink>
<div
class="track-info"
:style="{
color: getShift(colors.theme1, [0, -170]),
}"
>
<div v-tooltip class="title">
<span class="ellip">
{{ queue.currenttrack?.title || "Hello there" }}
</span>
<MasterFlag :bitrate="queue.currenttrack?.bitrate || 0" />
</div>
<ArtistName
:artists="queue.currenttrack?.artists || []"
:albumartists="queue.currenttrack?.albumartists || 'Welcome to Swing Music'"
class="artist"
/>
<div v-auto-animate class="left-group">
<HeartSvg
v-if="settings.use_np_img && !isMobile"
:state="queue.currenttrack?.is_favorite"
@handleFav="$emit('handleFav')"
/>
<RouterLink
v-else
title="Go to Now Playing"
:to="{
name: Routes.nowPlaying,
params: {
tab: 'home',
},
replace: true,
}"
class="np-image rounded-sm no-scroll"
>
<!-- <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>
</RouterLink>
<div
class="track-info"
:style="{
color: getShift(colors.lightVibrant, [0, -170]),
}"
>
<div v-tooltip class="title">
<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 || []"
:albumartists="queue.currenttrack?.albumartists || 'Welcome to Swing Music'"
class="artist"
/>
</div>
<Actions v-if="isLargerMobile" @handleFav="$emit('handleFav')" />
<HotKeys v-if="isMobile" />
</div>
<Actions v-if="isLargerMobile" @handleFav="$emit('handleFav')" />
<HotKeys v-if="isMobile" />
</div>
</template>
<script setup lang="ts">
import { paths } from "@/config";
import { Routes } from "@/router";
import { getShift } from "@/utils/colortools/shift";
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 useSettingsStore from "@/stores/settings";
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 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 queue = useQStore()
const colors = useColorStore()
const settings = useSettingsStore()
defineEmits<{
(e: "handleFav"): void;
}>();
(e: 'handleFav'): void
}>()
</script>
<style lang="scss">
.left-group {
display: grid;
grid-template-columns: max-content 1fr;
gap: $medium;
align-items: center;
font-size: small;
font-weight: 700;
line-height: 1.2;
margin-right: $medium;
display: grid;
grid-template-columns: max-content 1fr;
gap: $medium;
align-items: center;
font-size: small;
font-weight: 700;
line-height: 1.2;
margin-right: $medium;
.np-image {
position: relative;
height: 3rem;
img {
height: 100%;
.text-loader {
height: 1rem;
}
.expandicon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(51, 51, 51, 0.575);
.np-image {
position: relative;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s;
img {
height: 100%;
}
svg {
transform: rotate(-90deg);
}
.expandicon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(51, 51, 51, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease-out, height 0.2s ease-out, transform 0.2s ease-out,
background-color 0.2s ease-out;
svg {
transform: rotate(-90deg) scale(0.92);
}
}
&:hover {
.expandicon {
transform: translateY(-$medium);
height: 130%;
}
}
&:active {
.expandicon {
background-color: rgba(51, 51, 51, 0.74);
}
}
@include largePhones {
flex-shrink: 0;
margin-right: $medium;
}
@include smallerPhones {
margin-right: $small;
}
}
&:hover {
.expandicon {
transform: translateY(-$medium);
height: 130%;
}
.heart-button {
height: 3rem;
width: 3rem;
border: solid 1px $gray4;
padding: 0;
}
@include largePhones {
flex-shrink: 0;
margin-right: $medium;
}
.track-info {
.title {
color: $white;
display: flex;
align-items: center;
margin-bottom: 2px;
}
@include smallerPhones {
margin-right: $small;
}
}
.artistname {
opacity: 0.75;
.heart-button {
height: 3rem;
width: 3rem;
border: solid 1px $gray4;
padding: 0;
}
a {
font-size: 0.8rem;
}
}
.track-info {
.title {
color: $white;
display: flex;
align-items: center;
margin-bottom: 2px;
}
@include allPhones {
width: calc(100% + 8px);
}
.artistname {
opacity: 0.75;
a {
font-size: 0.8rem;
}
@include largePhones {
width: unset;
flex-grow: 1;
}
}
@include allPhones {
width: calc(100% + 8px);
grid-template-columns: max-content 1fr max-content max-content;
margin-right: unset;
.heart-button {
height: max-content;
border: 1px solid transparent;
}
}
@include largePhones {
width: unset;
flex-grow: 1;
display: flex;
gap: 0;
max-width: calc(100% - 8px);
}
}
@include allPhones {
grid-template-columns: max-content 1fr max-content max-content;
margin-right: unset;
@keyframes fadeIn {
from {
opacity: 0;
}
.heart-button {
height: max-content;
border: 1px solid transparent;
to {
opacity: 1;
}
}
}
@include largePhones {
display: flex;
gap: 0;
max-width: calc(100% - 8px);
}
.explicit-icon,
.master-flag {
opacity: 0;
animation: fadeIn 0.5s ease-in-out forwards;
animation-delay: 1.5s;
}
}
</style>

View File

@@ -1,91 +1,106 @@
<template>
<div class="right-group">
<LyricsButton />
<Volume />
<button
class="repeat"
:class="{ 'repeat-disabled': settings.no_repeat }"
:title="settings.repeat_all ? 'Repeat all' : settings.no_repeat ? 'No repeat' : 'Repeat one'"
@click="settings.toggleRepeatMode"
>
<RepeatOneSvg v-if="settings.repeat_one" />
<RepeatAllSvg v-else />
</button>
<button title="Shuffle" @click="queue.shuffleQueue">
<ShuffleSvg />
</button>
<HeartSvg
v-if="!hideHeart"
title="Favorite"
:state="queue.currenttrack?.is_favorite"
@handleFav="() => $emit('handleFav')"
/>
</div>
<div class="right-group">
<LyricsButton />
<Volume />
<button
class="repeat"
: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'" />
<RepeatAllSvg v-else />
</button>
<button class="shuffle" title="Shuffle" @click="queue.shuffleQueue">
<ShuffleSvg />
</button>
<HeartSvg
v-if="!hideHeart"
title="Favorite"
:state="queue.currenttrack?.is_favorite"
@handleFav="() => $emit('handleFav')"
/>
</div>
</template>
<script setup lang="ts">
import useQueue from "@/stores/queue";
import useSettings from "@/stores/settings";
import useQueue from '@/stores/queue'
import useSettings from '@/stores/settings'
import RepeatOneSvg from "@/assets/icons/repeat-one.svg";
import RepeatAllSvg from "@/assets/icons/repeat.svg";
import ShuffleSvg from "@/assets/icons/shuffle.svg";
import HeartSvg from "../shared/HeartSvg.vue";
import LyricsButton from "../shared/LyricsButton.vue";
import Volume from "./Volume.vue";
import RepeatOneSvg from '@/assets/icons/repeat-one.svg'
import RepeatAllSvg from '@/assets/icons/repeat.svg'
import ShuffleSvg from '@/assets/icons/shuffle.svg'
import HeartSvg from '../shared/HeartSvg.vue'
import LyricsButton from '../shared/LyricsButton.vue'
import Volume from './Volume.vue'
const queue = useQueue();
const settings = useSettings();
const queue = useQueue()
const settings = useSettings()
defineProps<{
hideHeart?: boolean;
}>();
hideHeart?: boolean
}>()
defineEmits<{
(event: "handleFav"): void;
}>();
(event: 'handleFav'): void
}>()
</script>
<style lang="scss">
.right-group {
display: grid;
justify-content: flex-end;
grid-template-columns: repeat(5, max-content);
align-items: center;
height: 4rem;
display: grid;
justify-content: flex-end;
grid-template-columns: repeat(5, max-content);
align-items: center;
height: 4rem;
@include allPhones {
width: max-content;
height: unset;
}
button {
height: 3rem !important;
width: 3rem !important;
background-color: transparent;
border: solid 1px transparent;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
@include allPhones {
width: max-content;
height: unset;
}
}
.lyrics,
.repeat {
svg {
transform: scale(0.75);
button {
height: 3rem !important;
width: 3rem !important;
background-color: transparent;
border: solid 1px transparent;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
}
}
}
button.repeat.repeat-disabled {
svg {
opacity: 0.25;
.shuffle {
padding: $small $smallest !important;
}
}
.heart-button {
border: solid 1px $gray4 !important;
}
.lyrics,
.repeat,
.shuffle {
svg {
height: 1.5rem;
width: 1.5rem;
}
&:active > svg {
transform: scale(0.6);
}
}
.speaker svg {
height: 1.35rem;
width: 1.35rem;
}
button.repeat.repeat-disabled {
svg {
opacity: 0.25;
}
}
.heart-button {
border: solid 1px $gray4 !important;
}
}
</style>

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

@@ -7,21 +7,10 @@
@mouseleave="handleMouseLeave"
@click="runAction"
>
<div
class="icon image"
v-html="option.icon"
></div>
<div class="icon image" v-html="option.icon"></div>
<div class="label ellip">{{ option.label }}</div>
<div
v-if="hasChildren && !option.singleChild"
class="more"
v-html="ExpandIcon"
></div>
<div
v-if="children"
ref="childRef"
class="children rounded shadow-sm"
>
<div v-if="hasChildren && !option.singleChild" class="more" v-html="ExpandIcon"></div>
<div v-if="children" ref="childRef" class="children rounded shadow-sm">
<div className="wrapper">
<div
v-for="child in children"
@@ -40,13 +29,7 @@
</template>
<script setup lang="ts">
import {
createPopper,
Instance,
Modifier,
Placement,
Rect,
} from '@popperjs/core'
import { createPopper, Instance, Modifier, Placement, Rect } from '@popperjs/core'
import { computed, ref } from 'vue'
import { contextChildrenShowMode } from '@/enums'
@@ -72,10 +55,7 @@ const childRef = ref<HTMLElement>()
const parentRef = ref<HTMLElement>()
const hasChildren = computed(() => {
return (
props.option.children &&
props.childrenShowMode === contextChildrenShowMode.hover
)
return props.option.children && props.childrenShowMode === contextChildrenShowMode.hover
})
let popperInstance: Instance | null = null
@@ -84,7 +64,7 @@ async function handleMouseEnter() {
if (!hasChildren.value) return
stillWaitingForChildren.value = true
await new Promise((resolve) => setTimeout(resolve, showChildrenDelay))
await new Promise(resolve => setTimeout(resolve, showChildrenDelay))
if (stillWaitingForChildren.value) {
showChildren()
@@ -122,11 +102,7 @@ async function showChildren() {
{
offset:
| [number, number]
| ((args: {
placement: Placement
reference: Rect
popper: Rect
}) => [number, number])
| ((args: { placement: Placement; reference: Rect; popper: Rect }) => [number, number])
}
> = {
name: 'offset',
@@ -141,30 +117,26 @@ async function showChildren() {
},
}
popperInstance = createPopper(
parentRef.value as HTMLElement,
childRef.value as HTMLElement,
{
placement: 'right-start',
modifiers: [
{
name: 'preventOverflow',
options: {
altAxis: true,
boundariesElement: 'viewport',
},
popperInstance = createPopper(parentRef.value as HTMLElement, childRef.value as HTMLElement, {
placement: 'right-start',
modifiers: [
{
name: 'preventOverflow',
options: {
altAxis: true,
boundariesElement: 'viewport',
},
{
name: 'flip',
options: {
fallbackPlacements: ['left-start', 'auto'],
boundariesElement: 'viewport',
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['left-start', 'auto'],
boundariesElement: 'viewport',
},
offsetModifier,
],
}
)
},
offsetModifier,
],
})
childRef.value ? (childRef.value.style.visibility = 'visible') : null
childRef.value ? (childRef.value.style.opacity = '1') : null
childrenShown.value = true
@@ -204,6 +176,7 @@ function runChildAction(action: () => void) {
<style lang="scss">
.context-item {
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
@@ -217,7 +190,7 @@ function runChildAction(action: () => void) {
width: 1.5rem;
position: absolute;
right: 2px;
bottom: 5px;
bottom: 6px;
transform: scale(0.65);
}
@@ -288,4 +261,9 @@ function runChildAction(action: () => void) {
width: 9rem;
}
}
/* Removes the cursor pointer on the empty area within children dropdown of context-items */
.context-item:has(.children) > .children {
cursor: initial !important;
}
</style>

View File

@@ -83,22 +83,15 @@ const browselist = [
icon: AlbumIcon,
class: "favorite",
},
{
title: "Settings",
route: null,
icon: SettingsIcon,
action: () => {
useDialog().showSettingsModal();
},
class: "settings",
},
{
title: "Quick scan",
route: null,
icon: ReloadIcon,
action: triggerScan,
class: "reload",
},
// {
// title: "Settings",
// route: null,
// icon: SettingsIcon,
// action: () => {
// useDialog().showSettingsModal();
// },
// class: "settings",
// },
{
title: "Stats",
icon: AlbumIcon,

View File

@@ -0,0 +1,63 @@
<template>
<RouterLink
:to="{
name: Routes.Mix,
params: {
mixid: mix.id,
},
query: mix.extra.type === 'artist' ? { src: mix.sourcehash } : { src: mix.extra.og_sourcehash },
}"
class="mixcard rounded"
>
<MixImage :mix="mix" :on_header="on_header" />
<div class="info">
<div class="mix rhelp" v-if="mix.time || mix.help_text">
<span class="help" v-if="mix.help_text">{{ mix.extra.type }} {{ mix.help_text }} </span>
<span class="time"> {{ mix.time }} </span>
</div>
<div class="description ellip2">
{{ mix.description }}
</div>
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { Mix } from '@/interfaces'
import { RouterLink } from 'vue-router'
import { Routes } from '@/router'
import MixImage from './MixImage.vue'
defineProps<{
mix: Mix
on_header?: boolean
}>()
</script>
<style lang="scss">
.mixcard {
padding: $medium;
&:hover {
background-color: $gray;
cursor: pointer;
}
.info {
margin-top: $small;
.title {
font-size: 1rem;
font-weight: 600;
}
.description {
font-size: 0.8rem;
font-weight: 500;
color: $gray1;
margin-top: $smaller;
}
}
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div class="miximage" :class="{ on_header }">
<div
class="infooverlay"
v-if="!mix.extra['image']"
:style="{
color: getTextColor(mix.extra.images?.[0]?.color || ''),
}"
>
<div class="type" :style="{ color: getTypeColor(mix.extra.images?.[0]?.color || '') }">
{{ mix.extra['type'] }} mix
</div>
<div class="title ellip">{{ mix.title.replace('Radio', '') }}</div>
</div>
<img
class="main"
:src="getImageUrl(mix.extra['image']?.image || '', false)"
v-if="mix.extra['image']"
:key="mix.extra['image']['image']"
/>
<div class="images" v-else>
<img
v-for="image in mix.extra['images']"
class="shadow-sm"
:src="getImageUrl(image, true)"
:key="image['image']"
/>
</div>
<div
class="gradient rounded-sm"
v-if="!mix.extra['image']"
:style="{
background: gradient,
}"
></div>
</div>
</template>
<script setup lang="ts">
import { paths } from '@/config'
import { Mix } from '@/interfaces'
import { addOpacity } from '@/utils/colortools/shift'
import { getTextColor } from '@/utils/colortools/shift'
import { getTypeColor } from '@/utils/colortools'
import { onMounted, ref } from 'vue'
const props = defineProps<{
mix: Mix
on_header?: boolean
}>()
const gradient = ref('')
async function getGradient() {
let color = props.mix.extra.image?.color
if (!color) {
color = props.mix.extra.images?.[0]?.color
}
if (color) {
return `linear-gradient(27deg, ${color} 21%, ${addOpacity(
color,
0.15
)}),linear-gradient(-17deg, ${color} 10%, ${addOpacity(color, 0)} 30%)`
}
return ''
}
function getImageUrl(image: any, is_extra: boolean = false) {
if (is_extra) {
if (image['type'] == 'artist') {
return paths.images.artist.medium + image['image']
}
return paths.images.thumb.medium + image['image']
}
if (props.on_header) {
return paths.images.mix.medium + image
}
return paths.images.mix.medium + image
}
onMounted(async () => {
gradient.value = await getGradient()
})
</script>
<style lang="scss">
.miximage {
position: relative;
aspect-ratio: 1;
.gradient {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5));
}
.infooverlay {
position: absolute;
bottom: $small;
z-index: 1;
left: $small;
.type {
font-size: 0.9rem;
font-weight: 900;
text-transform: capitalize;
// color: rgb(109, 69, 16) !important;
}
.title {
font-size: 1.15rem;
font-weight: 900;
}
}
.main {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.59rem;
}
.images {
border-radius: 0.59rem;
overflow: hidden;
height: 100%;
width: 100%;
position: relative;
img {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0;
height: 50%;
object-fit: cover;
border-radius: 0 !important;
}
img:nth-child(2) {
left: 25%;
}
img:nth-child(3) {
left: 50%;
}
}
}
.miximage.on_header {
height: 100%;
img {
border-radius: 1.1rem;
}
.gradient {
border-radius: 1rem;
}
.infooverlay {
padding: $small;
.type {
font-size: 1.25rem;
font-weight: 900;
}
.title {
font-size: 2rem;
font-weight: 900;
}
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="mixheader" v-if="mix.title">
<MixImage :mix="mix" :on_header="true" />
<div class="mixinfo">
<div class="header_type">{{ mix.extra['type'] }} mix</div>
<div class="header_title">{{ mix.title }}</div>
<div class="header_description ellip2">
{{ mix.description }}
</div>
<div class="bunchofstuff">
{{ mix.trackcount }} track{{ mix.trackcount === 1 ? '' : 's' }} {{ mix.duration }}
</div>
<div class="buttons">
<PlayBtnRect :source="playSources.mix" :bg_color="'#fff'" @click.prevent="$emit('playThis')" />
<button class="savebtn" :title="mix.saved ? 'Saved Mix' : 'Save Mix'" @click="saveMix">
<SaveFilledSvg v-if="mix.saved" />
<SaveSvg v-else />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FullMix } from '@/interfaces'
import MixImage from './MixImage.vue'
import PlayBtnRect from '../shared/PlayBtnRect.vue'
import SaveSvg from '@/assets/icons/bookmark.svg'
import SaveFilledSvg from '@/assets/icons/bookmark.fill.svg'
import { playSources } from '@/enums'
import useAxios from '@/requests/useAxios'
import { paths } from '@/config'
const props = defineProps<{
mix: FullMix
}>()
defineEmits<{
(e: 'playThis'): void
}>()
async function saveMix() {
const initialState = props.mix.saved
props.mix.saved = !initialState
const res = await useAxios({
url: paths.api.mixes + '/save',
method: 'POST',
props: {
type: props.mix.extra.type,
mixid: props.mix.id,
// INFO: save artist mixes using their sourcehash,
// but track mixes using their og_sourcehash, as track mixes are based
// on artist mixes
sourcehash: props.mix.extra.type === 'artist' ? props.mix.sourcehash : props.mix.extra.og_sourcehash,
},
})
if (res.status !== 200) {
props.mix.saved = initialState
}
}
</script>
<style lang="scss">
.mixheader {
height: 18rem;
display: grid;
grid-template-columns: 17.5rem 1fr;
gap: 1rem;
padding: $small;
.mixinfo {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.header_type {
font-weight: 600;
text-transform: capitalize;
font-size: 14px;
color: $gray1;
}
.header_title {
font-size: 4rem;
font-weight: 900;
}
.header_description {
font-size: 1rem;
font-weight: 500;
margin-top: $smaller;
color: $brown;
}
.bunchofstuff {
margin-top: $small;
font-size: 14px;
font-weight: 500;
}
.buttons {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: center;
.savebtn {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
svg {
height: 1.5rem;
}
}
}
}
</style>

View File

@@ -1,18 +1,7 @@
<template>
<div
v-if="notifStore.notifs"
class="toasts"
>
<div
v-for="notif in notifStore.notifs"
:key="notif.text"
class="new-notif rounded-sm"
:class="notif.type"
>
<component
:is="getSvg(notif.type)"
class="notif-icon"
/>
<div v-if="notifStore.notifs" class="toasts">
<div v-for="notif in notifStore.notifs" :key="notif.text" class="new-notif rounded-sm" :class="notif.type">
<component :is="getSvg(notif.type)" class="notif-icon" />
<div class="notif-text">{{ notif.text }}</div>
</div>
</div>
@@ -46,46 +35,71 @@ function getSvg(notif: NotifType) {
</script>
<style lang="scss">
.toasts {
position: fixed;
bottom: 6rem;
left: 50%;
width: 100%;
transform: translate(-50%);
z-index: 1003;
display: flex;
align-items: center;
flex-direction: column-reverse;
gap: 1rem;
position: fixed;
bottom: 6rem;
left: 50%;
width: 100%;
transform: translate(-50%);
z-index: 1003;
display: flex;
align-items: center;
flex-direction: column-reverse;
gap: 1rem;
}
.new-notif {
font-size: 0.85rem;
font-weight: 600;
width: 18rem;
min-height: 4rem;
background-color: $gray;
display: grid;
place-items: center;
box-shadow: 0px 0px 2rem rgba(0, 0, 0, 0.466);
padding: 1rem $small;
position: relative;
font-size: 0.85rem;
font-weight: 600;
color: $white;
display: grid;
place-items: center;
width: 100%;
max-width: 18rem;
min-height: 4rem;
padding: 1rem $medium;
padding-right: $large;
border: 1px solid $gray5;
background-color: $gray;
box-shadow: 0px 0px 2rem rgba(0, 0, 0, 0.6);
grid-template-columns: 2rem 3fr;
gap: $smaller;
gap: $small;
.notif-text {
width: 100%;
}
.notif-text {
width: 100%;
}
@include smallestPhones {
max-width: calc(100% - 2rem);
}
@include smallestPhones {
max-width: calc(100% - 2rem);
}
}
.new-notif.error > .notif-icon {
color: #c54848;
}
.new-notif.info > .notif-icon {
color: #418dc0;
}
.new-notif.favorite > .notif-icon,
.new-notif.success > .notif-icon {
color: #4cbd4c;
}
.new-notif.working > .notif-icon {
color: $white;
}
/*
.new-notif.error {
$bg: rgb(197, 72, 72);
background-color: $bg;
}
*/
/*
.new-notif.info,
.new-notif.favorite,
.new-notif.success {
@@ -93,9 +107,12 @@ function getSvg(notif: NotifType) {
background-color: $bg;
color: $black;
}
*/
/*
.new-notif.working {
$bg: $gray4;
background-color: $bg;
}
*/
</style>

View File

@@ -1,176 +1,262 @@
<template>
<div class="now-playing-header">
<div class="centered">
<PlayingFrom />
<RouterLink
:to="{
name: Routes.album,
params: {
albumhash: queue.currenttrack?.albumhash || ' ',
},
}"
title="Go to Album"
class="np-image"
>
<img v-motion-fade class="rounded" :src="paths.images.thumb.large + queue.currenttrack?.image" />
</RouterLink>
<NowPlayingInfo @handle-fav="handleFav" />
<Progress v-if="isSmallPhone" />
<div v-if="isSmallPhone" class="below-progress">
<div class="time">
{{ formatSeconds(queue.duration.current) }}
<div class="now-playing-header">
<div class="top">
<RouterLink :to="sourceData.location">
{{ sourceData.name }}
</RouterLink>
</div>
<Buttons :hide-heart="true" @handleFav="() => {}" />
<div class="time">
{{ formatSeconds(queue.duration.full) }}
<div class="centered">
<RouterLink
:to="{
name: Routes.album,
params: {
albumhash: queue.currenttrack?.albumhash || ' ',
},
}"
title="Go to Album"
class="np-image"
>
<ImageLoader
:image="paths.images.thumb.original + queue.currenttrack?.image"
:blurhash="queue.currenttrack?.blurhash"
:duration="1000"
/>
</RouterLink>
</div>
</div>
<div class="below">
<NowPlayingInfo @handle-fav="handleFav" />
<Progress v-if="isMobile" />
<div class="below-progress">
<div v-if="isMobile" class="time">
{{ formatSeconds(queue.duration.current) }}
</div>
<Buttons v-if="isSmallPhone" :hide-heart="true" @handleFav="() => {}" />
<div v-if="isMobile" class="time">
{{ formatSeconds(queue.duration.full) }}
</div>
</div>
</div>
<!-- <TrackContext /> -->
<!-- <h3 v-if="queue.next" class="nowplaying_title">Up Next</h3>
<SongItem
v-if="queue.next"
:track="queue.next"
:index="queue.nextindex + 1"
:source="dropSources.folder"
@play-this="queue.playNext"
/>
<h3 class="nowplaying_title">Queue</h3> -->
</div>
<h3 class="nowplaying_title" v-if="queue.next">Up Next</h3>
<SongItem
v-if="queue.next"
:track="queue.next"
:index="queue.nextindex + 1"
:source="dropSources.folder"
@play-this="queue.playNext"
/>
<h3 class="nowplaying_title">Queue</h3>
</div>
</template>
<script setup lang="ts">
import { paths } from "@/config";
import { dropSources, favType } from "@/enums";
import favoriteHandler from "@/helpers/favoriteHandler";
import { Routes } from "@/router";
import { isSmallPhone } from "@/stores/content-width";
import useQueueStore from "@/stores/queue";
import { formatSeconds } from "@/utils";
import { paths } from '@/config'
import { dropSources, favType } from '@/enums'
import favoriteHandler from '@/helpers/favoriteHandler'
import { Routes } from '@/router'
import { isMobile, isSmallPhone } from '@/stores/content-width'
import useQueueStore from '@/stores/queue'
import { formatSeconds } from '@/utils'
import Progress from "@/components/LeftSidebar/NP/Progress.vue";
import Buttons from "../BottomBar/Right.vue";
import SongItem from "../shared/SongItem.vue";
import NowPlayingInfo from "./NowPlayingInfo.vue";
import PlayingFrom from "./PlayingFrom.vue";
import Progress from '@/components/LeftSidebar/NP/Progress.vue'
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 queue = useQueueStore();
const props = defineProps<{
source: From
}>()
const queue = useQueueStore()
const sourceData = computed(() => {
const { name, location } = playingFrom(props.source)
return { name, location }
})
function handleFav() {
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || "",
() => null,
() => null
);
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || '',
() => null,
() => null
)
}
</script>
<style lang="scss">
.now-playing-view.isSmall .now-playing-header .nowplaying_title {
padding-left: 0.5rem;
}
.now-playing-header {
padding-bottom: $smaller;
position: relative;
padding-bottom: $smaller;
position: relative;
.nowplaying_title {
padding-left: 1rem;
margin: 1.25rem 0;
&:last-child {
padding-top: $large;
margin: 1rem 0;
}
display: grid;
place-items: stretch;
justify-items: center;
grid-template-rows: 1fr max-content 1fr;
@include largePhones {
padding-left: 0.5rem;
}
}
.below-progress {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
.time {
font-size: $medium;
font-weight: 500;
background-color: $gray3;
padding: 1px $smaller;
min-width: 2.5rem;
text-align: center;
border-radius: $smaller;
font-variant-numeric: tabular-nums;
padding: 1.5rem !important;
}
/* Responsive */
@include largePhones {
.right-group button.speaker {
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.nowplaying_title {
padding-left: 1rem;
margin: 1.25rem 0;
&:last-child {
padding-top: $large;
margin: 1rem 0;
}
@media only screen and (max-width: 724px) {
padding-left: 0.5rem;
}
/* Somehow has to be replaced by above now
@include largePhones {
padding-left: 0.5rem;
}
*/
}
@include smallestPhones {
position: relative;
flex-direction: column;
align-items: unset;
gap: $small;
.time:first-child {
align-self: baseline;
margin-left: 4px;
}
.time:last-child {
align-self: end;
position: absolute;
top: 0;
right: 4px;
}
.right-group {
width: 100% !important;
.below-progress {
display: flex;
justify-content: space-between;
}
}
}
align-items: center;
margin-top: 1rem;
.centered {
margin: 0 auto;
width: 26rem;
max-width: 100%;
}
.time {
font-size: $medium;
font-weight: 500;
background-color: $gray3;
padding: 1px $smaller;
min-width: 2.5rem;
text-align: center;
border-radius: $smaller;
font-variant-numeric: tabular-nums;
}
.np-image {
position: relative;
margin-bottom: 1rem;
/* Responsive */
@include allPhones {
.right-group button.speaker {
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
}
img {
width: 100%;
height: 100%;
max-width: 30rem;
// aspect-ratio: 1;
object-fit: cover;
}
}
@include smallestPhones {
position: relative;
flex-direction: column;
align-items: unset;
gap: $small;
#progress {
margin-top: 1rem;
margin-right: 0;
.time:first-child {
align-self: baseline;
margin-left: 4px;
}
&::-moz-range-thumb {
height: 0.8rem;
.time:last-child {
align-self: end;
position: absolute;
top: 0;
right: 4px;
}
.right-group {
width: 100% !important;
display: flex;
justify-content: space-between;
}
}
}
&::-webkit-slider-thumb {
height: 0.8rem;
$image-size: auto;
.centered {
margin: 0 auto;
width: 100%;
// max-width: $image-size;
}
&::-ms-thumb {
height: 0.8rem;
.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 {
position: relative;
margin-bottom: 1rem;
img {
width: 100%;
height: 100%;
aspect-ratio: 1;
object-fit: cover;
}
}
#progress {
margin-top: 1rem;
margin-right: 0;
&::-moz-range-thumb {
height: 0.8rem;
}
&::-webkit-slider-thumb {
height: 0.8rem;
}
&::-ms-thumb {
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

@@ -1,108 +1,121 @@
<template>
<div class="now-playing-top">
<router-link class="now-playling-from-link" :to="(data.location as RouteLocationRaw)" title="Go to Play Source">
<div class="from">
<img
v-if="tracklist.from.type === FromOptions.album || tracklist.from.type === FromOptions.artist"
:src="data.image + '.webp'"
:alt="`Now Playing ${tracklist.from.type} image`"
:class="`${tracklist.from.type === FromOptions.artist ? 'circular' : 'rounded-sm'}`"
/>
<div v-else class="from-icon border rounded-sm">
<component :is="data.icon"></component>
</div>
<div class="pad-sm">
<div class="type">{{ tracklist.from.type }}</div>
<div class="ellip2">{{ data.name }}</div>
</div>
</div>
</router-link>
<button class="options" @click="showContextMenu">
<MoreSvg />
</button>
</div>
<div class="now-playing-top">
<router-link class="now-playling-from-link" :to="(data.location as RouteLocationRaw)" title="Go to Play Source">
<div class="from">
<img
v-if="
tracklist.from.type === FromOptions.album ||
tracklist.from.type === FromOptions.artist ||
tracklist.from.type === FromOptions.mix
"
:src="data.image"
:class="`${tracklist.from.type === FromOptions.artist ? 'circular' : 'rounded-xsm'}`"
/>
<div v-else class="from-icon border rounded-sm">
<component :is="data.icon"></component>
</div>
<div class="pad-sm">
<div class="type">{{ tracklist.from.type }}</div>
<div class="ellip2">{{ data.name }}</div>
</div>
</div>
</router-link>
<button class="options" @click="showContextMenu">
<MoreSvg />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { RouteLocationRaw } from "vue-router";
import { computed, ref } from 'vue'
import { RouteLocationRaw } from 'vue-router'
import useTracklist from "@/stores/queue/tracklist";
import useTracklist from '@/stores/queue/tracklist'
import { FromOptions } from "@/enums";
import playingFrom from "@/utils/playingFrom";
import { FromOptions } from '@/enums'
import playingFrom from '@/utils/playingFrom'
import MoreSvg from "@/assets/icons/more.svg";
import { showQueueContextMenu } from "@/helpers/contextMenuHandler";
import MoreSvg from '@/assets/icons/more.svg'
import { showQueueContextMenu } from '@/helpers/contextMenuHandler'
const tracklist = useTracklist();
const context_showing = ref(false);
const tracklist = useTracklist()
const context_showing = ref(false)
const data = computed(() => {
const { name, location, icon, image } = playingFrom(tracklist.from);
return { name, location, icon, image };
});
const { name, location, icon, image } = playingFrom(tracklist.from)
return { name, location, icon, image }
})
function showContextMenu(e: MouseEvent) {
if (!tracklist.tracklist.length) return;
if (!tracklist.tracklist.length) return
showQueueContextMenu(e, context_showing);
showQueueContextMenu(e, context_showing)
}
</script>
<style lang="scss">
.now-playing-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 2rem 0 $small;
.options {
transform: rotate(90deg);
svg {
transform: scale(1.25);
@include largePhones {
margin: 0 1.5rem;
}
.options {
width: 2rem;
height: 2.5rem;
padding: 0;
svg {
transform: rotate(90deg) scale(1.25);
}
}
}
}
.now-playling-from-link {
display: block;
width: fit-content;
display: block;
width: fit-content;
}
.now-playling-from-link > .from {
display: flex;
align-items: center;
img {
width: 2.5rem;
aspect-ratio: 1;
object-fit: cover;
}
.from-icon {
padding: $smaller;
aspect-ratio: 1;
width: 2.5rem;
margin-right: 2px;
display: flex;
align-items: center;
justify-content: center;
background-color: $gray;
border: solid 1px $gray4;
}
.type {
text-transform: capitalize;
font-size: 0.8rem;
color: $gray1;
font-weight: 500;
}
img {
width: 3rem;
aspect-ratio: 1;
object-fit: cover;
}
.type + div {
font-weight: 500;
}
.from-icon {
padding: $smaller;
aspect-ratio: 1;
width: 2.5rem;
margin-right: 2px;
display: flex;
align-items: center;
justify-content: center;
background-color: $gray;
border: solid 1px $gray4;
svg {
width: 1.5rem;
color: rgb(202, 197, 197);
}
}
.type {
text-transform: capitalize;
font-size: 0.8rem;
color: $gray1;
font-weight: 500;
}
.type + div {
font-weight: 500;
}
}
</style>

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

@@ -1,23 +1,33 @@
<template>
<div class="p-after-header">
<div>All Tracks</div>
</div>
<div class="p-after-header">
<div>All Tracks</div>
</div>
</template>
<style lang="scss">
.isSmall .p-after-header {
padding-left: 0.5rem;
}
.p-after-header {
display: flex;
align-items: center;
height: 64px;
padding: 0 1rem;
margin-top: $small;
display: flex;
align-items: center;
height: 64px;
padding: 0 1rem;
margin-top: $small;
font-size: 14px;
font-weight: 500;
color: $gray1;
font-size: 14px;
font-weight: 500;
color: $gray1;
@media only screen and (max-width: 724px) {
padding-left: 0.5rem;
}
/* Somehow has to be replaced by above now
@include largePhones {
padding-left: 0.5rem;
}
*/
}
</style>

View File

@@ -1,20 +1,10 @@
<template>
<div class="last-updated">
<span
v-if="!isHeaderSmall"
class="status"
>Last updated {{ playlist.info._last_updated }}</span
>
<div
v-if="Number.isInteger(playlist.info.id)"
class="edit"
>
&#160;&#160;|&#160;&#160; <span @click="editPlaylist">Edit</span>&#160;&#160;
<span v-if="!isHeaderSmall" class="status">Last updated {{ playlist.info._last_updated }}</span>
<div v-if="Number.isInteger(playlist.info.id)" class="edit">
&#160;&#160;|&#160;&#160; <span @click="editPlaylist">Edit</span>&#160;&#160;
{{ Number.isInteger(playlist.info.id) ? ' | ' : '' }}
<DeleteSvg
class="edit"
@click="deletePlaylist"
/>
<DeleteSvg class="edit" @click="deletePlaylist" />
</div>
</div>
</template>
@@ -45,6 +35,7 @@ function deletePlaylist() {
right: 1rem;
padding: $smaller $small;
font-size: 0.9rem;
font-weight: 500;
border-radius: $smaller;
z-index: 12;
@@ -52,12 +43,15 @@ function deletePlaylist() {
align-items: center;
.edit {
cursor: pointer;
color: $brown;
display: flex;
align-items: center;
}
.edit > span {
cursor: pointer;
color: $brown;
}
svg {
transform: scale(0.75);
margin-bottom: -0.2rem;

View File

@@ -1,9 +1,13 @@
<template>
<router-link :to="{ name: 'PlaylistView', params: { pid: playlist.id } }" class="p-card rounded no-scroll">
<div v-if="!playlist.has_image && playlist.images.length" class="image-grid rounded-sm no-scroll">
<img v-for="(img, index) in playlist.images" :key="index" :src="paths.images.thumb.smallish + img['image']" />
<img v-for="(img, index) in playlist.images" :key="index" :src="paths.images.thumb.smallish + (img['image'] || img)" />
<PlayBtn :source="playSources.playlist" :playlist="playlist.id.toString()"/>
</div>
<div v-else class="image">
<img :src="imguri + playlist.thumb" class="rounded-sm" :class="{ border: !playlist.thumb }" />
<PlayBtn :source="playSources.playlist" :playlist="playlist.id.toString()"/>
</div>
<img v-else :src="imguri + playlist.thumb" class="rounded-sm" :class="{ border: !playlist.thumb }" />
<div class="overlay rounded">
<div v-if="playlist.help_text" class="rhelp playlist">
<span class="help">{{ playlist.help_text }}</span>
@@ -20,6 +24,8 @@
<script setup lang="ts">
import { paths } from "../../config";
import { Playlist } from "../../interfaces";
import { playSources } from '@/enums'
import PlayBtn from '../shared/PlayBtn.vue'
const imguri = paths.images.playlist;
defineProps<{
@@ -38,9 +44,14 @@ defineProps<{
height: max-content;
transition: background-color 0.2s ease-out;
.image {
position: relative;
}
.image-grid {
display: grid;
grid: repeat(2, 1fr) / repeat(2, 1fr);
position: relative;
}
&:hover {
@@ -48,6 +59,26 @@ defineProps<{
background-blend-mode: screen;
}
$btnwidth: 4rem;
.play-btn {
opacity: 0;
position: absolute;
width: 4rem;
bottom: $small;
left: calc(50% - ($btnwidth / 2));
transition: all 0.25s;
}
&:hover {
background-color: $gray4;
.play-btn {
opacity: 1;
transform: translateY(-0.75rem);
}
}
img {
width: 100%;
aspect-ratio: 1;

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

@@ -49,8 +49,13 @@ defineEmits<{
padding: 0 $small;
}
#tracks-results > .vue-recycle-scroller {
padding: unset;
}
.cardlistrow {
grid-template-columns: repeat(auto-fill, minmax(8.1rem, 1fr));
padding-bottom: 0;
}
}
@@ -59,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 {
@@ -69,6 +78,14 @@ defineEmits<{
border-color: $gray;
}
.designatedOS #tab-content .vue-recycle-scroller::-webkit-scrollbar-track {
background-color: $gray;
}
.designatedOS #tab-content .vue-recycle-scroller::-webkit-scrollbar-thumb {
border-color: $gray;
}
#right-tabs.tabContent {
grid-template-rows: min-content 1fr;
}

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

@@ -1,43 +1,39 @@
<template>
<div class="right-search-top-tracks">
<TrackItem
v-for="(track, index) in search.top_results.tracks"
:key="track.id"
:track="track"
:index="index"
:is-current="false"
:is-current-playing="false"
@play-this="handlePlay(track)"
/>
</div>
<div class="right-search-top-tracks">
<TrackItem
v-for="(track, index) in search.top_results.tracks"
:key="track.id"
:track="track"
:index="index"
:is-current="false"
:is-current-playing="false"
@play-this="handlePlay(track)"
/>
</div>
</template>
<script setup lang="ts">
import { Track } from "@/interfaces";
import { Track } from '@/interfaces'
import useQueueStore from "@/stores/queue";
import useTracklist from "@/stores/queue/tracklist";
import useSearchStore from "@/stores/search";
import useQueueStore from '@/stores/queue'
import useTracklist from '@/stores/queue/tracklist'
import useSearchStore from '@/stores/search'
import TrackItem from "@/components/shared/TrackItem.vue";
import TrackItem from '@/components/shared/TrackItem.vue'
const search = useSearchStore();
const queue = useQueueStore();
const tracklist = useTracklist();
const search = useSearchStore()
const queue = useQueueStore()
const tracklist = useTracklist()
function handlePlay(track: Track) {
queue.clearQueue();
tracklist.setFromSearch(search.query, [track]);
queue.play(0);
queue.clearQueue()
tracklist.setFromSearch(search.query, [track])
queue.play(0)
}
</script>
<style lang="scss">
.right-search-top-tracks {
margin-bottom: 2rem;
.track-item {
padding: $small;
}
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

@@ -139,6 +139,7 @@ function handleButton() {
border-radius: 3rem;
cursor: pointer;
flex-shrink: 0;
color: $white;
&:hover {
transition: all 0.2s ease;
@@ -171,11 +172,6 @@ function handleButton() {
font-weight: 600;
padding-right: $small;
}
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.clear_input {

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

@@ -48,10 +48,7 @@ async function submit(newValue: number) {
position: relative;
input {
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.875rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
width: 4rem !important;
border: none;

View File

@@ -0,0 +1,80 @@
<template>
<form class="secretinput" @submit.prevent="$emit('submit', input)">
<div class="left rounded-sm no-scroll">
<input v-model="input" :type="showText ? 'text' : 'password'" @input="() => (showTextManual = true)" />
<button @click.prevent="showTextManual = !showTextManual">
<EyeSvg v-if="showText" />
<EyeSlashSvg v-else />
</button>
</div>
<div class="right">
<button>Save</button>
</div>
</form>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import EyeSvg from '@/assets/icons/eye.svg'
import EyeSlashSvg from '@/assets/icons/eye.slash.svg'
const props = defineProps<{
text: string
}>()
const input = ref('')
const showTextManual = ref(false)
const showText = computed(() => {
if (showTextManual.value) return true
return input.value.length == 0
})
defineEmits<{
(e: 'submit', value: string): void
}>()
onMounted(() => {
if (props.text) {
input.value = props.text
}
})
</script>
<style lang="scss">
.secretinput {
display: grid;
grid-template-columns: 1fr max-content;
gap: 1rem;
width: 100%;
.left {
display: flex;
align-items: center;
gap: 1rem;
position: relative;
background-color: $gray5;
input {
height: 100%;
width: 100%;
border: none;
outline: none;
background: none;
padding: $small;
font-size: 12px;
font-family: 'SF Mono';
color: #ffffff00;
}
svg {
height: 1rem;
}
button {
background: none;
}
}
}
</style>

View File

@@ -84,6 +84,11 @@
component_key="streaming_quality"
/>
<BackupRestore v-if="setting.type === SettingType.backup" />
<SecretInput
v-if="setting.type === SettingType.secretinput"
:text="setting.state ? setting.state() : ''"
@submit="setting.action"
/>
</div>
</div>
</div>
@@ -96,18 +101,18 @@ import { SettingType } from '@/settings/enums'
import ReloadSvg from '@/assets/icons/reload.svg'
import List from './Components/List.vue'
import LockedNumberInput from './Components/LockedNumberInput.vue'
import NumberInput from './Components/NumberInput.vue'
import Select from './Components/Select.vue'
import SeparatorsInput from './Components/SeparatorsInput.vue'
import Switch from './Components/Switch.vue'
import NumberInput from './Components/NumberInput.vue'
import About from './About.vue'
import Profile from '../modals/settings/Profile.vue'
import Pairing from '../modals/settings/custom/Pairing.vue'
import Accounts from '../modals/settings/custom/Accounts.vue'
import Pairing from '../modals/settings/custom/Pairing.vue'
import DropDown from '../shared/DropDown.vue'
import settings from '@/settings'
import About from './About.vue'
import BackupRestore from './Components/BackupRestore.vue'
import SecretInput from './Components/SecretInput.vue'
defineProps<{
group: SettingGroup
@@ -190,6 +195,10 @@ defineProps<{
gap: $small;
width: 100%;
button {
padding-right: $medium;
}
button > svg {
transform: scale(0.65);
}

View File

@@ -1,20 +1,20 @@
<template>
<div class="chartgroup rounded" :class="group">
<ChartsHeader :name="group" @change-period="changePeriod" @change-group="changeGroup" :period="period" />
<div class="chartgroup rounded" :class="settings.statsgroup">
<ChartsHeader :name="settings.statsgroup" @change-period="changePeriod" @change-group="changeGroup" :period="settings.statsperiod" />
<br />
<div class="noitems rounded-sm" v-if="items.length === 0">
<div v-if="loading" class="loading">
<div class="spinner"></div>
<span>fetching data...</span>
</div>
<div v-if="!loading && loaded">No {{ group.slice(0, -1) }} data found for this period</div>
<div v-if="!loading && loaded">No {{ settings.statsgroup.slice(0, -1) }} data found for this period</div>
</div>
<ChartItem
v-for="(item, index) in items"
:key="index"
:item="item"
:index="index + 1"
:name="(group.slice(0, -1) as any)"
:name="(settings.statsgroup.slice(0, -1) as any)"
/>
<div class="scrobbleinfo rounded-sm">
<div class="date">
@@ -36,18 +36,19 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { getChartItem } from '@/requests/stats'
import { Artist, Album, Track } from '@/interfaces'
import useSettings from '@/stores/settings'
import ChartItem from './ChartItem.vue'
import ChartsHeader from './ChartsHeader.vue'
import ArrowSvg from '@/assets/icons/arrow.svg'
import CalendarSvg from '@/assets/icons/calendar.svg'
const settings = useSettings()
// Reactive variables
const loading = ref(true)
const loaded = ref(false)
const group = ref('artists')
const period = ref('week')
const items2: any = reactive({
tracks: <Track[]>[],
albums: <Album[]>[],
@@ -55,7 +56,7 @@ const items2: any = reactive({
})
const items = computed(() => {
return items2[group.value]
return items2[settings.statsgroup]
})
const scrobbleInfo = ref<{
@@ -66,7 +67,7 @@ const scrobbleInfo = ref<{
// Functions
async function getItems() {
items2[group.value] = []
items2[settings.statsgroup] = []
loaded.value = false
let isPending = true
@@ -78,8 +79,8 @@ async function getItems() {
}, 450)
try {
const res = await getChartItem(group.value, period.value, 10, 'playduration')
items2[group.value] = res.data[group.value]
const res = await getChartItem(settings.statsgroup, settings.statsperiod, 10, 'playduration')
items2[settings.statsgroup] = res.data[settings.statsgroup]
scrobbleInfo.value = res.data.scrobbles
loaded.value = true
} finally {
@@ -90,12 +91,12 @@ async function getItems() {
}
async function changePeriod(newPeriod: string) {
period.value = newPeriod
settings.setStatsPeriod(newPeriod)
await getItems()
}
async function changeGroup(newGroup: string) {
group.value = newGroup
settings.setStatsGroup(newGroup)
await getItems()
}

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 !== 'toptrack'" />
<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 === 'toptrack' && props.image"
>
<img class="staticon statimage shadow-sm" :src="paths.images.thumb.small + props.image" alt="" />
</router-link>
@@ -77,8 +77,15 @@ import StopWatchSvg from '@/assets/icons/timer.svg'
import HeadphoneSvg from '@/assets/icons/headphones.svg'
import FolderSvg from '@/assets/icons/folder.nopad.svg'
import Index1Svg from '@/assets/icons/index1.svg'
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
@@ -87,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':
@@ -101,13 +115,68 @@ const icon = computed(() => {
return Index1Svg
default:
return HeadphoneSvg
return SparklesSvg
}
})
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">
@@ -119,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;
@@ -182,7 +236,8 @@ const formattedValue = computed(() => {
}
}
.statitem.toptrack {
.statitem.toptrack,
.statitem.topalbum {
aspect-ratio: 1.5;
}
</style>

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)"
@@ -15,12 +15,13 @@
:value="statItems[statItems.length - 1].value"
:text="statItems[statItems.length - 1].text"
:icon="statItems[statItems.length - 1].cssclass"
:image="statItems[statItems.length - 1].image"
/>
</div>
</div>
<div class="statsdates">
<div v-if="date" class="statsdates">
<CalendarSvg />
{{ dates }}
{{ date }}
</div>
</template>
@@ -30,29 +31,43 @@ 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
image?: string
}
const statItems = ref<StatItem[]>([])
const dates = ref<string[]>([])
const props = defineProps<{
items?: StatsItem[]
}>()
const statItems = ref<StatsItem[]>([])
const date = ref<string | null>(null)
onMounted(async () => {
if (props.items) {
statItems.value = props.items
return
}
const res = await getStats()
if (res.status == 200) {
statItems.value = res.data.stats
dates.value = res.data.dates
date.value = res.data.dates
}
})
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss">
.statshead {
display: grid;
grid-template-columns: 1fr max-content;
overflow-x: auto;
gap: 1.5rem;
padding: 1rem;

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

@@ -238,10 +238,7 @@ onMounted(async () => {
align-items: center;
input {
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: 1rem;
font-weight: 500;
width: 100%;
height: 3rem;
padding: 1rem;
@@ -250,11 +247,6 @@ onMounted(async () => {
background-color: $gray5;
color: $white;
text-align: center;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.submit {

View File

@@ -1,32 +1,36 @@
<template>
<div class="confirm-modal">
<div class="t-center" style="padding: 0 4rem">{{ text }}</div>
<div class="buttons">
<button class="cancel" @click="cancelAction">Cancel</button>
<button class="confirm" @click="confirmAction">Delete</button>
<div class="confirm-modal">
<div class="t-center" style="padding: 0 4rem">{{ text }}</div>
<div class="buttons">
<button class="cancel" @click="cancelAction">Cancel</button>
<button class="confirm" @click="confirmAction">Delete</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
text: string;
confirmAction: () => void;
cancelAction: () => void;
}>();
text: string
confirmAction: () => void
cancelAction: () => void
}>()
</script>
<style lang="scss">
.confirm-modal {
.buttons {
margin-top: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.t-center {
font-weight: 500;
}
.confirm {
background: $red;
}
.buttons {
margin-top: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.confirm {
background: $red;
}
}
</style>

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 === 'Backup') {
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

@@ -3,60 +3,26 @@
<div class="profileavatar">
<Avatar :name="username || auth.user.username" />
<div class="name">
{{
adding_user
? username
: `Hi ${auth.user.username}`
}}
{{ adding_user ? username : `Hi ${auth.user.username}` }}
</div>
<div
class="roles"
v-if="!adding_user"
>
<span
class="role"
v-for="role in auth.user.roles"
:key="role"
>
{{ role }}</span
>
<div class="roles" v-if="!adding_user">
<span class="role" v-for="role in auth.user.roles" :key="role"> {{ role }}</span>
</div>
</div>
<form
class="updateprof"
v-auto-animate
@submit.prevent="handleSubmit"
>
<form class="updateprof" v-auto-animate @submit.prevent="handleSubmit">
<div class="names">
<label for="username">Username</label>
<Input
:placeholder="adding_user ? 'username' : auth.user.username"
@input="(input) => (username = input)"
@input="input => (username = input)"
/>
</div>
<label for="pswd"
>{{ adding_user ? 'Create' : 'Change' }} password</label
>
<Input
type="password"
placeholder="⏺⏺⏺⏺⏺⏺⏺⏺"
@input="(input) => (password = input)"
/>
<div
class="confirmpassword"
v-if="password.length"
>
<label for="pswd">{{ adding_user ? 'Create' : 'Change' }} password</label>
<Input type="password" placeholder="⏺⏺⏺⏺⏺⏺⏺⏺" @input="input => (password = input)" />
<div class="confirmpassword" v-if="password.length">
<label for="confirmpswd">Confirm password</label>
<Input
type="password"
placeholder="⏺⏺⏺⏺⏺⏺⏺⏺"
@input="(input) => (confirmPassword = input)"
/>
<label
class="error"
v-if="errorText"
>{{ errorText }}</label
>
<Input type="password" placeholder="⏺⏺⏺⏺⏺⏺⏺⏺" @input="input => (confirmPassword = input)" />
<label class="error" v-if="errorText">{{ errorText }}</label>
</div>
<button v-if="showSubmit">
{{ adding_user ? 'Add user' : 'Update' }}
@@ -89,19 +55,13 @@ const confirmPassword = ref('')
const showSubmit = computed(() => {
if (props.adding_user) {
return (
username.value.length &&
password.value.length &&
confirmPassword.value.length &&
!errorText.value
)
return username.value.length && password.value.length && confirmPassword.value.length && !errorText.value
}
// show submit button if:
// username has changed
// password has changed and is confirmed
return (
(!confirmPassword.value.length ||
(confirmPassword.value && !errorText.value)) &&
(!confirmPassword.value.length || (confirmPassword.value && !errorText.value)) &&
(payload.value.username || payload.value.password)
)
})
@@ -112,10 +72,7 @@ const errorText = computed(() => {
return ''
}
if (
confirmPassword.value.length &&
password.value !== confirmPassword.value
) {
if (confirmPassword.value.length && password.value !== confirmPassword.value) {
return 'Passwords do not match'
}
})
@@ -200,7 +157,8 @@ onMounted(async () => {
label {
margin-bottom: 0.5rem;
font-size: 14px;
font-weight: 500;
font-size: 0.9rem;
color: $gray1;
}

View File

@@ -15,7 +15,7 @@
<div class="h2">All users</div>
<button class="adduser" @click="showAddUser = true">
<PlusSvg />
new user
New user
</button>
</div>
<TransitionGroup name="list">
@@ -68,145 +68,145 @@
</template>
<script setup lang="ts">
import { User } from "@/interfaces";
import { getAllUsers } from "@/requests/auth";
import { updateConfig } from "@/requests/settings";
import { SettingType } from "@/settings/enums";
import { onMounted, ref } from "vue";
import { User } from '@/interfaces'
import { getAllUsers } from '@/requests/auth'
import { updateConfig } from '@/requests/settings'
import { SettingType } from '@/settings/enums'
import { onMounted, ref } from 'vue'
import useAuth from "@/stores/auth";
import { useToast } from "@/stores/notification";
import useAuth from '@/stores/auth'
import { useToast } from '@/stores/notification'
import DeleteSvg from "@/assets/icons/delete.svg";
import PlusSvg from "@/assets/icons/plus.svg";
import Avatar from "@/components/shared/Avatar.vue";
import Profile from "../Profile.vue";
import ToggleSetting from "./ToggleSetting.vue";
import DeleteSvg from '@/assets/icons/delete.svg'
import PlusSvg from '@/assets/icons/plus.svg'
import Avatar from '@/components/shared/Avatar.vue'
import Profile from '../Profile.vue'
import ToggleSetting from './ToggleSetting.vue'
const auth = useAuth();
const toast = useToast();
const auth = useAuth()
const toast = useToast()
const selectedUser = ref(0);
const users = ref(<User[]>[]);
const showAddUser = ref(false);
const selectedUser = ref(0)
const users = ref(<User[]>[])
const showAddUser = ref(false)
const settingsMap = {
enableGuest: ref(false),
usersOnLogin: ref(false),
} as { [key: string]: { value: boolean } };
} as { [key: string]: { value: boolean } }
const account_settings = [
{
title: "Enable guest access",
desc: "Allow users to access the site without an account",
title: 'Enable guest access',
desc: 'Allow users to access the site without an account',
type: SettingType.binary,
value: settingsMap.enableGuest,
action: async () => {
if (settingsMap.enableGuest.value) {
const success = await auth.deleteUser("guest");
const success = await auth.deleteUser('guest')
if (success) {
settingsMap.enableGuest.value = !settingsMap.enableGuest.value;
settingsMap.enableGuest.value = !settingsMap.enableGuest.value
}
return;
return
}
settingsMap.enableGuest.value = await auth.addGuestUser();
settingsMap.enableGuest.value = await auth.addGuestUser()
},
},
{
title: "Show users on login",
desc: "Show a list of users on your server when logging in",
title: 'Show users on login',
desc: 'Show a list of users on your server when logging in',
type: SettingType.binary,
value: settingsMap.usersOnLogin,
action: async () => {
const res = await updateConfig("usersOnLogin", !settingsMap.usersOnLogin.value);
const res = await updateConfig('usersOnLogin', !settingsMap.usersOnLogin.value)
if (res.status === 200) {
settingsMap.usersOnLogin.value = !settingsMap.usersOnLogin.value;
return;
settingsMap.usersOnLogin.value = !settingsMap.usersOnLogin.value
return
}
if (res.data.msg) {
return toast.showError(res.data.msg);
return toast.showError(res.data.msg)
}
toast.showGenericError();
toast.showGenericError()
},
},
];
]
const usettings = [
{
title: "Admin",
desc: "Can do anything",
title: 'Admin',
desc: 'Can do anything',
value: (roles: string[]) => {
return roles.includes("admin");
return roles.includes('admin')
},
action: async (user: User) => {
let initialRoles = [...user.roles];
let roles = [...user.roles];
let initialRoles = [...user.roles]
let roles = [...user.roles]
if (roles.includes("admin")) {
roles = roles.filter(r => r !== "admin");
if (roles.includes('admin')) {
roles = roles.filter(r => r !== 'admin')
} else {
roles.push("admin");
roles.push('admin')
}
const success = await auth.updateProfile({
id: user.id,
roles: roles,
});
})
if (success) {
user.roles = roles;
user.roles = roles
} else {
user.roles = initialRoles;
user.roles = initialRoles
}
},
},
];
]
async function deleteUser(user: User) {
if (user.username === auth.user.username) {
return toast.showError("Sorry! You cannot delete yourself");
return toast.showError('Sorry! You cannot delete yourself')
}
const success = await auth.deleteUser(user.username);
const success = await auth.deleteUser(user.username)
if (success) {
setTimeout(() => {
users.value = users.value.filter(u => u.id !== user.id);
}, 500);
users.value = users.value.filter(u => u.id !== user.id)
}, 500)
}
}
function userAdded(user: User) {
showAddUser.value = false;
showAddUser.value = false
setTimeout(() => {
// insert user after last admin
const lastAdmin = users.value.findIndex(u => u.roles.includes("admin"));
users.value.splice(lastAdmin + 1, 0, user);
}, 250);
const lastAdmin = users.value.findIndex(u => u.roles.includes('admin'))
users.value.splice(lastAdmin + 1, 0, user)
}, 250)
}
function selectUser(id: number) {
if (selectedUser.value === id) {
selectedUser.value = 0;
return;
selectedUser.value = 0
return
}
selectedUser.value = id;
selectedUser.value = id
}
onMounted(async () => {
const res = await getAllUsers(false);
const res = await getAllUsers(false)
if (res.users) {
// remove guest user from list
res.users = res.users.filter(u => u.username !== "guest");
users.value = res.users;
res.users = res.users.filter(u => u.username !== 'guest')
users.value = res.users
}
if (Object.keys(res.settings).length) {
@@ -217,11 +217,11 @@ onMounted(async () => {
for (const key in res.settings) {
if (settingsMap[key]) {
// @ts-expect-error
settingsMap[key].value = res.settings[key];
settingsMap[key].value = res.settings[key]
}
}
}
});
})
</script>
<style lang="scss">
@@ -268,6 +268,10 @@ onMounted(async () => {
justify-content: space-between;
align-items: center;
padding-right: $smaller;
> button.adduser {
padding-right: $medium;
}
}
.h2 {
@@ -324,7 +328,7 @@ onMounted(async () => {
margin-top: 1.75rem !important;
&::before {
content: "";
content: '';
position: absolute;
top: -1rem;
left: 45%;

View File

@@ -167,11 +167,6 @@ function update_playlist(e: Event) {
.playlist-modal {
#modal-playlist-name-input {
margin-bottom: 1rem;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.boxed {
@@ -247,6 +242,7 @@ function update_playlist(e: Event) {
svg {
transform: scale(1);
color: rgb(255, 255, 255);
transition: transform 0.2s ease-out;
}
&:hover {

View File

@@ -17,33 +17,35 @@ import ArrowSvg from '../../assets/icons/right-arrow.svg'
#back-forward {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
gap: $medium;
padding-right: 1rem;
border-right: 1px solid $gray5;
height: max-content;
& > * {
width: 2.25rem;
height: 2.25rem;
width: 2.15rem;
height: 2.15rem;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
border-radius: 5rem;
background-color: $gray5;
background-color: transparent;
border: 1px solid $gray5;
&:hover {
background-color: $gray4;
border-color: $gray4;
}
svg {
transform: scale(1.12);
transform: scale(0.96);
transition: transform 0.2s ease;
}
&:active {
transform: scale(0.88);
}
&:active > svg {
transform: scale(0.76);
}
}

View File

@@ -1,148 +1,149 @@
<template>
<div class="sidenav noSelect">
<div class="sidenav_header">
<a @click="closeSidenav" class="sidenav_logo" href="#">
<div class="art"><LogoSvg /></div>
<div class="title">Swing Music</div>
</a>
<div class="sidenav noSelect">
<div class="sidenav_header">
<a @click="closeSidenav" class="sidenav_logo" href="#">
<div class="art"><LogoSvg /></div>
<div class="title">Swing Music</div>
</a>
</div>
<div class="sidenav_content scrollable">
<RouterLink
v-for="link in topnavitems"
:key="link.name"
class="link"
:to="{ name: link.route_name, params: link.params }"
:class="{ active: $route.name === link.route_name }"
@click="closeSidenav"
>
<component :is="link.icon" />
<!-- Render the icon as a Vue component -->
<span>{{ link.name }}</span>
</RouterLink>
</div>
<div class="sidenav_footer">Swing Music - v</div>
</div>
<div class="sidenav_content scrollable">
<RouterLink
v-for="link in topnavitems"
:key="link.name"
class="link"
:to="{ name: link.route_name, params: link.params }"
:class="{ active: $route.name === link.route_name }"
@click="closeSidenav"
>
<component :is="link.icon" />
<!-- Render the icon as a Vue component -->
<span>{{ link.name }}</span>
</RouterLink>
</div>
<div class="sidenav_footer">Swing Music - v</div>
</div>
</template>
<script setup lang="ts">
import LogoSvg from "@/assets/icons/logos/logo-fill.light.svg";
import { topnavitems } from "../LeftSidebar/navitems";
import LogoSvg from '@/assets/icons/logos/logo-fill.light.svg'
import { topnavitems } from '../LeftSidebar/navitems'
const emit = defineEmits(["close"]);
const emit = defineEmits(['close'])
function closeSidenav() {
emit("close");
emit('close')
}
</script>
<style lang="scss">
.sidenav_toggle {
display: none;
display: none;
@include allPhones {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0;
gap: 6px;
width: 28px;
height: 28px;
cursor: pointer;
@include allPhones {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0;
gap: 6px;
width: 32px;
height: 32px;
padding: 2px;
cursor: pointer;
> .bar {
height: 2px;
width: calc(100% - 14px);
border-radius: 1rem;
background-color: $white;
opacity: 0.75;
transition: color 0.2s ease-out, transform 0.2s ease-out;
> .bar {
height: 2px;
width: calc(100% - 14px);
border-radius: 1rem;
background-color: $white;
opacity: 0.75;
transition: color 0.2s ease-out, transform 0.2s ease-out;
}
&:hover {
> .bar {
background-color: #ffffff;
}
}
}
&:hover {
> .bar {
background-color: #ffffff;
}
}
}
}
.sidenav {
display: none;
display: none;
@include allPhones {
position: fixed;
top: 0;
left: 0;
z-index: 1002;
width: 240px;
height: 100%;
display: flex;
flex-direction: column;
background-color: $body;
transform: translateX(-240px);
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
.sidenav_header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: $large 24px;
box-sizing: border-box;
.sidenav_logo {
@include allPhones {
position: fixed;
top: 0;
left: 0;
z-index: 1002;
width: 240px;
height: 100%;
display: flex;
align-items: center;
gap: 1rem;
flex-direction: column;
background-color: $body;
transform: translateX(-240px);
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
.title {
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
.sidenav_header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: $large 24px;
box-sizing: border-box;
.sidenav_logo {
display: flex;
align-items: center;
gap: 1rem;
.title {
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
}
}
}
.sidenav_content {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
margin-right: 2px;
overflow: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
.link {
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
text-transform: capitalize;
position: relative;
display: flex;
align-items: center;
gap: 1rem;
margin: $smaller $medium;
padding: $small $medium;
cursor: pointer;
}
svg {
height: 1.5rem;
}
}
.sidenav_footer {
font-size: $medium;
margin: $large auto;
opacity: 0.5;
}
}
}
.sidenav_content {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
margin-right: 2px;
overflow: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
.link {
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
text-transform: capitalize;
position: relative;
display: flex;
align-items: center;
gap: 1rem;
margin: $smaller $medium;
padding: $small $medium;
cursor: pointer;
}
svg {
height: 1.5rem;
}
}
.sidenav_footer {
font-size: $medium;
margin: $large auto;
opacity: 0.5;
}
}
}
.sidenav.active {
transform: translateX(0);
transform: translateX(0);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="profiledrop rounded-sm pad-sm shadow-lg">
<div class="profiledrop rounded-md pad-sm shadow-lg noSelect">
<div class="info item">
<div class="username ellip2">Hi {{ auth.user.firstname || auth.user.username }}</div>
</div>
@@ -36,10 +36,11 @@ const modal = useModal()
<style lang="scss">
.profiledrop {
position: absolute;
z-index: 10;
z-index: 9999;
top: 2.25rem;
right: 0;
width: 10rem;
width: 10.25rem;
font-size: 0.95rem;
font-weight: 400;
display: flex;
@@ -59,15 +60,22 @@ const modal = useModal()
justify-content: space-between;
gap: $smaller;
padding: $small $medium;
border-radius: 6px;
padding-right: $small;
max-height: 36px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease-out;
transition: background-color 0.2s ease-out, opacity 0.2s ease-out, box-shadow 0.2s ease-out;
&:hover {
background-color: $gray4;
}
&:active {
opacity: 0.3;
}
svg {
display: block;
height: 1.5rem;
}
}
@@ -92,28 +100,32 @@ const modal = useModal()
}
.info {
flex-direction: column;
align-items: baseline;
gap: $smallest;
gap: $small;
cursor: auto;
padding: 0.25rem 0.75rem;
padding: $smaller $medium;
&:hover {
background-color: transparent;
}
.username {
> .username {
font-weight: 500;
}
}
.info.item {
max-height: unset;
opacity: unset;
pointer-events: none;
}
.critical {
color: $red;
}
.critical:hover {
background-color: transparent;
outline: solid 1px;
box-shadow: 0 0 0 1px $red;
}
}
</style>

View File

@@ -43,13 +43,14 @@ function navigate(path: string) {
}
interface SortItem {
key: string;
title: string;
key: string
title: string
}
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' },
@@ -111,7 +112,7 @@ const current = computed(() => {
}
.fname {
background-color: $gray4;
background-color: $gray5;
border-radius: $small;
height: 2.188rem;
display: flex;

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
@@ -23,7 +25,7 @@
</div>
<div>
<div v-if="album.help_text" class="rhelp album">
<span class="help">{{ album.help_text }}</span>
<span class="help" :class="{ keep: !album.time }">{{ album.help_text }}</span>
<span class="time">{{ album.time }}</span>
</div>
<h4 v-tooltip class="title ellip">
@@ -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;
@@ -121,6 +133,7 @@ const artists = computed(() => {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.25s ease;
}
&:hover {
@@ -129,10 +142,6 @@ const artists = computed(() => {
opacity: 1;
}
img {
border-radius: 0 0 $medium $medium;
}
.gradient {
opacity: 1;
}

View File

@@ -1,114 +1,128 @@
<template>
<RouterLink
:to="{
name: Routes.artist,
params: {
hash: artist.artisthash,
},
}"
class="artist-card"
>
<div class="image circular">
<img class="artist-image circular" :src="imguri + artist.image" />
<div
class="overlay circular"
:style="{
background: `linear-gradient(to top, ${artist.color} 20%, transparent)`,
<RouterLink
:to="{
name: Routes.artist,
params: {
hash: artist.artisthash,
},
}"
></div>
<PlayBtn :artisthash="artist.artisthash" :artistname="artist.name" :source="playSources.artist" />
</div>
<div v-if="artist.help_text" class="rhelp t-center">
<span class="help">{{ artist.help_text }}</span>
<span class="time">{{ artist.time }}</span>
</div>
<div class="artist-name t-center">
{{ artist.name }}
</div>
<div v-if="artist.help_text && artist.trackcount" class="racount t-center">
{{ artist.trackcount }} Track{{ artist.trackcount == 1 ? "" : "s" }}
</div>
</RouterLink>
class="artist-card"
@contextmenu.prevent="showContextMenu"
:class="{ 'context-menu-open': contextMenuFlag }"
>
<div class="image circular">
<img class="artist-image circular" :src="imguri + artist.image" />
<div
class="overlay circular"
:style="{
background: `linear-gradient(to top, ${artist.color} 20%, transparent)`,
}"
></div>
<PlayBtn :artisthash="artist.artisthash" :artistname="artist.name" :source="playSources.artist" />
</div>
<div v-if="artist.help_text" class="rhelp t-center">
<span class="help" :class="{ keep: !artist.time }">{{ artist.help_text }}</span>
<span class="time">{{ artist.time }}</span>
</div>
<div class="artist-name t-center">
{{ artist.name }}
</div>
<div v-if="artist.help_text && artist.trackcount" class="racount t-center">
{{ artist.trackcount }} Track{{ artist.trackcount == 1 ? '' : 's' }}
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { paths } from "@/config";
import { Artist } from "@/interfaces";
import { Routes } from "@/router";
import { paths } from '@/config'
import { Artist } from '@/interfaces'
import { Routes } from '@/router'
import { playSources } from "@/enums";
import PlayBtn from "./PlayBtn.vue";
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 imguri = paths.images.artist.medium
const contextMenuFlag = ref(false)
defineProps<{
artist: Artist;
}>();
const props = defineProps<{
artist: Artist
}>()
const showContextMenu = (e: MouseEvent) => {
showArtistContextMenu(e, contextMenuFlag, props.artist.artisthash, props.artist.name)
}
</script>
<style lang="scss">
.artist-card {
overflow: hidden;
position: relative;
border-radius: $medium;
justify-content: center;
padding: 1.2rem 1rem !important;
font-size: 0.95rem;
font-weight: 700;
height: max-content;
transition: background-color 0.2s ease-out;
.image {
overflow: hidden;
position: relative;
.overlay {
position: absolute;
width: 100%;
height: calc(100% - $small + 1px);
top: 0;
opacity: 0;
border-radius: $medium;
justify-content: center;
padding: 1.2rem 1rem !important;
font-size: 0.95rem;
font-weight: 700;
height: max-content;
transition: background-color 0.2s ease-out;
&.context-menu-open {
background-color: $gray5;
}
}
$btnwidth: 4rem;
.image {
position: relative;
.play-btn {
opacity: 0;
position: absolute;
width: 4rem;
bottom: 0;
left: calc(50% - ($btnwidth / 2));
transition: all 0.25s;
}
.overlay {
position: absolute;
width: 100%;
height: calc(100% - $small + 1px);
top: 0;
opacity: 0;
transition: opacity 0.25s ease;
}
}
&:hover {
background-color: $gray5;
$btnwidth: 4rem;
.play-btn {
opacity: 1;
transform: translateY(-1.25rem);
opacity: 0;
position: absolute;
width: 4rem;
bottom: 0;
left: calc(50% - ($btnwidth / 2));
transition: all 0.25s;
}
.overlay {
opacity: 1;
&:hover {
background-color: $gray5;
.play-btn {
opacity: 1;
transform: translateY(-1.25rem);
}
.overlay {
opacity: 1;
}
}
}
.artist-image {
width: 100%;
transition: all 0.5s ease-in-out;
object-fit: cover;
margin-bottom: $smaller;
}
.artist-image {
width: 100%;
transition: all 0.5s ease-in-out;
object-fit: cover;
margin-bottom: $smaller;
}
.artist-name {
word-break: break-word;
}
.artist-name {
word-break: break-word;
}
.racount {
font-size: 12px;
color: #ffffffbf;
}
.racount {
font-size: 12px;
color: #ffffffbf;
}
}
</style>

View File

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

View File

@@ -1,32 +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 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 } from "@/interfaces";
import AlbumCard from "./AlbumCard.vue";
import ArtistCard from "./ArtistCard.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: "album" | "artist";
items: Album[] | Artist[];
}>();
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,18 +2,31 @@
<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">
<component
:is="getComponent(i.type)"
v-for="(i, index) in itemlist.slice(0, maxAbumCards)"
:key="index"
:key="i"
class="hlistitem"
v-bind="getProps(i)"
@playThis="() => $emit('playThis', index)"
@@ -35,6 +48,7 @@ import CardContent from './CardContent.vue'
import FavoritesCard from './FavoritesCard.vue'
import FolderCard from './FolderCard.vue'
import TrackCard from './TrackCard.vue'
import MixCard from '@/components/Mixes/MixCard.vue'
const props = defineProps<{
title: string
@@ -83,8 +97,10 @@ function getComponent(type: string) {
return FolderCard
case 'playlist':
return PlaylistCard
case 'favorite_tracks':
case 'favorite':
return FavoritesCard
case 'mix':
return MixCard
}
}
@@ -118,10 +134,14 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
return {
playlist: item.item,
}
case 'favorite_tracks':
case 'favorite':
return {
item: item.item,
}
case 'mix':
return {
mix: item.item,
}
}
}
</script>
@@ -158,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;
}
}
@@ -183,6 +206,10 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
display: none;
}
.keep {
display: block !important;
}
// INFO: Set the time to display block on hover
.rhelp .time {
display: block;

View File

@@ -4,11 +4,15 @@
<button
class="selected"
:class="{ showDropDown }"
:title="
reverse !== 'hide'
? `sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase()
: undefined
"
@click.prevent="handleOpener"
:title="reverse !== 'hide' ? `sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase() : undefined"
>
<span class="ellip">{{ current.title }}</span>
<ArrowSvg :class="{ reverse }" 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
@@ -68,6 +72,12 @@ onClickOutside(dropOptionsRef, e => {
<style lang="scss">
.smdropdown {
z-index: 1000;
.dropdown-arrow {
width: 100%;
aspect-ratio: 1;
}
.selected {
width: 100%;
display: grid;

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

@@ -1,13 +1,18 @@
<template>
<div class="generichead">
<div class="left">
<h1 class="title"><slot name="name"></slot></h1>
<div class="desc">
<slot name="description"></slot>
<div class="before">
<div class="left">
<h1 class="title"><slot name="name"></slot></h1>
<div class="desc">
<slot name="description"></slot>
</div>
</div>
<div class="right">
<slot name="right"></slot>
</div>
</div>
<div class="right">
<slot name="right"></slot>
<div class="after">
<slot name="after"></slot>
</div>
</div>
</template>
@@ -16,11 +21,25 @@
.generichead {
padding: 0 0 1rem $medium;
height: max-content;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
max-width: 100%;
overflow: hidden;
max-width: 100%;
.before {
display: grid;
grid-template-columns: 1fr max-content;
}
.right {
display: flex;
align-items: center;
height: 100%;
}
.after {
margin-top: 2rem;
margin-left: -$medium;
}
.left {
max-width: 100%;

View File

@@ -2,6 +2,7 @@
<button
v-wave
class="heart-button circular"
:class="{ favorited: state }"
:style="{
color: color ? getTextColor(color) : '',
}"
@@ -26,23 +27,23 @@
</template>
<script setup lang="ts">
import { Motion } from "motion/vue";
import { Motion } from 'motion/vue'
import HeartFillSvg from "@/assets/icons/heart.fill.svg";
import HeartSvg from "@/assets/icons/heart.svg";
import HeartFillSvg from '@/assets/icons/heart.fill.svg'
import HeartSvg from '@/assets/icons/heart.svg'
import { getTextColor } from "@/utils/colortools/shift";
import { getTextColor } from '@/utils/colortools/shift'
defineProps<{
state: Boolean | undefined;
no_emit?: Boolean;
color?: string;
}>();
state: Boolean | undefined
no_emit?: Boolean
color?: string
}>()
defineEmits<{
// eslint-disable-next-line no-unused-vars
(event: "handleFav"): void;
}>();
(event: 'handleFav'): void
}>()
</script>
<style lang="scss">
@@ -62,7 +63,8 @@ $bg: rgb(255, 255, 255);
transform: scale(1);
svg {
height: 1.5rem;
height: 1.75rem;
width: 1.75rem;
display: block;
}
}

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

@@ -13,7 +13,7 @@ import {
playFromFolderCard,
playFromPlaylist,
} from "@/helpers/usePlayFrom";
import { Track } from "@/interfaces";
import { Playlist, Track } from "@/interfaces";
import PlaySvg from "@/assets/icons/play.svg";
import useQueue from "@/stores/queue";
@@ -27,6 +27,7 @@ const props = defineProps<{
artisthash?: string;
artistname?: string;
folderpath?: string;
playlist?: string;
track?: Track;
}>();
@@ -61,6 +62,9 @@ function handlePlay() {
case playSources.favorite:
playFromFavorites(props.track);
break;
case playSources.playlist:
playFromPlaylist(props.playlist as string);
break;
default:
break;

View File

@@ -1,42 +1,43 @@
<template>
<button
v-wave
class="playbtnrect shadow-sm circular btn-active"
:style="{
backgroundColor: bg_color ? bg_color : '',
borderColor: bg_color ? bg_color : '',
color: bg_color ? getShift(bg_color, [100, 100]) : '',
}"
@click="playFrom(source)"
>
<playBtnSvg />
<div class="text">Play</div>
</button>
<button
v-wave
class="playbtnrect shadow-sm circular btn-active"
:style="{
backgroundColor: bg_color ? bg_color : '',
borderColor: bg_color ? bg_color : '',
color: bg_color ? getShift(bg_color, [100, 100]) : '',
}"
@click="playFrom(source)"
>
<playBtnSvg />
<div class="text">Play</div>
</button>
</template>
<script setup lang="ts">
import { playSources } from "@/enums";
import { getShift } from "@/utils/colortools/shift";
import { playSources } from '@/enums'
import { getShift } from '@/utils/colortools/shift'
import { playFrom } from "@/helpers/usePlayFrom";
import playBtnSvg from "@/assets/icons/play.svg";
import playBtnSvg from '@/assets/icons/play.svg'
import { playFrom } from '@/helpers/usePlayFrom'
defineProps<{
source: playSources;
bg_color?: string;
}>();
source: playSources
bg_color?: string
}>()
</script>
<style lang="scss">
.playbtnrect {
width: 6rem;
display: flex;
align-items: center;
justify-content: center;
color: $white;
width: 6rem;
display: flex;
align-items: center;
justify-content: center;
color: $white;
padding-right: 1rem;
svg {
height: 1.75rem;
}
svg {
height: 1.75rem;
}
}
</style>

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