Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2d9537ff | ||
|
|
6f4a59f971 | ||
|
|
7b21853f97 | ||
|
|
663dbd2a7c | ||
|
|
c7a0b5ab7e | ||
|
|
ad8eeb7a2a | ||
|
|
e799c96872 | ||
|
|
234aed54d7 | ||
|
|
574d7fd5e7 | ||
|
|
4a1106d784 | ||
|
|
d9f7e5fb14 | ||
|
|
571c4a5264 | ||
|
|
e71bc7164c | ||
|
|
77f18ac640 | ||
|
|
78d57a64b9 | ||
|
|
ff502521e8 | ||
|
|
7caa70b9d6 | ||
|
|
cc3b372090 | ||
|
|
c297f75132 | ||
|
|
7c954ef805 | ||
|
|
9222e94b6c | ||
|
|
54c165b64a | ||
|
|
591509ebaf | ||
|
|
80a0bdbbf1 | ||
|
|
2e27da3f9f | ||
|
|
74bf8f5d78 | ||
|
|
bfdefc37fd | ||
|
|
44a877b9c9 | ||
|
|
db93fd554e | ||
|
|
40a7ad084c | ||
|
|
e44aa01d63 | ||
|
|
192e705890 | ||
|
|
50f92b65ab | ||
|
|
a5aea45cd6 | ||
|
|
56b1ab35d3 | ||
|
|
cc93fe7419 | ||
|
|
56d1c9da90 | ||
|
|
da611f5e8e | ||
|
|
b9e767b3c3 | ||
|
|
1eaf18ae75 | ||
|
|
e420dc3aac | ||
|
|
9b938194a6 | ||
|
|
54ab071803 | ||
|
|
b3484b08dd | ||
|
|
96178c462f | ||
|
|
2c4dad299b | ||
|
|
0fcbe03bab | ||
|
|
6775b7abaf | ||
|
|
275877a258 | ||
|
|
a2772b3945 | ||
|
|
ea48380699 | ||
|
|
818c37a6be | ||
|
|
a711007e66 | ||
|
|
4165e13aaa | ||
|
|
de353bf534 | ||
|
|
c2a3fe5725 | ||
|
|
32d06850e4 | ||
|
|
d9b14c0bf7 | ||
|
|
b18b411005 | ||
|
|
79ba8b0f6d | ||
|
|
67ca114f7c | ||
|
|
43c6638f40 | ||
|
|
0d0d519213 | ||
|
|
ab7075726d | ||
|
|
f4117a452f | ||
|
|
00f6181cbd | ||
|
|
ed847077ee | ||
|
|
72915c8367 | ||
|
|
866c67a154 | ||
|
|
387c60165c | ||
|
|
35aca59508 | ||
|
|
57bd7c151f |
2
TODO.md
@@ -4,7 +4,6 @@
|
||||
- Check out the mobile sidebar and navbar
|
||||
- Remove old settings page files
|
||||
- Fix: track loading indicator in bottom bar
|
||||
|
||||
- Unfuck javascript controlled responsiveness
|
||||
|
||||
- Redesign the album page header for mobile
|
||||
@@ -14,7 +13,6 @@
|
||||
- Add trailing slash to folder url accessed from the breadcrumb
|
||||
- Clip the browseable items on the homepage
|
||||
- Fix: The responsiveness glitch between 900px - 964px 😅
|
||||
- Fix: Queue repeat
|
||||
- Make All Albums/Artists view sort banner sticky
|
||||
|
||||
# DONE ✅
|
||||
|
||||
@@ -24,7 +24,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 +45,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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
3
src/assets/icons/bookmark.fill.svg
Normal 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 |
3
src/assets/icons/bookmark.svg
Normal 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 |
4
src/assets/icons/explicit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.84421 21.8972H18.0295C20.5685 21.8972 21.8737 20.5919 21.8737 18.0914V3.82921C21.8737 1.32656 20.5685 0.0234375 18.0295 0.0234375H3.84421C1.31484 0.0234375 0 1.31695 0 3.82921V18.0914C0 20.6016 1.31484 21.8972 3.84421 21.8972Z" fill="#aeaeaf"/>
|
||||
<path d="M8.24921 16.3608C7.44976 16.3608 7.04688 15.8618 7.04688 15.0368V6.67026C7.04688 5.84948 7.45187 5.34839 8.24921 5.34839H13.795C14.3777 5.34839 14.7607 5.68026 14.7607 6.26643C14.7607 6.8376 14.3777 7.19619 13.795 7.19619H9.33695V9.92808H13.5377C14.0824 9.92808 14.4464 10.2356 14.4464 10.7923C14.4464 11.3222 14.0824 11.6255 13.5377 11.6255H9.33695V14.513H13.795C14.3777 14.513 14.7607 14.8545 14.7607 15.4406C14.7607 16.0118 14.3777 16.3608 13.795 16.3608H8.24921Z" fill="#111111"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 831 B |
@@ -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 |
11
src/assets/icons/lastfm.svg
Normal 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 |
4
src/assets/icons/pencil.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.42607 18.5857L16.593 5.42412L14.344 3.16546L1.1674 16.3366L0.0267015 19.0816C-0.10197 19.4303 0.258496 19.8049 0.592479 19.6708L3.42607 18.5857ZM17.715 4.32139L18.9829 3.07476C19.6122 2.44546 19.6378 1.7482 19.0703 1.16906L18.6128 0.709452C18.0454 0.139922 17.3439 0.200625 16.7125 0.808593L15.4467 2.06273L17.715 4.32139Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -1,4 +1,4 @@
|
||||
<svg width="28" height="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 |
3
src/assets/icons/sparkles.svg
Normal 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 |
@@ -1,272 +1,278 @@
|
||||
$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;
|
||||
}
|
||||
|
||||
#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));
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,10 @@ button {
|
||||
&.playlist {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&.mix {
|
||||
color: $lightbrown;
|
||||
}
|
||||
}
|
||||
|
||||
// Badges used in settings
|
||||
@@ -263,4 +267,9 @@ button {
|
||||
.badge.new {
|
||||
background-color: $blue;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.explicit-icon {
|
||||
width: 0.9rem;
|
||||
margin-left: $smaller;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: #000;
|
||||
|
||||
$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;
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ defineEmits<{
|
||||
}
|
||||
}
|
||||
|
||||
@include largePhones {
|
||||
@media only screen and (max-width: 724px) {
|
||||
padding-left: 0.5rem !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,6 @@ const update = async () => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("mounted");
|
||||
props.fetch_callback().then(update);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,178 +1,187 @@
|
||||
<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="" />
|
||||
<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>
|
||||
<ExplicitIcon class="explicit-icon" v-if="queue.currenttrack?.explicit" />
|
||||
<MasterFlag :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 useColorStore from '@/stores/colors'
|
||||
import { isLargerMobile, isMobile } from '@/stores/content-width'
|
||||
import useQStore from '@/stores/queue'
|
||||
import useSettingsStore from '@/stores/settings'
|
||||
|
||||
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 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 ExplicitIcon from '@/assets/icons/explicit.svg'
|
||||
|
||||
const queue = useQStore();
|
||||
const settings = useSettingsStore();
|
||||
const colors = useColorStore();
|
||||
const queue = useQStore()
|
||||
const settings = useSettingsStore()
|
||||
const colors = useColorStore()
|
||||
|
||||
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;
|
||||
.np-image {
|
||||
position: relative;
|
||||
height: 3rem;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
img {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.expandicon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(51, 51, 51, 0.575);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.heart-button {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
border: solid 1px $gray4;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.expandicon {
|
||||
transform: translateY(-$medium);
|
||||
height: 130%;
|
||||
}
|
||||
}
|
||||
.track-info {
|
||||
.title {
|
||||
color: $white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@include largePhones {
|
||||
flex-shrink: 0;
|
||||
margin-right: $medium;
|
||||
}
|
||||
.artistname {
|
||||
opacity: 0.75;
|
||||
|
||||
@include smallerPhones {
|
||||
margin-right: $small;
|
||||
}
|
||||
}
|
||||
a {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.heart-button {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
border: solid 1px $gray4;
|
||||
padding: 0;
|
||||
}
|
||||
@include allPhones {
|
||||
width: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.track-info {
|
||||
.title {
|
||||
color: $white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
.heart-button {
|
||||
height: max-content;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@include largePhones {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
max-width: calc(100% - 8px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,91 +1,95 @@
|
||||
<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 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;
|
||||
.lyrics,
|
||||
.repeat {
|
||||
svg {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
|
||||
&:active > svg {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heart-button {
|
||||
border: solid 1px $gray4 !important;
|
||||
}
|
||||
button.repeat.repeat-disabled {
|
||||
svg {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.heart-button {
|
||||
border: solid 1px $gray4 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
src/components/Mixes/MixCard.vue
Normal 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>
|
||||
186
src/components/Mixes/MixImage.vue
Normal 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>
|
||||
122
src/components/Mixes/MixesHeader.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,176 +1,186 @@
|
||||
<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="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="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>
|
||||
<Buttons :hide-heart="true" @handleFav="() => {}" />
|
||||
<div class="time">
|
||||
{{ formatSeconds(queue.duration.full) }}
|
||||
</div>
|
||||
</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>
|
||||
<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'
|
||||
|
||||
const queue = useQueueStore();
|
||||
const queue = useQueueStore()
|
||||
|
||||
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;
|
||||
.nowplaying_title {
|
||||
padding-left: 1rem;
|
||||
margin: 1.25rem 0;
|
||||
|
||||
&:last-child {
|
||||
padding-top: $large;
|
||||
margin: 1rem 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 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
.centered {
|
||||
margin: 0 auto;
|
||||
width: 26rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
height: 0.8rem;
|
||||
.np-image {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 30rem;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,108 +1,116 @@
|
||||
<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-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>
|
||||
</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 tracklist = useTracklist()
|
||||
|
||||
const context_showing = ref(false);
|
||||
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-bottom: 1rem;
|
||||
|
||||
.options {
|
||||
transform: rotate(90deg);
|
||||
.options {
|
||||
transform: rotate(90deg);
|
||||
|
||||
svg {
|
||||
transform: scale(1.25);
|
||||
svg {
|
||||
transform: 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: 2.5rem;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
  |   <span @click="editPlaylist">Edit</span>  
|
||||
<span v-if="!isHeaderSmall" class="status">Last updated {{ playlist.info._last_updated }}</span>
|
||||
<div v-if="Number.isInteger(playlist.info.id)" class="edit">
|
||||
  |   <span @click="editPlaylist">Edit</span>  
|
||||
{{ 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,6 +49,10 @@ defineEmits<{
|
||||
padding: 0 $small;
|
||||
}
|
||||
|
||||
#tracks-results > .vue-recycle-scroller {
|
||||
padding: unset;
|
||||
}
|
||||
|
||||
.cardlistrow {
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.1rem, 1fr));
|
||||
}
|
||||
@@ -69,6 +73,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
<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;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.track-item {
|
||||
padding: $small;
|
||||
}
|
||||
.track-item {
|
||||
padding: $small 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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;
|
||||
|
||||
80
src/components/SettingsView/Components/SecretInput.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<form class="secretinput" @submit.prevent="$emit('submit', input)">
|
||||
<div class="left rounded-sm no-scroll">
|
||||
<input :type="showText ? 'text' : 'password'" v-model="input" @input="() => (showTextManual = true)" />
|
||||
<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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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', ''),
|
||||
},
|
||||
}"
|
||||
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>
|
||||
|
||||
@@ -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 class="statsdates" v-if="date">
|
||||
<CalendarSvg />
|
||||
{{ dates }}
|
||||
{{ date }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -37,22 +38,36 @@ interface StatItem {
|
||||
image?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items?: StatItem[]
|
||||
}>()
|
||||
|
||||
const statItems = ref<StatItem[]>([])
|
||||
const dates = ref<string[]>([])
|
||||
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;
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
@hideModal="hideModal"
|
||||
@setTitle="setTitle"
|
||||
/>
|
||||
<CrudPage
|
||||
v-if="modal.component == modal.options.page"
|
||||
@hideModal="hideModal"
|
||||
@setTitle="setTitle"
|
||||
v-bind="modal.props"
|
||||
/>
|
||||
<UpdatePlaylist
|
||||
v-if="modal.component == modal.options.updatePlaylist"
|
||||
v-bind="modal.props"
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
82
src/components/modals/CrudPage.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<form action="" v-if="delete">
|
||||
<br>
|
||||
<div>Are you sure you want to delete this collection?</div>
|
||||
<br />
|
||||
<button @click.prevent="submit" class="critical">Yes, Delete</button>
|
||||
</form>
|
||||
<form class="playlist-modal" @submit.prevent="submit" v-else>
|
||||
<label for="name">Collection name</label>
|
||||
<br />
|
||||
<input type="search" class="rounded-sm" id="name" :value="collection?.name" />
|
||||
<br />
|
||||
<label for="description">Description</label>
|
||||
<br />
|
||||
<input type="search" class="rounded-sm" id="description" :value="collection?.extra.description" />
|
||||
<br /><br />
|
||||
<button type="submit">{{ collection ? 'Update' : 'Create' }}</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Collection } from '@/interfaces'
|
||||
import { createNewCollection, deleteCollection, updateCollection } from '@/requests/collections'
|
||||
import { router } from '@/router'
|
||||
import { NotifType, Notification } from '@/stores/notification'
|
||||
|
||||
const props = defineProps<{
|
||||
collection?: Collection
|
||||
hash?: string
|
||||
type?: string
|
||||
extra?: any
|
||||
delete?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'hideModal'): void
|
||||
(e: 'setTitle', title: string): void
|
||||
}>()
|
||||
|
||||
emit('setTitle', (props.collection ? (props.delete ? 'Delete' : 'Update') : 'New') + ' Collection')
|
||||
|
||||
async function submit(e: Event) {
|
||||
if (props.delete && props.collection) {
|
||||
const deleted = await deleteCollection(props.collection.id)
|
||||
if (deleted) {
|
||||
new Notification('Collection deleted', NotifType.Success)
|
||||
emit('hideModal')
|
||||
router.push('/')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
const name = (e.target as any).elements['name'].value
|
||||
const description = (e.target as any).elements['description'].value
|
||||
|
||||
// If the page is null, we are creating a new page
|
||||
if (props.collection == null) {
|
||||
const created = await createNewCollection(name, description, [
|
||||
{
|
||||
hash: props.hash as string,
|
||||
type: props.type as string,
|
||||
extra: props.extra,
|
||||
},
|
||||
])
|
||||
|
||||
if (created) {
|
||||
new Notification('New collection created', NotifType.Success)
|
||||
emit('hideModal')
|
||||
}
|
||||
} else {
|
||||
const updatedPage = await updateCollection(props.collection, name, description)
|
||||
|
||||
if (updatedPage) {
|
||||
props.collection.name = updatedPage.name
|
||||
props.collection.extra.description = updatedPage.extra.description
|
||||
new Notification('Collection updated', NotifType.Success)
|
||||
emit('hideModal')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -59,7 +59,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -43,8 +43,8 @@ function navigate(path: string) {
|
||||
}
|
||||
|
||||
interface SortItem {
|
||||
key: string;
|
||||
title: string;
|
||||
key: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const items: SortItem[] = [
|
||||
@@ -111,7 +111,7 @@ const current = computed(() => {
|
||||
}
|
||||
|
||||
.fname {
|
||||
background-color: $gray4;
|
||||
background-color: $gray5;
|
||||
border-radius: $small;
|
||||
height: 2.188rem;
|
||||
display: flex;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 v-for="item in items" :key="item.key" :is="item.component" v-bind="item.props" />
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
class="selected"
|
||||
:class="{ showDropDown }"
|
||||
@click.prevent="handleOpener"
|
||||
:title="reverse !== 'hide' ? `sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase() : undefined"
|
||||
: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 :class="{ reverse }" class="dropdown-arrow" v-if="reverse !== 'hide'" />
|
||||
</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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -26,23 +26,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 +62,8 @@ $bg: rgb(255, 255, 255);
|
||||
transform: scale(1);
|
||||
|
||||
svg {
|
||||
height: 1.5rem;
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
<div
|
||||
class="songlist-item rounded-sm"
|
||||
:class="[{ current: isCurrent() }, { contexton: context_menu_showing }]"
|
||||
@dblclick.prevent="emitUpdate"
|
||||
@dblclick="emitUpdate"
|
||||
@contextmenu.prevent="showMenu"
|
||||
>
|
||||
<TrackIndex v-if="!isSmall" :index="index" :is_fav="is_fav" @add-to-fav="addToFav(track.trackhash)" />
|
||||
<TrackIndex
|
||||
v-if="!isSmall"
|
||||
:index="index"
|
||||
:is_fav="is_fav"
|
||||
:show-inline-fav-icon="settings.showInlineFavIcon"
|
||||
@add-to-fav="addToFav(track.trackhash)"
|
||||
/>
|
||||
|
||||
<TrackTitle
|
||||
:track="track"
|
||||
:is_current="isCurrent()"
|
||||
@@ -21,12 +28,22 @@
|
||||
:albumhash="track.albumhash || ''"
|
||||
:hide_album="hide_album || false"
|
||||
/>
|
||||
<TrackDuration :duration="track.duration || 0" @showMenu="showMenu" :help_text="track.help_text" />
|
||||
<TrackDuration
|
||||
:duration="track.duration || 0"
|
||||
:help_text="track.help_text"
|
||||
:is_fav="is_fav"
|
||||
:showFavIcon="!isFavoritesPage"
|
||||
:showInlineFavIcon="settings.showInlineFavIcon"
|
||||
:highlightFavoriteTracks="settings.highlightFavoriteTracks"
|
||||
@showMenu="showMenu"
|
||||
@toggleFav="addToFav(track.trackhash)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { dropSources, favType } from '@/enums'
|
||||
import { showTrackContextMenu as showContext } from '@/helpers/contextMenuHandler'
|
||||
@@ -40,7 +57,9 @@ import TrackAlbum from './SongItem/TrackAlbum.vue'
|
||||
import TrackDuration from './SongItem/TrackDuration.vue'
|
||||
import TrackIndex from './SongItem/TrackIndex.vue'
|
||||
import TrackTitle from './SongItem/TrackTitle.vue'
|
||||
import useSettings from '@/stores/settings'
|
||||
|
||||
const settings = useSettings()
|
||||
const context_menu_showing = ref(false)
|
||||
|
||||
const queue = useQueueStore()
|
||||
@@ -102,6 +121,9 @@ const stopWatcher = watch(
|
||||
onBeforeUnmount(() => {
|
||||
stopWatcher()
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isFavoritesPage = route.path.startsWith('/favorites')
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -121,9 +143,9 @@ onBeforeUnmount(() => {
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray5;
|
||||
background-color: $gray;
|
||||
|
||||
.index {
|
||||
.index.ready {
|
||||
.text {
|
||||
transition-delay: 400ms;
|
||||
|
||||
@@ -147,6 +169,10 @@ onBeforeUnmount(() => {
|
||||
.song-duration.help-text {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.options-and-duration .heart-icon.showInlineFavIcon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.index {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<div class="options-and-duration">
|
||||
<div
|
||||
v-if="showInlineFavIcon"
|
||||
class="heart-icon"
|
||||
:class="{ showInlineFavIcon, 'is_fav': is_fav && highlightFavoriteTracks }"
|
||||
@click.stop="$emit('toggleFav')"
|
||||
>
|
||||
<HeartSvg :state="is_fav" :no_emit="true" />
|
||||
</div>
|
||||
<div class="song-duration" :class="{ has_help_text: help_text }">{{ formatSeconds(duration) }}</div>
|
||||
<div class="song-duration help-text" v-if="help_text">
|
||||
{{ help_text }}
|
||||
@@ -13,14 +21,20 @@
|
||||
<script setup lang="ts">
|
||||
import OptionSvg from '@/assets/icons/more.svg'
|
||||
import { formatSeconds } from '@/utils'
|
||||
import HeartSvg from '../HeartSvg.vue'
|
||||
|
||||
defineProps<{
|
||||
duration: number
|
||||
is_fav: boolean
|
||||
showInlineFavIcon: boolean
|
||||
highlightFavoriteTracks: boolean
|
||||
showFavIcon?: boolean
|
||||
help_text?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'showMenu', event: MouseEvent): void
|
||||
(e: 'toggleFav'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -33,8 +47,36 @@ defineEmits<{
|
||||
margin-right: $small;
|
||||
position: relative;
|
||||
|
||||
@include allPhones {
|
||||
gap: $small;
|
||||
@include mediumPhones {
|
||||
> .heart-icon.is-favorited {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
display: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s ease-out;
|
||||
transform: scale(0.8);
|
||||
margin-right: $small;
|
||||
|
||||
svg {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
@include mediumPhones {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .heart-button {
|
||||
all: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.heart-icon.is_fav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
@@ -46,7 +88,7 @@ defineEmits<{
|
||||
display: none;
|
||||
}
|
||||
|
||||
transition: opacity 0.1s ease-out;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.song-duration.help-text {
|
||||
@@ -57,7 +99,11 @@ defineEmits<{
|
||||
text-transform: uppercase;
|
||||
color: $orange;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease-out;
|
||||
transition: opacity 0.2s ease-out;
|
||||
|
||||
@include allPhones {
|
||||
right: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.options-icon {
|
||||
@@ -67,6 +113,7 @@ defineEmits<{
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1;
|
||||
width: 2rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
svg {
|
||||
@@ -78,4 +125,8 @@ defineEmits<{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.songlist-item:hover > .options-and-duration > .heart-icon.is-favorited {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
class="index t-center ellip"
|
||||
@click.prevent="$emit('addToFav')"
|
||||
@dblclick.prevent.stop="() => {}"
|
||||
:class="{ 'ready': !showInlineFavIcon }"
|
||||
>
|
||||
<div class="text">
|
||||
{{ index }}
|
||||
</div>
|
||||
<div class="heart-icon">
|
||||
<div class="heart-icon" v-if="!showInlineFavIcon">
|
||||
<HeartSvg :state="is_fav" :no_emit="true" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,6 +20,7 @@ import HeartSvg from "../HeartSvg.vue";
|
||||
defineProps<{
|
||||
index: number | string;
|
||||
is_fav: boolean | undefined;
|
||||
showInlineFavIcon: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
@@ -53,7 +55,6 @@ defineEmits<{
|
||||
transition: all 0.2s;
|
||||
transform: translateX(-1.5rem);
|
||||
|
||||
|
||||
button {
|
||||
border: none;
|
||||
width: 2rem;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<span class="title ellip">
|
||||
{{ track.title }}
|
||||
</span>
|
||||
<ExplicitIcon class="explicit-icon" v-if="track.explicit" />
|
||||
<MasterFlag :bitrate="track.bitrate" />
|
||||
</div>
|
||||
<div class="isSmallArtists">
|
||||
@@ -40,6 +41,7 @@ const imguri = paths.images.thumb.small;
|
||||
|
||||
import ArtistName from "../ArtistName.vue";
|
||||
import MasterFlag from "../MasterFlag.vue";
|
||||
import ExplicitIcon from "@/assets/icons/explicit.svg";
|
||||
|
||||
import { paths } from "@/config";
|
||||
|
||||
@@ -59,6 +61,10 @@ defineEmits<{
|
||||
position: relative;
|
||||
align-items: center;
|
||||
|
||||
.explicit-icon {
|
||||
margin-left: $small;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
margin-right: $medium;
|
||||
display: flex;
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const development = import.meta.env.DEV
|
||||
|
||||
export function getBaseUrl() {
|
||||
const base_url = window.location.origin
|
||||
|
||||
if (!development) {
|
||||
return base_url
|
||||
return ''
|
||||
}
|
||||
|
||||
const base_url = window.location.origin
|
||||
const splits = base_url.split(':')
|
||||
return base_url.replace(splits[splits.length - 1], '1980')
|
||||
}
|
||||
|
||||
const base_url = getBaseUrl()
|
||||
axios.defaults.baseURL = base_url
|
||||
|
||||
const baseImgUrl = base_url + '/img'
|
||||
|
||||
const imageRoutes = {
|
||||
@@ -31,7 +34,7 @@ const imageRoutes = {
|
||||
|
||||
export const paths = {
|
||||
api: {
|
||||
favorites: base_url + '/favorites',
|
||||
favorites: '/favorites',
|
||||
get favAlbums() {
|
||||
return this.favorites + '/albums'
|
||||
},
|
||||
@@ -50,12 +53,15 @@ export const paths = {
|
||||
get removeFavorite() {
|
||||
return this.favorites + '/remove'
|
||||
},
|
||||
artist: base_url + '/artist',
|
||||
lyrics: base_url + '/lyrics',
|
||||
plugins: base_url + '/plugins',
|
||||
artist: '/artist',
|
||||
lyrics: '/lyrics',
|
||||
plugins: '/plugins',
|
||||
get mixes() {
|
||||
return this.plugins + '/mixes'
|
||||
},
|
||||
|
||||
// Single album
|
||||
album: base_url + '/album',
|
||||
album: '/album',
|
||||
get albumartists() {
|
||||
return this.album + '/artists'
|
||||
},
|
||||
@@ -69,12 +75,12 @@ export const paths = {
|
||||
return this.album + '/other-versions'
|
||||
},
|
||||
folder: {
|
||||
base: base_url + '/folder',
|
||||
showInFiles: base_url + '/folder/show-in-files',
|
||||
base: '/folder',
|
||||
showInFiles: '/folder/show-in-files',
|
||||
},
|
||||
dir_browser: base_url + '/folder/dir-browser',
|
||||
dir_browser: '/folder/dir-browser',
|
||||
playlist: {
|
||||
base: base_url + '/playlists',
|
||||
base: '/playlists',
|
||||
get new() {
|
||||
return this.base + '/new'
|
||||
},
|
||||
@@ -82,8 +88,11 @@ export const paths = {
|
||||
return this.base + '/artists'
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
base: '/collections',
|
||||
},
|
||||
search: {
|
||||
base: base_url + '/search',
|
||||
base: '/search',
|
||||
get top() {
|
||||
return this.base + '/top?q='
|
||||
},
|
||||
@@ -101,13 +110,13 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
base: base_url + '/logger',
|
||||
base: '/logger',
|
||||
get logTrack() {
|
||||
return this.base + '/track/log'
|
||||
},
|
||||
},
|
||||
getall: {
|
||||
base: base_url + '/getall',
|
||||
base: '/getall',
|
||||
get albums() {
|
||||
return this.base + '/albums'
|
||||
},
|
||||
@@ -116,7 +125,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
base: base_url + '/colors',
|
||||
base: '/colors',
|
||||
get album() {
|
||||
return this.base + '/album'
|
||||
},
|
||||
@@ -139,9 +148,9 @@ export const paths = {
|
||||
return this.base + '/update'
|
||||
},
|
||||
},
|
||||
files: base_url + '/file',
|
||||
files: '/file',
|
||||
home: {
|
||||
base: base_url + '/home',
|
||||
base: '/nothome',
|
||||
get recentlyAdded() {
|
||||
return this.base + '/recents/added'
|
||||
},
|
||||
@@ -180,7 +189,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
backups: {
|
||||
base: base_url + '/backup',
|
||||
base: '/backup',
|
||||
get get_backups() {
|
||||
return this.base + '/list'
|
||||
},
|
||||
@@ -195,7 +204,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
base: base_url + '/logger',
|
||||
base: '/logger',
|
||||
get topArtists() {
|
||||
return this.base + '/top-artists'
|
||||
},
|
||||
@@ -220,5 +229,9 @@ export const paths = {
|
||||
medium: baseImgUrl + imageRoutes.artist.medium,
|
||||
},
|
||||
playlist: baseImgUrl + imageRoutes.playlist,
|
||||
mix: {
|
||||
medium: baseImgUrl + '/mix/medium/',
|
||||
small: baseImgUrl + '/mix/small/',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,56 +1,109 @@
|
||||
import useModal from "@/stores/modal";
|
||||
import useAlbum from "@/stores/pages/album";
|
||||
import useTracklist from "@/stores/queue/tracklist";
|
||||
import { router, Routes } from '@/router'
|
||||
|
||||
import { Option, Playlist } from "@/interfaces";
|
||||
import { addAlbumToPlaylist } from "@/requests/playlists";
|
||||
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
|
||||
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
|
||||
import useAlbum from '@/stores/pages/album'
|
||||
import useCollection from '@/stores/pages/collections'
|
||||
import useTracklist from '@/stores/queue/tracklist'
|
||||
|
||||
export default async () => {
|
||||
const album = useAlbum();
|
||||
import { getAlbumTracks } from '@/requests/album'
|
||||
import { addOrRemoveItemFromCollection } from '@/requests/collections'
|
||||
import { addAlbumToPlaylist } from '@/requests/playlists'
|
||||
|
||||
const play_next = <Option>{
|
||||
label: "Play next",
|
||||
action: () => {
|
||||
const tracks = album.tracks.filter(
|
||||
(track) => !track.is_album_disc_number
|
||||
);
|
||||
useTracklist().insertAfterCurrent(tracks);
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
};
|
||||
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
|
||||
import { Album, Collection, Option, Playlist, Track } from '@/interfaces'
|
||||
import { get_find_on_social, getAddToCollectionOptions, getAddToPlaylistOptions } from './utils'
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: "Add to queue",
|
||||
action: () => {
|
||||
const tracks = album.tracks.filter(
|
||||
(track) => !track.is_album_disc_number
|
||||
);
|
||||
useTracklist().addTracks(tracks);
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
};
|
||||
export default async (album?: Album) => {
|
||||
const albumStore = useAlbum()
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
const store = album;
|
||||
addAlbumToPlaylist(playlist, store.info.albumhash);
|
||||
};
|
||||
if (!album) {
|
||||
album = albumStore.info
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: "Add to Playlist",
|
||||
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
albumhash: album.info.albumhash,
|
||||
playlist_name: album.info.title,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
};
|
||||
const play_next = <Option>{
|
||||
label: 'Play next',
|
||||
action: async () => {
|
||||
let tracks: Track[] = []
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
get_find_on_social(),
|
||||
];
|
||||
};
|
||||
if (album) {
|
||||
tracks = await getAlbumTracks(album.albumhash)
|
||||
} else {
|
||||
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
|
||||
}
|
||||
|
||||
useTracklist().insertAfterCurrent(tracks)
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
}
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: 'Add to queue',
|
||||
action: async () => {
|
||||
let tracks: Track[] = []
|
||||
|
||||
if (album) {
|
||||
tracks = await getAlbumTracks(album.albumhash)
|
||||
} else {
|
||||
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
|
||||
}
|
||||
|
||||
useTracklist().addTracks(tracks)
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
}
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addAlbumToPlaylist(playlist, album.albumhash)
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: 'Add to Playlist',
|
||||
children: () =>
|
||||
getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
albumhash: album.albumhash,
|
||||
playlist_name: album.title,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const addToPageAction = (page: Collection) => {
|
||||
addOrRemoveItemFromCollection(page.id, album, 'album', 'add')
|
||||
}
|
||||
|
||||
const add_to_page: Option = {
|
||||
label: 'Add to Collection',
|
||||
children: () =>
|
||||
getAddToCollectionOptions(addToPageAction, {
|
||||
collection: null,
|
||||
hash: album.albumhash,
|
||||
type: 'album',
|
||||
extra: {},
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const remove_from_page: Option = {
|
||||
label: 'Remove item',
|
||||
action: async () => {
|
||||
const success = await addOrRemoveItemFromCollection(
|
||||
parseInt(router.currentRoute.value.params.collection as string),
|
||||
album,
|
||||
'album',
|
||||
'remove'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
useCollection().removeLocalItem(album, 'album')
|
||||
}
|
||||
},
|
||||
icon: DeleteIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
...[router.currentRoute.value.name === Routes.Page ? remove_from_page : add_to_page],
|
||||
get_find_on_social('album', '', album),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,54 +1,101 @@
|
||||
import modal from "@/stores/modal";
|
||||
import useTracklist from "@/stores/queue/tracklist";
|
||||
import { Routes, router } from '@/router'
|
||||
|
||||
import { getArtistTracks } from "@/requests/artists";
|
||||
import { addArtistToPlaylist } from "@/requests/playlists";
|
||||
import useCollection from '@/stores/pages/collections'
|
||||
import useTracklist from '@/stores/queue/tracklist'
|
||||
|
||||
import { Option, Playlist } from "@/interfaces";
|
||||
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
|
||||
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
|
||||
import { getArtistTracks } from '@/requests/artists'
|
||||
import { addOrRemoveItemFromCollection } from '@/requests/collections'
|
||||
import { addArtistToPlaylist } from '@/requests/playlists'
|
||||
|
||||
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
|
||||
import { Artist, Collection, Option, Playlist } from '@/interfaces'
|
||||
import { getAddToCollectionOptions, getAddToPlaylistOptions, get_find_on_social } from './utils'
|
||||
|
||||
export default async (artisthash: string, artistname: string) => {
|
||||
const play_next = <Option>{
|
||||
label: "Play next",
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then((tracks) => {
|
||||
const store = useTracklist();
|
||||
store.insertAfterCurrent(tracks);
|
||||
});
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
};
|
||||
const play_next = <Option>{
|
||||
label: 'Play next',
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then(tracks => {
|
||||
const store = useTracklist()
|
||||
store.insertAfterCurrent(tracks)
|
||||
})
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
}
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: "Add to queue",
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then((tracks) => {
|
||||
const store = useTracklist();
|
||||
store.addTracks(tracks);
|
||||
});
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
};
|
||||
const add_to_queue = <Option>{
|
||||
label: 'Add to queue',
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then(tracks => {
|
||||
const store = useTracklist()
|
||||
store.addTracks(tracks)
|
||||
})
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
}
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addArtistToPlaylist(playlist, artisthash);
|
||||
};
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addArtistToPlaylist(playlist, artisthash)
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: "Add to Playlist",
|
||||
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
artisthash,
|
||||
playlist_name: `This is ${artistname}`,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
};
|
||||
const add_to_playlist: Option = {
|
||||
label: 'Add to Playlist',
|
||||
children: () =>
|
||||
getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
artisthash,
|
||||
playlist_name: `This is ${artistname}`,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
get_find_on_social("artist"),
|
||||
];
|
||||
};
|
||||
const addToCollectionAction = (collection: Collection) => {
|
||||
addOrRemoveItemFromCollection(
|
||||
collection.id,
|
||||
{
|
||||
artisthash,
|
||||
} as Artist,
|
||||
'artist',
|
||||
'add'
|
||||
)
|
||||
}
|
||||
|
||||
const add_to_page: Option = {
|
||||
label: 'Add to Collection',
|
||||
children: () =>
|
||||
getAddToCollectionOptions(addToCollectionAction, {
|
||||
collection: null,
|
||||
hash: artisthash,
|
||||
type: 'artist',
|
||||
extra: {},
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const remove_from_collection: Option = {
|
||||
label: 'Remove item',
|
||||
action: async () => {
|
||||
const success = await addOrRemoveItemFromCollection(
|
||||
parseInt(router.currentRoute.value.params.collection as string),
|
||||
{
|
||||
artisthash,
|
||||
} as Artist,
|
||||
'artist',
|
||||
'remove'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
useCollection().removeLocalItem({ artisthash } as Artist, 'artist')
|
||||
}
|
||||
},
|
||||
icon: DeleteIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
...[router.currentRoute.value.name === Routes.Page ? remove_from_collection : add_to_page],
|
||||
get_find_on_social('artist'),
|
||||
]
|
||||
}
|
||||
|
||||
22
src/context_menus/hashing.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
|
||||
export function getLastFmApiSig(data: {[key: string]: any}, secret: string): string {
|
||||
// Sort keys alphabetically
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
|
||||
// Concatenate parameters in name+value format
|
||||
const concatenatedString = sortedKeys.reduce((acc, key) => {
|
||||
// Ensure values are properly encoded
|
||||
const value = encodeURIComponent(data[key].toString());
|
||||
return acc + key + value;
|
||||
}, '');
|
||||
|
||||
// Append secret
|
||||
const stringToHash = concatenatedString + secret;
|
||||
|
||||
// Generate MD5 hash
|
||||
return crypto.createHash('md5')
|
||||
.update(stringToHash)
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import modal from '@/stores/modal'
|
||||
import useAlbum from '@/stores/pages/album'
|
||||
import useArtist from '@/stores/pages/artist'
|
||||
|
||||
import { SearchIcon } from '@/icons'
|
||||
import { Option, Playlist } from '@/interfaces'
|
||||
import { Album, Collection, Option, Playlist } from '@/interfaces'
|
||||
import { getAllCollections } from '@/requests/collections'
|
||||
import { getAllPlaylists } from '@/requests/playlists'
|
||||
|
||||
export const separator: Option = {
|
||||
type: 'separator',
|
||||
}
|
||||
|
||||
export function get_new_playlist_option(
|
||||
new_playlist_modal_props: any = {}
|
||||
): Option {
|
||||
export function get_new_playlist_option(new_playlist_modal_props: any = {}): Option {
|
||||
return {
|
||||
label: 'New playlist',
|
||||
action: () => {
|
||||
@@ -21,6 +19,15 @@ export function get_new_playlist_option(
|
||||
}
|
||||
}
|
||||
|
||||
export function get_new_collection_option(new_collection_modal_props: any = {}): Option {
|
||||
return {
|
||||
label: 'New Collection',
|
||||
action: () => {
|
||||
modal().showCollectionModal(new_collection_modal_props)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type action = (playlist: Playlist) => void
|
||||
|
||||
/**
|
||||
@@ -29,10 +36,7 @@ type action = (playlist: Playlist) => void
|
||||
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
|
||||
* @returns A list of options to be used in a context menu
|
||||
*/
|
||||
export async function getAddToPlaylistOptions(
|
||||
addToPlaylist: action,
|
||||
new_playlist_modal_props: any = {}
|
||||
) {
|
||||
export async function getAddToPlaylistOptions(addToPlaylist: action, new_playlist_modal_props: any = {}) {
|
||||
const new_playlist = get_new_playlist_option(new_playlist_modal_props)
|
||||
const p = await getAllPlaylists(true)
|
||||
|
||||
@@ -44,7 +48,7 @@ export async function getAddToPlaylistOptions(
|
||||
|
||||
let playlists = <Option[]>[]
|
||||
|
||||
playlists = p.map((playlist) => {
|
||||
playlists = p.map(playlist => {
|
||||
return <Option>{
|
||||
label: playlist.name,
|
||||
action: () => {
|
||||
@@ -56,20 +60,45 @@ export async function getAddToPlaylistOptions(
|
||||
return [...items, separator, ...playlists]
|
||||
}
|
||||
|
||||
export const get_find_on_social = (page = 'album', query = '') => {
|
||||
/**
|
||||
*
|
||||
* @param addToPlaylist Function to be called when a playlist is selected
|
||||
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
|
||||
* @returns A list of options to be used in a context menu
|
||||
*/
|
||||
export async function getAddToCollectionOptions(
|
||||
addToCollection: (collection: Collection) => void,
|
||||
new_page_modal_props: any = {}
|
||||
) {
|
||||
const new_page = get_new_collection_option(new_page_modal_props)
|
||||
const data = await getAllCollections()
|
||||
|
||||
let items = [new_page]
|
||||
|
||||
if (data.length === 0) {
|
||||
return items
|
||||
}
|
||||
|
||||
let collections = <Option[]>[]
|
||||
|
||||
collections = data.map(collection => {
|
||||
return <Option>{
|
||||
label: collection.name,
|
||||
action: () => {
|
||||
addToCollection(collection)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [...items, separator, ...collections]
|
||||
}
|
||||
|
||||
export const get_find_on_social = (page = 'album', query = '', album?: Album) => {
|
||||
const is_album = page === 'album'
|
||||
const getAlbumSearchTerm = () => {
|
||||
const store = useAlbum()
|
||||
|
||||
return `${store.info.title} - ${store.info.albumartists
|
||||
.map((a) => a.name)
|
||||
.join(', ')}`
|
||||
return `${album?.title} - ${album?.albumartists.map(a => a.name).join(', ')}`
|
||||
}
|
||||
const search_term = query
|
||||
? query
|
||||
: is_album
|
||||
? getAlbumSearchTerm()
|
||||
: useArtist().info.name
|
||||
const search_term = query ? query : is_album ? getAlbumSearchTerm() : useArtist().info.name
|
||||
|
||||
return <Option>{
|
||||
label: 'Search on',
|
||||
@@ -77,67 +106,36 @@ export const get_find_on_social = (page = 'album', query = '') => {
|
||||
children: async () => [
|
||||
{
|
||||
label: 'Google',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.google.com/search?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.google.com/search?q=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'YouTube',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.youtube.com/results?search_query=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.youtube.com/results?search_query=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Spotify',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://open.spotify.com/search/${search_term}/${page}s`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://open.spotify.com/search/${search_term}/${page}s`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Tidal',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://listen.tidal.com/search/${page}s?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://listen.tidal.com/search/${page}s?q=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Apple Music',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://music.apple.com/search?term=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://music.apple.com/search?term=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Deezer',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.deezer.com/search/${search_term}/${page}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.deezer.com/search/${search_term}/${page}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Wikipedia',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
window.open(`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Last.fm',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.last.fm/search/${page}s?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.last.fm/search/${page}s?q=${search_term}`, '_blank'),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum playSources {
|
||||
track,
|
||||
recentlyAdded,
|
||||
recentlyPlayed,
|
||||
mix,
|
||||
}
|
||||
|
||||
export enum NotifType {
|
||||
@@ -24,6 +25,8 @@ export enum FromOptions {
|
||||
album = "album",
|
||||
search = "search",
|
||||
artist = "artist",
|
||||
mix = "mix",
|
||||
artistMix = "artist mix",
|
||||
albumCard = "albumCard",
|
||||
favorite = "favorite",
|
||||
}
|
||||
@@ -99,4 +102,8 @@ export interface DBSettings {
|
||||
scanInterval: number
|
||||
plugins: Plugin[];
|
||||
version: string;
|
||||
lastfmApiKey: string;
|
||||
lastfmApiSecret: string;
|
||||
lastfmSessionKey: string;
|
||||
showPlaylistsInFolderView: boolean;
|
||||
}
|
||||
|
||||
@@ -1,88 +1,78 @@
|
||||
import { Store } from "pinia";
|
||||
import { Ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Store } from 'pinia'
|
||||
import { Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { ContextSrc } from "@/enums";
|
||||
import { Track } from "@/interfaces";
|
||||
import useContextStore from "@/stores/context";
|
||||
import { ContextSrc } from '@/enums'
|
||||
import { Album, Track } from '@/interfaces'
|
||||
import useContextStore from '@/stores/context'
|
||||
|
||||
import albumContextItems from "@/context_menus/album";
|
||||
import artistContextItems from "@/context_menus/artist";
|
||||
import folderContextItems from "@/context_menus/folder";
|
||||
import trackContextItems from "@/context_menus/track";
|
||||
import queueContextItems from "@/context_menus/queue";
|
||||
import albumContextItems from '@/context_menus/album'
|
||||
import artistContextItems from '@/context_menus/artist'
|
||||
import folderContextItems from '@/context_menus/folder'
|
||||
import trackContextItems from '@/context_menus/track'
|
||||
import queueContextItems from '@/context_menus/queue'
|
||||
|
||||
let stop_prev_watcher = () => {};
|
||||
let stop_prev_watcher = () => {}
|
||||
|
||||
function flagWatcher(menu: Store, flag: Ref<boolean>) {
|
||||
stop_prev_watcher();
|
||||
stop_prev_watcher()
|
||||
|
||||
if (flag.value) {
|
||||
return (flag.value = false);
|
||||
}
|
||||
if (flag.value) {
|
||||
return (flag.value = false)
|
||||
}
|
||||
|
||||
// watch for context menu visibility and reset flag
|
||||
stop_prev_watcher = menu.$subscribe((mutation, state) => {
|
||||
//@ts-ignore
|
||||
flag.value = state.visible;
|
||||
});
|
||||
// watch for context menu visibility and reset flag
|
||||
stop_prev_watcher = menu.$subscribe((mutation, state) => {
|
||||
//@ts-ignore
|
||||
flag.value = state.visible
|
||||
})
|
||||
}
|
||||
|
||||
export const showTrackContextMenu = (
|
||||
e: MouseEvent,
|
||||
track: Track,
|
||||
flag: Ref<boolean>,
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
const options = () => trackContextItems(track);
|
||||
export const showTrackContextMenu = (e: MouseEvent, track: Track, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore()
|
||||
const options = () => trackContextItems(track)
|
||||
|
||||
menu.showContextMenu(e, options, ContextSrc.Track);
|
||||
menu.showContextMenu(e, options, ContextSrc.Track)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore();
|
||||
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>, album?: Album) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => albumContextItems();
|
||||
menu.showContextMenu(e, options, ContextSrc.AlbumHeader);
|
||||
const options = () => albumContextItems(album)
|
||||
menu.showContextMenu(e, options, ContextSrc.AlbumHeader)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showFolderContextMenu = (
|
||||
e: MouseEvent,
|
||||
flag: Ref<boolean>,
|
||||
source: ContextSrc,
|
||||
path: string
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
export const showFolderContextMenu = (e: MouseEvent, flag: Ref<boolean>, source: ContextSrc, path: string) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => folderContextItems(path);
|
||||
menu.showContextMenu(e, options, source);
|
||||
const options = () => folderContextItems(path)
|
||||
menu.showContextMenu(e, options, source)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showArtistContextMenu = (
|
||||
e: MouseEvent,
|
||||
flag: Ref<boolean>,
|
||||
artisthash: string,
|
||||
artistname: string
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
export const showArtistContextMenu = (e: MouseEvent, flag: Ref<boolean>, artisthash: string, artistname: string) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => artistContextItems(artisthash, artistname);
|
||||
menu.showContextMenu(e, options, ContextSrc.ArtistHeader);
|
||||
const options = () => artistContextItems(artisthash, artistname)
|
||||
menu.showContextMenu(e, options, ContextSrc.ArtistHeader)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showQueueContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore();
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => queueContextItems();
|
||||
menu.showContextMenu(e, options, ContextSrc.Queue);
|
||||
const options = () => queueContextItems()
|
||||
menu.showContextMenu(e, options, ContextSrc.Queue)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
// export const showAlbumCardContextMenu = (e: MouseEvent, flag: Ref<boolean>, album: Album) => {
|
||||
|
||||
// }
|
||||
|
||||
@@ -1,67 +1,81 @@
|
||||
import { paths } from "../config";
|
||||
|
||||
import useQueueStore from "../stores/queue";
|
||||
import { paths } from '../config'
|
||||
import useSettings from '../stores/settings'
|
||||
import useQueueStore from '../stores/queue'
|
||||
import updatePageTitle from '@/utils/updatePageTitle'
|
||||
|
||||
export default () => {
|
||||
if ("mediaSession" in navigator) {
|
||||
const queue = useQueueStore();
|
||||
const { currenttrack: track } = queue;
|
||||
if ('mediaSession' in navigator) {
|
||||
const queue = useQueueStore()
|
||||
const { currenttrack: track } = queue
|
||||
|
||||
if (track === undefined) {
|
||||
return;
|
||||
if (track === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = useSettings()
|
||||
|
||||
if (settings.nowPlayingTrackOnTabTitle) {
|
||||
updatePageTitle(`${track.title} - ${track.artists[0].name}`, true)
|
||||
}
|
||||
|
||||
const url = paths.images.thumb
|
||||
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||
title: track.title,
|
||||
album: track.album,
|
||||
artist: track.artists.map(a => a.name).join(', '),
|
||||
artwork: [
|
||||
{
|
||||
src: url.small + track.image,
|
||||
sizes: '96x96',
|
||||
type: 'image/jpeg',
|
||||
},
|
||||
{
|
||||
src: url.medium + track.image,
|
||||
sizes: '128x128',
|
||||
type: 'image/webp',
|
||||
},
|
||||
{
|
||||
src: url.medium + track.image,
|
||||
sizes: '192x192',
|
||||
type: 'image/webp',
|
||||
},
|
||||
{
|
||||
src: url.medium + track.image,
|
||||
sizes: '256x256',
|
||||
type: 'image/webp',
|
||||
},
|
||||
{
|
||||
src: url.large + track.image,
|
||||
sizes: '384x384',
|
||||
type: 'image/webp',
|
||||
},
|
||||
{
|
||||
src: url.large + track.image,
|
||||
sizes: '512x512',
|
||||
type: 'image/webp',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
queue.playPause()
|
||||
})
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
queue.playPause()
|
||||
})
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
queue.playPrev()
|
||||
})
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
queue.playNext()
|
||||
})
|
||||
navigator.mediaSession.setActionHandler('seekto', details => {
|
||||
if (details.fastSeek || details.seekTime == undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
queue.seek(details.seekTime)
|
||||
})
|
||||
}
|
||||
|
||||
const url = paths.images.thumb;
|
||||
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||
title: track.title,
|
||||
album: track.album,
|
||||
artist: track.artists.map((a) => a.name).join(", "),
|
||||
artwork: [
|
||||
{
|
||||
src: url.small + track.image,
|
||||
sizes: "96x96",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: url.medium + track.image,
|
||||
sizes: "128x128",
|
||||
type: "image/webp",
|
||||
},
|
||||
{
|
||||
src: url.medium + track.image,
|
||||
sizes: "192x192",
|
||||
type: "image/webp",
|
||||
},
|
||||
{
|
||||
src: url.medium + track.image,
|
||||
sizes: "256x256",
|
||||
type: "image/webp",
|
||||
},
|
||||
{
|
||||
src: url.large + track.image,
|
||||
sizes: "384x384",
|
||||
type: "image/webp",
|
||||
},
|
||||
{
|
||||
src: url.large + track.image,
|
||||
sizes: "512x512",
|
||||
type: "image/webp",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler("play", () => {
|
||||
queue.playPause();
|
||||
});
|
||||
navigator.mediaSession.setActionHandler("pause", () => {
|
||||
queue.playPause();
|
||||
});
|
||||
navigator.mediaSession.setActionHandler("previoustrack", () => {
|
||||
queue.playPrev();
|
||||
});
|
||||
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
||||
queue.playNext();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,12 +84,10 @@ export async function playFromFolderCard(folderpath: string) {
|
||||
export async function playFromFavorites(track: Track | undefined) {
|
||||
const queue = useQueue()
|
||||
const tracklist = useTracklist()
|
||||
console.log(track)
|
||||
|
||||
// if our tracklist is not from favorites, we need to fetch the favorites
|
||||
if (tracklist.from.type !== FromOptions.favorite) {
|
||||
const res = await getFavTracks(0, -1)
|
||||
console.log(res)
|
||||
tracklist.setFromFav(res.tracks)
|
||||
}
|
||||
|
||||
@@ -99,14 +97,13 @@ export async function playFromFavorites(track: Track | undefined) {
|
||||
index = tracklist.tracklist.findIndex(t => t.trackhash === track?.trackhash)
|
||||
}
|
||||
|
||||
console.log(tracklist.tracklist)
|
||||
queue.play(index)
|
||||
}
|
||||
|
||||
export async function playFromPlaylist(id: string, track?: Track) {
|
||||
const queue = useQueue()
|
||||
const tracklist = useTracklist()
|
||||
const data = await getPlaylist(id)
|
||||
const data = await getPlaylist(id, false, 0, -1)
|
||||
|
||||
if (!data) return
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Track extends AlbumDisc {
|
||||
trackhash: string
|
||||
filetype: string
|
||||
is_favorite: boolean
|
||||
explicit: boolean
|
||||
type?: string
|
||||
|
||||
og_title: string
|
||||
og_album: string
|
||||
@@ -37,7 +39,7 @@ export interface Track extends AlbumDisc {
|
||||
help_text?: string
|
||||
time?: string
|
||||
trend?: {
|
||||
trend: 'rising' | 'falling' | 'stable',
|
||||
trend: 'rising' | 'falling' | 'stable'
|
||||
is_new: boolean
|
||||
}
|
||||
}
|
||||
@@ -73,11 +75,53 @@ export interface Album {
|
||||
genres: Genre[]
|
||||
versions: string[]
|
||||
trend?: {
|
||||
trend: 'rising' | 'falling' | 'stable',
|
||||
trend: 'rising' | 'falling' | 'stable'
|
||||
is_new: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface Mix {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
sourcehash: string
|
||||
userid: number
|
||||
timestamp: number | string
|
||||
saved: boolean
|
||||
extra: {
|
||||
type: string
|
||||
artisthash: string
|
||||
og_sourcehash: string
|
||||
image?: {
|
||||
image: string
|
||||
color: string
|
||||
}
|
||||
images?: {
|
||||
image: string
|
||||
color: string
|
||||
}[]
|
||||
}
|
||||
duration: number
|
||||
trackcount: number
|
||||
help_text?: string
|
||||
time?: string
|
||||
}
|
||||
|
||||
export interface FullMix extends Mix {
|
||||
tracks: Track[]
|
||||
saved: boolean
|
||||
}
|
||||
|
||||
export interface HomePageItem {
|
||||
position: number
|
||||
title?: string
|
||||
description?: string
|
||||
items: { type: string; item?: any; with_helptext?: boolean }[]
|
||||
path?: string
|
||||
seeAllText?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
name: string
|
||||
image: string
|
||||
@@ -90,10 +134,11 @@ export interface Artist {
|
||||
help_text?: string
|
||||
time?: string
|
||||
genres: Genre[]
|
||||
type?: string
|
||||
|
||||
// available in charts
|
||||
trend?: {
|
||||
trend: 'rising' | 'falling' | 'stable',
|
||||
trend: 'rising' | 'falling' | 'stable'
|
||||
is_new: boolean
|
||||
}
|
||||
extra?: any
|
||||
@@ -137,6 +182,15 @@ export interface Playlist {
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: number
|
||||
name: string
|
||||
items: (Album | Artist | Mix | Playlist)[]
|
||||
extra: {
|
||||
description: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Radio {
|
||||
name: string
|
||||
image: string
|
||||
@@ -176,6 +230,17 @@ export interface fromArtist {
|
||||
artistname: string
|
||||
}
|
||||
|
||||
export interface fromMix {
|
||||
type: FromOptions.mix
|
||||
name: string
|
||||
mixid: string
|
||||
sourcehash: string
|
||||
image: {
|
||||
type: 'mix' | 'track'
|
||||
image: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface fromFav {
|
||||
type: FromOptions.favorite
|
||||
}
|
||||
@@ -257,3 +322,10 @@ export interface User extends UserSimplified {
|
||||
image: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
cssclass: string
|
||||
value: string
|
||||
text: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { paths } from '@/config'
|
||||
import { Album, Track } from '@/interfaces'
|
||||
import { Album, StatItem, Track } from '@/interfaces'
|
||||
import { NotifType, useToast } from '@/stores/notification'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
albumVersions,
|
||||
} = paths.api
|
||||
|
||||
const getAlbumData = async (albumhash: string, ToastStore: typeof useToast) => {
|
||||
const getAlbumData = async (albumhash: string, albumlimit: number) => {
|
||||
interface AlbumData {
|
||||
info: Album
|
||||
tracks: Track[]
|
||||
@@ -20,17 +20,23 @@ const getAlbumData = async (albumhash: string, ToastStore: typeof useToast) => {
|
||||
track_total: number
|
||||
avg_bitrate: number
|
||||
}
|
||||
stats: StatItem[]
|
||||
more_from: {
|
||||
[key: string]: Album[]
|
||||
}
|
||||
other_versions: Album[]
|
||||
}
|
||||
|
||||
const { data, status } = await useAxios({
|
||||
url: albumUrl,
|
||||
props: {
|
||||
albumhash,
|
||||
albumlimit,
|
||||
},
|
||||
})
|
||||
|
||||
if (status == 204) {
|
||||
ToastStore().showNotification('Album not created yet!', NotifType.Error)
|
||||
useToast().showNotification('Album not created yet!', NotifType.Error)
|
||||
}
|
||||
|
||||
return data as AlbumData
|
||||
@@ -112,7 +118,7 @@ export async function getAlbumTracks(albumhash: string): Promise<Track[]> {
|
||||
|
||||
export async function getSimilarAlbums(artisthash: string, limit: number = 5): Promise<Album[]> {
|
||||
const { data } = await useAxios({
|
||||
url: albumUrl + '/similar?' + 'artisthash=' + artisthash + '&limit=' + limit,
|
||||
url: albumUrl + '/similar?' + 'artisthash=' + artisthash + '&albumlimit=' + limit,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { paths } from '@/config'
|
||||
import { Album, Artist, Genre, Track } from '@/interfaces'
|
||||
import { Album, Artist, Genre, StatItem, Track } from '@/interfaces'
|
||||
import { NotifType, useToast } from '@/stores/notification'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
export const getArtistData = async (hash: string, limit: number = 5) => {
|
||||
export const getArtistData = async (hash: string, limit: number = 15, albumlimit: number = 7) => {
|
||||
interface ArtistData {
|
||||
artist: Artist
|
||||
tracks: Track[]
|
||||
albums: {
|
||||
albums: Album[]
|
||||
singles_and_eps: Album[]
|
||||
appearances: Album[]
|
||||
compilations: Album[]
|
||||
}
|
||||
genres: Genre[]
|
||||
stats: StatItem[]
|
||||
}
|
||||
|
||||
const { data, error, status } = await useAxios({
|
||||
method: 'GET',
|
||||
url: paths.api.artist + `/${hash}?limit=${limit}`,
|
||||
url: paths.api.artist + `/${hash}?tracklimit=${limit}&albumlimit=${albumlimit}`,
|
||||
})
|
||||
|
||||
if (status == 404) {
|
||||
@@ -63,7 +70,7 @@ export const getArtistTracks = async (hash: string) => {
|
||||
export const getSimilarArtists = async (hash: string, limit = 6) => {
|
||||
const { data, error } = await useAxios({
|
||||
method: 'GET',
|
||||
url: paths.api.artist + `/${hash}/similar?limit=${limit}`,
|
||||
url: paths.api.artist + `/${hash}/similar?artistlimit=${limit}`,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import useAxios from './useAxios'
|
||||
import { User, UserSimplified } from '@/interfaces'
|
||||
|
||||
export async function getAllUsers<T extends boolean>(simple: T = true as T) {
|
||||
interface res {
|
||||
interface Response {
|
||||
users: T extends true ? UserSimplified[] : User[]
|
||||
settings: { [key: string]: any }
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export async function getAllUsers<T extends boolean>(simple: T = true as T) {
|
||||
})
|
||||
|
||||
if (res.status === 200) {
|
||||
return res.data as res
|
||||
return res.data as Response
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
|
||||
138
src/requests/collections.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { paths } from '@/config'
|
||||
import { Album, Artist, Collection, Mix, Playlist } from '@/interfaces'
|
||||
import { Notification, NotifType } from '@/stores/notification'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
const { base: baseCollectionUrl } = paths.api.collections
|
||||
|
||||
export async function getAllCollections() {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return data as Collection[]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function getCollection(collection_id: string) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}`,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return data as Collection
|
||||
}
|
||||
|
||||
export async function createNewCollection(
|
||||
name: string,
|
||||
description: string,
|
||||
items?: { hash: string; type: string; extra: any }[]
|
||||
) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl,
|
||||
props: {
|
||||
name,
|
||||
description,
|
||||
items,
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (status == 201) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function updateCollection(collection: Collection, name: string, description: string) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection.id}`,
|
||||
props: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
method: 'PUT',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return data as Collection
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function addOrRemoveItemFromCollection(
|
||||
collection_id: number,
|
||||
item: Album | Artist | Mix | Playlist,
|
||||
type: string,
|
||||
command: 'add' | 'remove'
|
||||
) {
|
||||
const payload = {
|
||||
type: type,
|
||||
hash: '',
|
||||
extra: {},
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'album':
|
||||
payload.hash = (item as Album).albumhash
|
||||
break
|
||||
case 'artist':
|
||||
payload.hash = (item as Artist).artisthash
|
||||
break
|
||||
case 'mix':
|
||||
payload.hash = (item as Mix).sourcehash
|
||||
break
|
||||
case 'playlist':
|
||||
payload.hash = (item as Playlist).id.toString()
|
||||
break
|
||||
}
|
||||
|
||||
if (payload.hash === '') {
|
||||
throw new Error('Invalid item type. Item not added to collection.')
|
||||
}
|
||||
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}/items`,
|
||||
props: {
|
||||
item: payload,
|
||||
},
|
||||
method: command == 'add' ? 'POST' : 'DELETE',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
new Notification(
|
||||
`${payload.type[0].toUpperCase() + payload.type.slice(1)} ${
|
||||
command == 'add' ? 'added' : 'removed'
|
||||
} to page`,
|
||||
NotifType.Success
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (status == 400) {
|
||||
new Notification(`${payload.type[0].toUpperCase() + payload.type.slice(1)} already in collection`, NotifType.Error)
|
||||
return false
|
||||
}
|
||||
|
||||
new Notification('Failed: ' + data.error, NotifType.Error)
|
||||
return false
|
||||
}
|
||||
|
||||
export async function deleteCollection(collection_id: number) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,19 +1,28 @@
|
||||
import { paths } from "@/config";
|
||||
import useAxios from "./useAxios";
|
||||
import { paths } from '@/config'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
export async function getRecents(path: string, limit: number) {
|
||||
const { data } = await useAxios({
|
||||
url: path + "?limit=" + limit,
|
||||
method: "GET",
|
||||
});
|
||||
const { data } = await useAxios({
|
||||
url: path + '?limit=' + limit,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return data;
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRecentlyAdded(limit: number) {
|
||||
return getRecents(paths.api.home.recentlyAdded, limit);
|
||||
return getRecents(paths.api.home.recentlyAdded, limit)
|
||||
}
|
||||
|
||||
export async function getRecentlyPlayed(limit: number) {
|
||||
return getRecents(paths.api.home.recentlyPlayed, limit);
|
||||
return getRecents(paths.api.home.recentlyPlayed, limit)
|
||||
}
|
||||
|
||||
export async function getHomePageData(limit: number) {
|
||||
const { data } = await useAxios({
|
||||
url: paths.api.home.base + '?limit=' + limit,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
9
src/requests/mixes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { paths } from '@/config'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
export function getMix(mixid: string, sourcehash: string, og_sourcehash?: string) {
|
||||
return useAxios({
|
||||
url: paths.api.mixes + `/?mixid=${mixid}&sourcehash=${sourcehash}&og_sourcehash=${og_sourcehash}`,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { paths } from "@/config";
|
||||
import axios from "axios";
|
||||
import useAxios from "./useAxios";
|
||||
import { paths } from '@/config'
|
||||
import axios from 'axios'
|
||||
import useAxios from './useAxios'
|
||||
import { Track, Album, Artist } from '@/interfaces'
|
||||
|
||||
const {
|
||||
top: searchTopResultsUrl,
|
||||
tracks: searchTracksUrl,
|
||||
albums: searchAlbumsUrl,
|
||||
artists: searchArtistsUrl,
|
||||
load: loadMoreUrl,
|
||||
} = paths.api.search;
|
||||
top: searchTopResultsUrl,
|
||||
base,
|
||||
} = paths.api.search
|
||||
|
||||
/**
|
||||
* Fetch data from url
|
||||
@@ -16,75 +14,40 @@ const {
|
||||
* @returns promise that resolves to the JSON
|
||||
*/
|
||||
async function fetchData(url: string) {
|
||||
const { data } = await useAxios({
|
||||
url: url,
|
||||
method: "GET",
|
||||
});
|
||||
const { data } = await useAxios({
|
||||
url: url,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return data;
|
||||
return data
|
||||
}
|
||||
|
||||
async function searchTopResults(query: string, limit: number) {
|
||||
const url =
|
||||
searchTopResultsUrl + encodeURIComponent(query.trim()) + `&limit=${limit}`;
|
||||
return await fetchData(url);
|
||||
const url = searchTopResultsUrl + encodeURIComponent(query.trim()) + `&limit=${limit}`
|
||||
return await fetchData(url)
|
||||
}
|
||||
|
||||
async function searchTracks(query: string) {
|
||||
const url = searchTracksUrl + encodeURIComponent(query.trim());
|
||||
return await fetchData(url);
|
||||
async function searchItems(type: 'tracks' | 'albums' | 'artists', index: number, query: string) {
|
||||
const { data } = await useAxios({
|
||||
url: base + `/?itemtype=${type}&start=${index}&q=${query}&limit=30`,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function searchAlbums(query: string) {
|
||||
const url = searchAlbumsUrl + encodeURIComponent(query.trim());
|
||||
return await fetchData(url);
|
||||
async function searchTracks(query: string, start: number = 0): Promise<{ results: Track[]; more: boolean }> {
|
||||
return searchItems('tracks', start, query)
|
||||
}
|
||||
|
||||
async function searchArtists(query: string) {
|
||||
const url = searchArtistsUrl + encodeURIComponent(query.trim());
|
||||
return await fetchData(url);
|
||||
async function searchAlbums(query: string, start: number = 0): Promise<{ results: Album[]; more: boolean }> {
|
||||
return searchItems('albums', start, query)
|
||||
}
|
||||
|
||||
async function loadMoreTracks(index: number, query: string) {
|
||||
const response = await axios.get(loadMoreUrl, {
|
||||
params: {
|
||||
type: "tracks",
|
||||
index: index,
|
||||
q: query,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
async function searchArtists(query: string, start: number = 0): Promise<{ results: Artist[]; more: boolean }> {
|
||||
return searchItems('artists', start, query)
|
||||
}
|
||||
|
||||
async function loadMoreAlbums(index: number, query: string) {
|
||||
const response = await axios.get(loadMoreUrl, {
|
||||
params: {
|
||||
type: "albums",
|
||||
index: index,
|
||||
q: query,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function loadMoreArtists(index: number, query: string) {
|
||||
const response = await axios.get(loadMoreUrl, {
|
||||
params: {
|
||||
type: "artists",
|
||||
index: index,
|
||||
q: query,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export {
|
||||
loadMoreAlbums,
|
||||
loadMoreArtists, loadMoreTracks, searchAlbums,
|
||||
searchArtists, searchTopResults, searchTracks
|
||||
};
|
||||
export { searchAlbums, searchArtists, searchTracks, searchTopResults }
|
||||
|
||||
// TODO: Rewrite this module using `useAxios` hook
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function getBackups() {
|
||||
playlists: number
|
||||
scrobbles: number
|
||||
favorites: number
|
||||
collections: number
|
||||
date: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { FetchProps } from '@/interfaces'
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import useModal from '@/stores/modal'
|
||||
|
||||
import useLoaderStore from '@/stores/loader'
|
||||
import { logoutUser } from './auth'
|
||||
|
||||
const development = import.meta.env.DEV
|
||||
|
||||
export function getBaseUrl() {
|
||||
const base_url = window.location.origin
|
||||
|
||||
if (!development) {
|
||||
return base_url
|
||||
}
|
||||
|
||||
const splits = base_url.split(':')
|
||||
return base_url.replace(splits[splits.length - 1], '1980')
|
||||
if (window.location.protocol === 'https:') {
|
||||
const meta = document.createElement('meta');
|
||||
meta.httpEquiv = 'Content-Security-Policy';
|
||||
meta.content = 'upgrade-insecure-requests';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
|
||||
axios.defaults.baseURL = getBaseUrl()
|
||||
|
||||
export default async (args: FetchProps) => {
|
||||
export default async (args: FetchProps, withCredentials: boolean = true) => {
|
||||
const on_ngrok = args.url.includes('ngrok')
|
||||
const ngrok_config = {
|
||||
'ngrok-skip-browser-warning': 'stupid-SOAB!',
|
||||
@@ -37,7 +29,7 @@ export default async (args: FetchProps) => {
|
||||
method: args.method || 'POST',
|
||||
// INFO: Add ngrok header and provided headers
|
||||
headers: { ...args.headers, ...(on_ngrok ? ngrok_config : {}) },
|
||||
withCredentials: true,
|
||||
withCredentials: withCredentials,
|
||||
})
|
||||
|
||||
stopLoading()
|
||||
@@ -61,7 +53,7 @@ export default async (args: FetchProps) => {
|
||||
try {
|
||||
isSignatureError = error.response.data.msg == 'Signature verification failed'
|
||||
} catch (error) {
|
||||
console.log('Error:', error)
|
||||
console.error('Error:', error)
|
||||
}
|
||||
|
||||
if (error.response?.status === 422 && isSignatureError) {
|
||||
@@ -75,19 +67,6 @@ export default async (args: FetchProps) => {
|
||||
status: error.response?.status,
|
||||
}
|
||||
}
|
||||
|
||||
// await getAxios()
|
||||
// .then((res: AxiosResponse) => {
|
||||
// data = res.data;
|
||||
// status = res.status;
|
||||
// })
|
||||
// .catch((err: AxiosError) => {
|
||||
// error = err.message as string;
|
||||
// status = err.response?.status as number;
|
||||
// })
|
||||
// .then(() => stopLoading());
|
||||
|
||||
// return { data, error, status };
|
||||
}
|
||||
|
||||
// TODO: Set base url in axios config
|
||||
|
||||
@@ -1,243 +1,269 @@
|
||||
import { createRouter, createWebHashHistory, RouterOptions } from "vue-router";
|
||||
import { createRouter, createWebHashHistory, RouterOptions } from 'vue-router'
|
||||
|
||||
import state from "@/composables/state";
|
||||
import useAlbumPageStore from "@/stores/pages/album";
|
||||
import useFolderPageStore from "@/stores/pages/folder";
|
||||
import usePlaylistPageStore from "@/stores/pages/playlist";
|
||||
import usePlaylistListPageStore from "@/stores/pages/playlists";
|
||||
import useArtistPageStore from "@/stores/pages/artist";
|
||||
import state from '@/composables/state'
|
||||
import useAlbumPageStore from '@/stores/pages/album'
|
||||
import useFolderPageStore from '@/stores/pages/folder'
|
||||
import usePlaylistPageStore from '@/stores/pages/playlist'
|
||||
import usePlaylistListPageStore from '@/stores/pages/playlists'
|
||||
import useArtistPageStore from '@/stores/pages/artist'
|
||||
|
||||
|
||||
import HomeView from "@/views/HomeView";
|
||||
const Lyrics = () => import("@/views/LyricsView");
|
||||
const ArtistView = () => import("@/views/ArtistView");
|
||||
const NotFound = () => import("@/views/NotFound.vue");
|
||||
const NowPlaying = () => import("@/views/NowPlaying");
|
||||
const SearchView = () => import("@/views/SearchView");
|
||||
const AlbumList = () => import("@/views/AlbumListView");
|
||||
const FolderView = () => import("@/views/FolderView.vue");
|
||||
const FavoritesView = () => import("@/views/Favorites.vue");
|
||||
const SettingsView = () => import("@/views/SettingsView.vue");
|
||||
const AlbumView = () => import("@/views/AlbumView/index.vue");
|
||||
const ArtistTracksView = () => import("@/views/ArtistTracks.vue");
|
||||
const PlaylistListView = () => import("@/views/PlaylistList.vue");
|
||||
const FavoriteTracks = () => import("@/views/FavoriteTracks.vue");
|
||||
const PlaylistView = () => import("@/views/PlaylistView/index.vue");
|
||||
const ArtistDiscographyView = () => import("@/views/ArtistDiscography.vue");
|
||||
const FavoriteCardScroller = () => import("@/views/FavoriteCardScroller.vue");
|
||||
const StatsView = () => import("@/views/Stats/main.vue");
|
||||
import HomeView from '@/views/HomeView'
|
||||
const Lyrics = () => import('@/views/LyricsView')
|
||||
const ArtistView = () => import('@/views/ArtistView')
|
||||
const NotFound = () => import('@/views/NotFound.vue')
|
||||
const NowPlaying = () => import('@/views/NowPlaying')
|
||||
const SearchView = () => import('@/views/SearchView')
|
||||
const AlbumList = () => import('@/views/AlbumListView')
|
||||
const FolderView = () => import('@/views/FolderView.vue')
|
||||
const FavoritesView = () => import('@/views/Favorites.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const AlbumView = () => import('@/views/AlbumView/index.vue')
|
||||
const ArtistTracksView = () => import('@/views/ArtistTracks.vue')
|
||||
const PlaylistListView = () => import('@/views/PlaylistList.vue')
|
||||
const FavoriteTracks = () => import('@/views/FavoriteTracks.vue')
|
||||
const PlaylistView = () => import('@/views/PlaylistView/index.vue')
|
||||
const ArtistDiscographyView = () => import('@/views/ArtistDiscography.vue')
|
||||
const FavoriteCardScroller = () => import('@/views/FavoriteCardScroller.vue')
|
||||
const StatsView = () => import('@/views/Stats/main.vue')
|
||||
const MixView = () => import('@/views/MixView.vue')
|
||||
const MixListView = () => import('@/views/MixListView.vue')
|
||||
const Collection = () => import('@/views/Collections/Collection.vue')
|
||||
|
||||
const folder = {
|
||||
path: "/folder/:path",
|
||||
name: "FolderView",
|
||||
component: FolderView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
await useFolderPageStore()
|
||||
.fetchAll(to.params.path, true)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/folder/:path',
|
||||
name: 'FolderView',
|
||||
component: FolderView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
await useFolderPageStore()
|
||||
.fetchAll(to.params.path, true)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const playlists = {
|
||||
path: "/playlists",
|
||||
name: "PlaylistList",
|
||||
component: PlaylistListView,
|
||||
beforeEnter: async () => {
|
||||
state.loading.value = true;
|
||||
await usePlaylistListPageStore()
|
||||
.fetchAll()
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/playlists',
|
||||
name: 'PlaylistList',
|
||||
component: PlaylistListView,
|
||||
beforeEnter: async () => {
|
||||
state.loading.value = true
|
||||
await usePlaylistListPageStore()
|
||||
.fetchAll()
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const playlistView = {
|
||||
path: "/playlist/:pid",
|
||||
name: "PlaylistView",
|
||||
component: PlaylistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
await usePlaylistPageStore()
|
||||
.fetchAll(to.params.pid)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/playlist/:pid',
|
||||
name: 'PlaylistView',
|
||||
component: PlaylistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
await usePlaylistPageStore()
|
||||
.fetchAll(to.params.pid)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const albumView = {
|
||||
path: "/albums/:albumhash",
|
||||
name: "AlbumView",
|
||||
component: AlbumView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
const store = useAlbumPageStore();
|
||||
path: '/albums/:albumhash',
|
||||
name: 'AlbumView',
|
||||
component: AlbumView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
const store = useAlbumPageStore()
|
||||
|
||||
await store.fetchTracksAndArtists(to.params.albumhash).then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
await store.fetchTracksAndArtists(to.params.albumhash).then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const artistView = {
|
||||
path: "/artists/:hash",
|
||||
name: "ArtistView",
|
||||
component: ArtistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
path: '/artists/:hash',
|
||||
name: 'ArtistView',
|
||||
component: ArtistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
|
||||
await useArtistPageStore()
|
||||
.getData(to.params.hash)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
await useArtistPageStore()
|
||||
.getData(to.params.hash)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const NowPlayingView = {
|
||||
path: "/nowplaying/:tab",
|
||||
name: "NowPlaying",
|
||||
component: NowPlaying,
|
||||
};
|
||||
path: '/nowplaying/:tab',
|
||||
name: 'NowPlaying',
|
||||
component: NowPlaying,
|
||||
}
|
||||
|
||||
const LyricsView = {
|
||||
path: "/lyrics",
|
||||
name: "LyricsView",
|
||||
component: Lyrics,
|
||||
};
|
||||
path: '/lyrics',
|
||||
name: 'LyricsView',
|
||||
component: Lyrics,
|
||||
}
|
||||
|
||||
const ArtistTracks = {
|
||||
path: "/artists/:hash/tracks",
|
||||
name: "ArtistTracks",
|
||||
component: ArtistTracksView,
|
||||
};
|
||||
path: '/artists/:hash/tracks',
|
||||
name: 'ArtistTracks',
|
||||
component: ArtistTracksView,
|
||||
}
|
||||
|
||||
const artistDiscography = {
|
||||
path: "/artists/:hash/discography/:type",
|
||||
name: "ArtistDiscographyView",
|
||||
component: ArtistDiscographyView,
|
||||
};
|
||||
path: '/artists/:hash/discography/:type',
|
||||
name: 'ArtistDiscographyView',
|
||||
component: ArtistDiscographyView,
|
||||
}
|
||||
|
||||
const settings = {
|
||||
path: "/settings/:tab",
|
||||
name: "SettingsView",
|
||||
component: SettingsView,
|
||||
};
|
||||
path: '/settings/:tab',
|
||||
name: 'SettingsView',
|
||||
component: SettingsView,
|
||||
}
|
||||
|
||||
const search = {
|
||||
path: "/search/:page",
|
||||
name: "SearchView",
|
||||
component: SearchView,
|
||||
};
|
||||
path: '/search/:page',
|
||||
name: 'SearchView',
|
||||
component: SearchView,
|
||||
}
|
||||
|
||||
const favorites = {
|
||||
path: "/favorites",
|
||||
name: "FavoritesView",
|
||||
component: FavoritesView,
|
||||
};
|
||||
path: '/favorites',
|
||||
name: 'FavoritesView',
|
||||
component: FavoritesView,
|
||||
}
|
||||
|
||||
const favoriteAlbums = {
|
||||
path: "/favorites/albums",
|
||||
name: "FavoriteAlbums",
|
||||
component: FavoriteCardScroller,
|
||||
};
|
||||
path: '/favorites/albums',
|
||||
name: 'FavoriteAlbums',
|
||||
component: FavoriteCardScroller,
|
||||
}
|
||||
|
||||
const favoriteArtists = {
|
||||
path: "/favorites/artists",
|
||||
name: "FavoriteArtists",
|
||||
component: FavoriteCardScroller,
|
||||
};
|
||||
path: '/favorites/artists',
|
||||
name: 'FavoriteArtists',
|
||||
component: FavoriteCardScroller,
|
||||
}
|
||||
|
||||
const favoriteTracks = {
|
||||
path: "/favorites/tracks",
|
||||
name: "FavoriteTracks",
|
||||
component: FavoriteTracks,
|
||||
};
|
||||
path: '/favorites/tracks',
|
||||
name: 'FavoriteTracks',
|
||||
component: FavoriteTracks,
|
||||
}
|
||||
|
||||
const notFound = {
|
||||
name: "NotFound",
|
||||
path: "/:pathMatch(.*)",
|
||||
component: NotFound,
|
||||
};
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)',
|
||||
component: NotFound,
|
||||
}
|
||||
|
||||
const Home = {
|
||||
path: "/",
|
||||
name: "Home",
|
||||
component: HomeView,
|
||||
};
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: HomeView,
|
||||
}
|
||||
|
||||
const AlbumListView = {
|
||||
path: "/albums",
|
||||
name: "AlbumListView",
|
||||
component: AlbumList,
|
||||
};
|
||||
path: '/albums',
|
||||
name: 'AlbumListView',
|
||||
component: AlbumList,
|
||||
}
|
||||
|
||||
const Stats = {
|
||||
path: "/stats",
|
||||
name: "StatsView",
|
||||
component: StatsView,
|
||||
};
|
||||
path: '/stats',
|
||||
name: 'StatsView',
|
||||
component: StatsView,
|
||||
}
|
||||
|
||||
const ArtistListView = {
|
||||
...AlbumListView,
|
||||
path: "/artists",
|
||||
name: "ArtistListView",
|
||||
};
|
||||
...AlbumListView,
|
||||
path: '/artists',
|
||||
name: 'ArtistListView',
|
||||
}
|
||||
|
||||
const Mix = {
|
||||
path: '/mix/:mixid',
|
||||
name: 'MixView',
|
||||
component: MixView,
|
||||
}
|
||||
|
||||
const MixList = {
|
||||
path: '/mixes/:type',
|
||||
name: 'MixListView',
|
||||
component: MixListView,
|
||||
}
|
||||
|
||||
const PageView = {
|
||||
path: '/collections/:collection',
|
||||
name: 'Collection',
|
||||
component: Collection,
|
||||
}
|
||||
|
||||
const routes = [
|
||||
folder,
|
||||
playlists,
|
||||
playlistView,
|
||||
albumView,
|
||||
artistView,
|
||||
artistDiscography,
|
||||
settings,
|
||||
search,
|
||||
notFound,
|
||||
ArtistTracks,
|
||||
favorites,
|
||||
favoriteAlbums,
|
||||
favoriteTracks,
|
||||
favoriteArtists,
|
||||
NowPlayingView,
|
||||
Home,
|
||||
AlbumListView,
|
||||
ArtistListView,
|
||||
LyricsView,
|
||||
Stats,
|
||||
];
|
||||
folder,
|
||||
playlists,
|
||||
playlistView,
|
||||
albumView,
|
||||
artistView,
|
||||
artistDiscography,
|
||||
settings,
|
||||
search,
|
||||
notFound,
|
||||
ArtistTracks,
|
||||
favorites,
|
||||
favoriteAlbums,
|
||||
favoriteTracks,
|
||||
favoriteArtists,
|
||||
NowPlayingView,
|
||||
Home,
|
||||
AlbumListView,
|
||||
ArtistListView,
|
||||
LyricsView,
|
||||
Stats,
|
||||
Mix,
|
||||
MixList,
|
||||
PageView,
|
||||
]
|
||||
|
||||
const Routes = {
|
||||
folder: folder.name,
|
||||
playlists: playlists.name,
|
||||
playlist: playlistView.name,
|
||||
album: albumView.name,
|
||||
artist: artistView.name,
|
||||
artistDiscography: artistDiscography.name,
|
||||
settings: settings.name,
|
||||
search: search.name,
|
||||
notFound: notFound.name,
|
||||
artistTracks: ArtistTracks.name,
|
||||
favorites: favorites.name,
|
||||
favoriteAlbums: favoriteAlbums.name,
|
||||
favoriteTracks: favoriteTracks.name,
|
||||
favoriteArtists: favoriteArtists.name,
|
||||
nowPlaying: NowPlayingView.name,
|
||||
Home: Home.name,
|
||||
AlbumList: AlbumListView.name,
|
||||
ArtistList: ArtistListView.name,
|
||||
Lyrics: LyricsView.name,
|
||||
Stats: Stats.name,
|
||||
};
|
||||
folder: folder.name,
|
||||
playlists: playlists.name,
|
||||
playlist: playlistView.name,
|
||||
album: albumView.name,
|
||||
artist: artistView.name,
|
||||
artistDiscography: artistDiscography.name,
|
||||
settings: settings.name,
|
||||
search: search.name,
|
||||
notFound: notFound.name,
|
||||
artistTracks: ArtistTracks.name,
|
||||
favorites: favorites.name,
|
||||
favoriteAlbums: favoriteAlbums.name,
|
||||
favoriteTracks: favoriteTracks.name,
|
||||
favoriteArtists: favoriteArtists.name,
|
||||
nowPlaying: NowPlayingView.name,
|
||||
Home: Home.name,
|
||||
AlbumList: AlbumListView.name,
|
||||
ArtistList: ArtistListView.name,
|
||||
Lyrics: LyricsView.name,
|
||||
Stats: Stats.name,
|
||||
Mix: Mix.name,
|
||||
MixList: MixList.name,
|
||||
Page: PageView.name,
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
mode: "hash",
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
} as RouterOptions);
|
||||
mode: 'hash',
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
} as RouterOptions)
|
||||
|
||||
export { router, Routes };
|
||||
export { router, Routes }
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Setting } from '@/interfaces/settings'
|
||||
import settings from '@/stores/settings'
|
||||
import { SettingType } from '../enums'
|
||||
|
||||
const use_legacy_streaming_endpoint: Setting = {
|
||||
title: 'Use legacy streaming',
|
||||
desc: 'Enable if you experience issues with playback',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().use_legacy_streaming_endpoint,
|
||||
action: () => settings().toggleUseLegacyStreamingEndpoint(),
|
||||
}
|
||||
// const use_legacy_streaming_endpoint: Setting = {
|
||||
// title: 'Use legacy streaming',
|
||||
// desc: 'Enable if you experience issues with playback',
|
||||
// type: SettingType.binary,
|
||||
// state: () => settings().use_legacy_streaming_endpoint,
|
||||
// action: () => settings().toggleUseLegacyStreamingEndpoint(),
|
||||
// }
|
||||
|
||||
const use_silence: Setting = {
|
||||
title: 'Silence padding removal',
|
||||
@@ -38,45 +38,45 @@ const crossfade: Setting = {
|
||||
show_if: () => settings().use_crossfade,
|
||||
}
|
||||
|
||||
const streaming_quality_options = [
|
||||
{
|
||||
title: 'Original',
|
||||
key: 'original',
|
||||
},
|
||||
// {
|
||||
// title: 'High (1024kbps) (FLAC)',
|
||||
// key: '1024',
|
||||
// },
|
||||
// {
|
||||
// title: 'Medium (640kbps) (FLAC)',
|
||||
// key: '640',
|
||||
// },
|
||||
{
|
||||
title: '320kbps',
|
||||
key: '320',
|
||||
},
|
||||
{
|
||||
title: '128kbps',
|
||||
key: '128',
|
||||
},
|
||||
{
|
||||
title: '96kbps',
|
||||
key: '96',
|
||||
},
|
||||
]
|
||||
// const streaming_quality_options = [
|
||||
// {
|
||||
// title: 'Original',
|
||||
// key: 'original',
|
||||
// },
|
||||
// // {
|
||||
// // title: 'High (1024kbps) (FLAC)',
|
||||
// // key: '1024',
|
||||
// // },
|
||||
// // {
|
||||
// // title: 'Medium (640kbps) (FLAC)',
|
||||
// // key: '640',
|
||||
// // },
|
||||
// {
|
||||
// title: '320kbps',
|
||||
// key: '320',
|
||||
// },
|
||||
// {
|
||||
// title: '128kbps',
|
||||
// key: '128',
|
||||
// },
|
||||
// {
|
||||
// title: '96kbps',
|
||||
// key: '96',
|
||||
// },
|
||||
// ]
|
||||
|
||||
const transcoding: Setting = {
|
||||
title: 'Streaming quality',
|
||||
desc: 'Choose the streaming quality of the music',
|
||||
type: SettingType.streaming_quality,
|
||||
state: () => streaming_quality_options.find(option => option.key === settings().streaming_quality),
|
||||
action: (quality: {
|
||||
key: string
|
||||
title: string
|
||||
}) => settings().setStreamingQuality(quality.key),
|
||||
defaultAction: () => {},
|
||||
show_if: () => !settings().use_legacy_streaming_endpoint,
|
||||
options: streaming_quality_options as any,
|
||||
}
|
||||
// const transcoding: Setting = {
|
||||
// title: 'Streaming quality',
|
||||
// desc: 'Choose the streaming quality of the music',
|
||||
// type: SettingType.streaming_quality,
|
||||
// state: () => streaming_quality_options.find(option => option.key === settings().streaming_quality),
|
||||
// action: (quality: {
|
||||
// key: string
|
||||
// title: string
|
||||
// }) => settings().setStreamingQuality(quality.key),
|
||||
// defaultAction: () => {},
|
||||
// show_if: () => !settings().use_legacy_streaming_endpoint,
|
||||
// options: streaming_quality_options as any,
|
||||
// }
|
||||
|
||||
export default [use_legacy_streaming_endpoint, use_silence, transcoding, use_crossfade, crossfade]
|
||||
export default [use_silence, use_crossfade, crossfade]
|
||||
|
||||
@@ -7,7 +7,7 @@ export enum SettingType {
|
||||
root_dirs,
|
||||
free_number_input,
|
||||
locked_number_input,
|
||||
|
||||
|
||||
// custom components 👇
|
||||
quick_actions,
|
||||
profile,
|
||||
@@ -15,5 +15,6 @@ export enum SettingType {
|
||||
pairing,
|
||||
about,
|
||||
streaming_quality,
|
||||
backup
|
||||
backup,
|
||||
secretinput,
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const library = {
|
||||
show_if: loggedInUserIsAdmin,
|
||||
groups: [
|
||||
{
|
||||
title: "Root directories",
|
||||
title: "Folders",
|
||||
icon: FolderSvg,
|
||||
desc: rootRootStrings.desc,
|
||||
settings: [...rootDirSettings],
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { SettingType } from "../enums";
|
||||
import { Setting } from "@/interfaces/settings";
|
||||
import { SettingType } from '../enums'
|
||||
import { Setting } from '@/interfaces/settings'
|
||||
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import useSettingsStore from '@/stores/settings'
|
||||
|
||||
const settings = useSettingsStore;
|
||||
const settings = useSettingsStore
|
||||
|
||||
const disable_np_img: Setting = {
|
||||
title: "Hide album art from the left sidebar",
|
||||
type: SettingType.binary,
|
||||
state: () => !settings().use_np_img,
|
||||
action: () => settings().toggleUseNPImg(),
|
||||
show_if: () => !settings().is_alt_layout,
|
||||
};
|
||||
title: 'Hide album art from the left sidebar',
|
||||
type: SettingType.binary,
|
||||
state: () => !settings().use_np_img,
|
||||
action: () => settings().toggleUseNPImg(),
|
||||
show_if: () => !settings().is_alt_layout,
|
||||
}
|
||||
|
||||
const showNowPlayingOnTabTitle: Setting = {
|
||||
title: "Show Now Playing track on tab title",
|
||||
desc: "Replace current page info with Now Playing track info",
|
||||
type: SettingType.binary,
|
||||
state: () => settings().nowPlayingTrackOnTabTitle,
|
||||
action: () => settings().toggleNowPlayingTrackOnTabTitle(),
|
||||
};
|
||||
title: 'Show Now Playing track on tab title',
|
||||
desc: 'Replace current page info with Now Playing track info',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().nowPlayingTrackOnTabTitle,
|
||||
action: () => settings().toggleNowPlayingTrackOnTabTitle(),
|
||||
}
|
||||
|
||||
const showInlineFavIcon: Setting = {
|
||||
title: 'Show inline favorite icon',
|
||||
desc: 'Show the favorite button next to the track duration',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().showInlineFavIcon,
|
||||
action: () => settings().toggleShowInlineFavIcon(),
|
||||
}
|
||||
|
||||
const highlightFavoriteTracks: Setting = {
|
||||
title: 'Highlight favorite tracks',
|
||||
desc: 'Always show the favorite button for favorited tracks',
|
||||
type: SettingType.binary,
|
||||
state: () => settings()._highlightFavoriteTracks,
|
||||
action: () => settings().toggleHighlightFavoriteTracks(),
|
||||
show_if: () => settings().showInlineFavIcon,
|
||||
}
|
||||
|
||||
export default [disable_np_img, showNowPlayingOnTabTitle];
|
||||
export default [disable_np_img, showNowPlayingOnTabTitle, showInlineFavIcon, highlightFavoriteTracks]
|
||||
|
||||
@@ -1,63 +1,70 @@
|
||||
import { Setting } from "@/interfaces/settings";
|
||||
import {
|
||||
addRootDirs as editRootDirs,
|
||||
triggerScan,
|
||||
} from "@/requests/settings/rootdirs";
|
||||
import { SettingType } from "../enums";
|
||||
import { manageRootDirsStrings as data } from "../strings";
|
||||
import { Setting } from '@/interfaces/settings'
|
||||
import { addRootDirs as editRootDirs, triggerScan } from '@/requests/settings/rootdirs'
|
||||
import { SettingType } from '../enums'
|
||||
import { manageRootDirsStrings as data } from '../strings'
|
||||
|
||||
import useModalStore from "@/stores/modal";
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import useModalStore from '@/stores/modal'
|
||||
import settings from '@/stores/settings'
|
||||
|
||||
const text = data.settings;
|
||||
const text = data.settings
|
||||
|
||||
const change_root_dirs: Setting = {
|
||||
title: text.change,
|
||||
type: SettingType.button,
|
||||
state: null,
|
||||
button_text: () =>
|
||||
`\xa0 \xa0 ${
|
||||
useSettingsStore().root_dirs.length ? "Modify" : "Configure"
|
||||
} \xa0 \xa0`,
|
||||
action: () => useModalStore().showRootDirsPromptModal(),
|
||||
};
|
||||
title: text.change,
|
||||
type: SettingType.button,
|
||||
state: null,
|
||||
button_text: () => `\xa0 \xa0 ${settings().root_dirs.length ? 'Modify' : 'Configure'} \xa0 \xa0`,
|
||||
action: () => useModalStore().showRootDirsPromptModal(),
|
||||
}
|
||||
|
||||
const list_root_dirs: Setting = {
|
||||
title: text.list_root_dirs,
|
||||
type: SettingType.root_dirs,
|
||||
state: () =>
|
||||
useSettingsStore().root_dirs.map((d) => ({
|
||||
title: d,
|
||||
action: () => {
|
||||
editRootDirs([], [d]).then((all_dirs) => {
|
||||
useSettingsStore().setRootDirs(all_dirs);
|
||||
});
|
||||
},
|
||||
})),
|
||||
defaultAction: () => {},
|
||||
action: () => triggerScan(),
|
||||
};
|
||||
title: text.list_root_dirs,
|
||||
type: SettingType.root_dirs,
|
||||
state: () =>
|
||||
settings().root_dirs.map(d => ({
|
||||
title: d,
|
||||
action: () => {
|
||||
editRootDirs([], [d]).then(all_dirs => {
|
||||
settings().setRootDirs(all_dirs)
|
||||
})
|
||||
},
|
||||
})),
|
||||
defaultAction: () => {},
|
||||
action: () => triggerScan(),
|
||||
}
|
||||
|
||||
const enable_scans: Setting = {
|
||||
title: "Enable periodic scans",
|
||||
type: SettingType.binary,
|
||||
state: () => useSettingsStore().enablePeriodicScans,
|
||||
action: () => useSettingsStore().togglePeriodicScans(),
|
||||
};
|
||||
const show_playlists_in_folders: Setting = {
|
||||
title: 'Show playlists in folder view',
|
||||
desc: 'Browse playlists and favorites in folders screen (meant for mobile app)',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().show_playlists_in_folders,
|
||||
action: () => settings().toggleShowPlaylistsInFolders(),
|
||||
}
|
||||
|
||||
const useWatchdog: Setting = {
|
||||
title: "Watch root dirs for new music",
|
||||
experimental: true,
|
||||
type: SettingType.binary,
|
||||
state: () => useSettingsStore().enableWatchDog,
|
||||
action: () => useSettingsStore().toggleWatchdog(),
|
||||
};
|
||||
// const enable_scans: Setting = {
|
||||
// title: "Enable periodic scans",
|
||||
// type: SettingType.binary,
|
||||
// state: () => useSettingsStore().enablePeriodicScans,
|
||||
// action: () => useSettingsStore().togglePeriodicScans(),
|
||||
// };
|
||||
|
||||
const periodicScanInterval: Setting = {
|
||||
title: "Periodic scan interval (minutes)",
|
||||
type: SettingType.free_number_input,
|
||||
state: () => useSettingsStore().periodicInterval,
|
||||
action: (newValue: number) => useSettingsStore().updatePeriodicInterval(newValue),
|
||||
};
|
||||
// const useWatchdog: Setting = {
|
||||
// title: "Watch root dirs for new music",
|
||||
// experimental: true,
|
||||
// type: SettingType.binary,
|
||||
// state: () => useSettingsStore().enableWatchDog,
|
||||
// action: () => useSettingsStore().toggleWatchdog(),
|
||||
// };
|
||||
|
||||
export default [change_root_dirs, list_root_dirs, useWatchdog, enable_scans, periodicScanInterval];
|
||||
// const periodicScanInterval: Setting = {
|
||||
// title: "Periodic scan interval (minutes)",
|
||||
// type: SettingType.free_number_input,
|
||||
// state: () => useSettingsStore().periodicInterval,
|
||||
// action: (newValue: number) => useSettingsStore().updatePeriodicInterval(newValue),
|
||||
// };
|
||||
|
||||
export default [
|
||||
change_root_dirs,
|
||||
list_root_dirs,
|
||||
show_playlists_in_folders,
|
||||
// useWatchdog, enable_scans, periodicScanInterval
|
||||
]
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import lyrics from "./lyrics";
|
||||
import useAuth from "@/stores/auth";
|
||||
import { SettingCategory } from "@/interfaces/settings";
|
||||
import lyrics from './lyrics'
|
||||
import useAuth from '@/stores/auth'
|
||||
import { SettingCategory } from '@/interfaces/settings'
|
||||
|
||||
import LyricsSvg from "@/assets/icons/lyrics.svg?raw";
|
||||
import { loggedInUserIsAdmin } from "../utils";
|
||||
import LyricsSvg from '@/assets/icons/lyrics.svg?raw'
|
||||
import LastfmSvg from '@/assets/icons/lastfm.svg?raw'
|
||||
|
||||
import { loggedInUserIsAdmin } from '../utils'
|
||||
import lastfm from './lastfm'
|
||||
|
||||
export default <SettingCategory>{
|
||||
title: "Plugins",
|
||||
show_if: loggedInUserIsAdmin,
|
||||
groups: [
|
||||
{
|
||||
title: "Lyrics",
|
||||
icon: LyricsSvg,
|
||||
desc: "Finds and displays lyrics from the internet.",
|
||||
settings: lyrics,
|
||||
experimental: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
title: 'Plugins',
|
||||
show_if: loggedInUserIsAdmin,
|
||||
groups: [
|
||||
{
|
||||
title: 'Lyrics',
|
||||
icon: LyricsSvg,
|
||||
desc: 'Finds and displays lyrics from the internet.',
|
||||
settings: lyrics,
|
||||
experimental: true,
|
||||
},
|
||||
{
|
||||
title: 'Last.fm',
|
||||
icon: LastfmSvg,
|
||||
desc: 'Last.fm integration',
|
||||
settings: lastfm,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||