40 Commits

Author SHA1 Message Date
cwilvx
5ff9b67b5e update duration 2024-10-14 17:24:35 +03:00
cwilvx
418f326366 add loader, image and router link 2024-10-14 17:23:11 +03:00
cwilvx
c0c84504e0 fix: spacing on stats 2024-10-14 13:46:40 +03:00
cwilvx
e1e30565de use tabs to render charts 2024-10-14 13:43:58 +03:00
cwilvx
3593e4ac8e add title to stat item 2024-10-13 20:07:15 +03:00
cwilvx
e7276a3552 rearrange stats page 2024-10-13 20:03:48 +03:00
cwilvx
850d573f91 accomodate static date ranges 2024-10-13 19:16:35 +03:00
cwilvx
c78b24f088 add stats section 2024-10-08 00:41:21 +03:00
cwilvx
10e48cb068 update duration 2024-10-05 08:37:39 +03:00
cwilvx
c6e5f9d740 initial stats draft 2024-10-05 08:33:05 +03:00
cwilvx
76bcf51eab update default value for disabled setting 2024-09-21 19:40:13 +03:00
cwilvx
5bc21f98a8 feat: add transcoding and backup & restore settings 2024-09-21 19:39:19 +03:00
cwilvx
4c03644389 keep track order when adding a folder to a playlist 2024-09-08 23:55:22 +03:00
cwilvx
21ffbc3842 align help text on track to the right 2024-09-08 23:21:05 +03:00
cwilvx
0f42c48ca1 try: persisting home page content 2024-09-08 23:04:40 +03:00
cwilvx
f966df7581 show help text on artist tracks 2024-09-08 12:52:32 +03:00
cwilvx
9f714eef75 fix: playback randomly stopping
+ add robots.txt
+ expiriment with the web audio API at @/composables/usePlayer.ts
2024-09-07 22:57:32 +03:00
Mungai Njoroge
1c054f17b9 merge #34 from @Simonh2o
breadcrumb fix & volume slider styles
2024-09-02 20:12:25 +03:00
Simonh2o
c53c937cac login modal overflow responsiveness fix 2024-09-02 19:07:43 +02:00
Simonh2o
90b72b5f1c added some transitions, effects on hover 2024-09-02 18:55:33 +02:00
Simonh2o
d740ed43be login modal inputs, bolder font login greeting 2024-09-02 18:26:54 +02:00
Simonh2o
9899f70657 fixed font on scan interval input 2024-09-02 18:14:54 +02:00
Stannnnn
606ee6cecd Merge remote-tracking branch 'upstream/master' 2024-09-02 17:46:39 +02:00
cwilvx
1aeb3dc1d1 use limit=-1 to fetch all tracks 2024-08-31 12:20:04 +03:00
Mungai Njoroge
bf471049e4 Merge pull request #35 from swingmx/the-big-one
The big one
2024-08-31 12:16:41 +03:00
Simonh2o
9533a0db19 changed placeholder style for other inputs 2024-08-29 02:29:35 +02:00
Simonh2o
e33800281b z-index fix for thumbnails overlapping modal 2024-08-29 01:58:29 +02:00
Simonh2o
1aeddd95b5 tiny changes to the main searchbar 2024-08-29 01:17:37 +02:00
Simonh2o
5cef926675 Placeholder search text more "bold" 2024-08-28 16:09:42 +02:00
Simonh2o
767b35dd08 Fixed issue where scrollbar was smaller after search 2024-08-28 16:09:01 +02:00
Simonh2o
33541ea964 changed bg colors for backforward buttons 2024-08-27 02:53:38 +02:00
Simonh2o
bc2152c45c fixed spacing, e.g properly lined up generic titles with artist headers etc 2024-08-27 02:41:17 +02:00
Simonh2o
227963556c removed the fixed modal height so the window takes up available space 2024-08-26 23:52:50 +02:00
Simonh2o
01254f64f0 Fixed/separated search item styling on right sidebar and main search bar, sidebar scrollbar size fix 2024-07-24 15:52:30 +02:00
Simonh2o
b14ff26932 fixed responsive scroll for login modal 2024-07-23 00:59:51 +02:00
Simonh2o
09bb5ae7b0 added spacing between scan and settings item, equal to what settings menu has 2024-07-04 21:36:43 +02:00
Simonh2o
e954dbf713 more spacing between settings sidebar items & root directories small icons fix 2024-07-04 02:03:11 +02:00
Simonh2o
8fcff3d958 songlist text overflow and duration alignment fix 2024-07-03 23:26:00 +02:00
Simonh2o
8b4605e3bf Added background styling to volume slider bar, reduced main scrollbar border 2024-06-23 01:52:07 +02:00
Simonh2o
ef35c517af Fix for breadcrumb artifact on windows? 2024-06-22 20:36:13 +02:00
63 changed files with 2563 additions and 998 deletions

View File

@@ -1,2 +1,2 @@
User-agent: *
Disallow:
Disallow: /

View File

@@ -147,6 +147,7 @@ onMounted(async () => {
<script lang="ts">
// Detect OS & browser agents and add class
import { defineComponent } from "vue";
import usePlayer from "./composables/usePlayer";
export default defineComponent({
name: "OsAndBrowserSpecificContent",
mounted() {

View File

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.69434 13.6455C5.69434 13.9092 5.80859 14.1641 6.01074 14.3574L11.8027 20.1494C12.0137 20.3516 12.251 20.4482 12.4883 20.4482C13.042 20.4482 13.4375 20.0527 13.4375 19.5254C13.4375 19.2529 13.332 19.0156 13.1562 18.8486L11.1875 16.8535L8.58594 14.4805L10.6426 14.6035H21.3301C21.9014 14.6035 22.3057 14.208 22.3057 13.6455C22.3057 13.0742 21.9014 12.6875 21.3301 12.6875H10.6426L8.59473 12.8105L11.1875 10.4375L13.1562 8.44238C13.332 8.2666 13.4375 8.0293 13.4375 7.75684C13.4375 7.22949 13.042 6.84277 12.4883 6.84277C12.251 6.84277 12.0137 6.93066 11.7852 7.15039L6.01074 12.9336C5.80859 13.1182 5.69434 13.3818 5.69434 13.6455Z" fill="#F2F2F2"/>
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.69434 13.6455C5.69434 13.9092 5.80859 14.1641 6.01074 14.3574L11.8027 20.1494C12.0137 20.3516 12.251 20.4482 12.4883 20.4482C13.042 20.4482 13.4375 20.0527 13.4375 19.5254C13.4375 19.2529 13.332 19.0156 13.1562 18.8486L11.1875 16.8535L8.58594 14.4805L10.6426 14.6035H21.3301C21.9014 14.6035 22.3057 14.208 22.3057 13.6455C22.3057 13.0742 21.9014 12.6875 21.3301 12.6875H10.6426L8.59473 12.8105L11.1875 10.4375L13.1562 8.44238C13.332 8.2666 13.4375 8.0293 13.4375 7.75684C13.4375 7.22949 13.042 6.84277 12.4883 6.84277C12.251 6.84277 12.0137 6.93066 11.7852 7.15039L6.01074 12.9336C5.80859 13.1182 5.69434 13.3818 5.69434 13.6455Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

After

Width:  |  Height:  |  Size: 745 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.84421 24.8972H21.968C24.4974 24.8972 25.8026 23.5919 25.8026 21.0914V6.82921C25.8026 4.32656 24.4974 3.02344 21.968 3.02344H5.84421C3.31484 3.02344 2 4.31695 2 6.82921V21.0914C2 23.6016 3.31484 24.8972 5.84421 24.8972ZM5.81186 22.6013C4.82795 22.6013 4.29592 22.0969 4.29592 21.0661V10.2389C4.29592 9.21772 4.82795 8.70585 5.81186 8.70585H21.9812C22.9651 8.70585 23.5067 9.21772 23.5067 10.2389V21.0661C23.5067 22.0969 22.9651 22.6013 21.9812 22.6013H5.81186ZM11.6516 12.7673H12.343C12.7594 12.7673 12.8947 12.6438 12.8947 12.2273V11.5359C12.8947 11.1194 12.7594 10.9863 12.343 10.9863H11.6516C11.2351 10.9863 11.0902 11.1194 11.0902 11.5359V12.2273C11.0902 12.6438 11.2351 12.7673 11.6516 12.7673ZM15.481 12.7673H16.1724C16.5889 12.7673 16.7359 12.6438 16.7359 12.2273V11.5359C16.7359 11.1194 16.5889 10.9863 16.1724 10.9863H15.481C15.0645 10.9863 14.9197 11.1194 14.9197 11.5359V12.2273C14.9197 12.6438 15.0645 12.7673 15.481 12.7673ZM19.3126 12.7673H20.004C20.4205 12.7673 20.5653 12.6438 20.5653 12.2273V11.5359C20.5653 11.1194 20.4205 10.9863 20.004 10.9863H19.3126C18.8961 10.9863 18.7609 11.1194 18.7609 11.5359V12.2273C18.7609 12.6438 18.8961 12.7673 19.3126 12.7673ZM7.8221 16.5382H8.50178C8.92999 16.5382 9.06522 16.4147 9.06522 15.9982V15.3068C9.06522 14.8903 8.92999 14.7668 8.50178 14.7668H7.8221C7.39389 14.7668 7.25866 14.8903 7.25866 15.3068V15.9982C7.25866 16.4147 7.39389 16.5382 7.8221 16.5382ZM11.6516 16.5382H12.343C12.7594 16.5382 12.8947 16.4147 12.8947 15.9982V15.3068C12.8947 14.8903 12.7594 14.7668 12.343 14.7668H11.6516C11.2351 14.7668 11.0902 14.8903 11.0902 15.3068V15.9982C11.0902 16.4147 11.2351 16.5382 11.6516 16.5382ZM15.481 16.5382H16.1724C16.5889 16.5382 16.7359 16.4147 16.7359 15.9982V15.3068C16.7359 14.8903 16.5889 14.7668 16.1724 14.7668H15.481C15.0645 14.7668 14.9197 14.8903 14.9197 15.3068V15.9982C14.9197 16.4147 15.0645 16.5382 15.481 16.5382ZM19.3126 16.5382H20.004C20.4205 16.5382 20.5653 16.4147 20.5653 15.9982V15.3068C20.5653 14.8903 20.4205 14.7668 20.004 14.7668H19.3126C18.8961 14.7668 18.7609 14.8903 18.7609 15.3068V15.9982C18.7609 16.4147 18.8961 16.5382 19.3126 16.5382ZM7.8221 20.3187H8.50178C8.92999 20.3187 9.06522 20.1877 9.06522 19.7691V19.0798C9.06522 18.6612 8.92999 18.5398 8.50178 18.5398H7.8221C7.39389 18.5398 7.25866 18.6612 7.25866 19.0798V19.7691C7.25866 20.1877 7.39389 20.3187 7.8221 20.3187ZM11.6516 20.3187H12.343C12.7594 20.3187 12.8947 20.1877 12.8947 19.7691V19.0798C12.8947 18.6612 12.7594 18.5398 12.343 18.5398H11.6516C11.2351 18.5398 11.0902 18.6612 11.0902 19.0798V19.7691C11.0902 20.1877 11.2351 20.3187 11.6516 20.3187ZM15.481 20.3187H16.1724C16.5889 20.3187 16.7359 20.1877 16.7359 19.7691V19.0798C16.7359 18.6612 16.5889 18.5398 16.1724 18.5398H15.481C15.0645 18.5398 14.9197 18.6612 14.9197 19.0798V19.7691C14.9197 20.1877 15.0645 20.3187 15.481 20.3187Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 27 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.84421 21.825H23.3062C25.541 21.825 26.8558 20.5198 26.8558 18.0192V5.96718C26.8558 3.46664 25.5314 2.16141 23.0116 2.16141H12.1076C11.2706 2.16141 10.7627 1.96758 10.1219 1.43625L9.44928 0.892499C8.62944 0.217265 8.02335 0 6.79569 0H3.47905C1.29539 0 0 1.27523 0 3.73405V18.0192C0 20.5294 1.31484 21.825 3.84421 21.825ZM3.97733 19.5291C2.88772 19.5291 2.29592 18.967 2.29592 17.8263V3.93233C2.29592 2.85655 2.88209 2.28631 3.93538 2.28631H6.195C7.01273 2.28631 7.50468 2.48764 8.15929 3.02108L8.82984 3.57444C9.64429 4.23256 10.2696 4.45944 11.4973 4.45944H22.8689C23.9489 4.45944 24.5599 5.03108 24.5599 6.16968V17.8359C24.5599 18.967 23.9489 19.5291 22.8689 19.5291H3.97733ZM1.43319 8.87716H25.432V6.85241H1.43319V8.87716Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 840 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 14.0029C2 17.5576 3.03008 21.4841 4.76539 24.5308C5.10476 25.1148 5.7589 25.285 6.36851 24.9477C6.94717 24.6318 7.11311 23.9755 6.76413 23.3425C5.20647 20.5042 4.336 17.132 4.336 14.0029C4.336 7.60233 8.2496 3.336 14.1259 3.336C19.9946 3.336 23.9157 7.60233 23.9157 14.0029C23.9157 17.132 23.0356 20.5042 21.478 23.3425C21.129 23.9755 21.2949 24.6318 21.8736 24.9477C22.4832 25.285 23.1469 25.1148 23.4767 24.5308C25.212 21.4841 26.2517 17.5576 26.2517 14.0029C26.2517 6.1907 21.4137 1 14.1259 1C6.82836 1 2 6.1907 2 14.0029ZM5.9485 24.2188C6.36686 25.6108 7.57225 26.2424 8.97803 25.8262C10.3721 25.4036 11.0155 24.1811 10.5854 22.787L8.90115 17.2223C8.4828 15.8399 7.27952 15.1966 5.87374 15.6128C4.47968 16.0429 3.83632 17.2579 4.2664 18.6637L5.9485 24.2188ZM22.2936 24.2188L23.9757 18.6637C24.4058 17.2483 23.772 16.0429 22.3684 15.6128C20.9626 15.1966 19.7689 15.8399 19.3409 17.2223L17.6567 22.787C17.2266 24.1907 17.87 25.4036 19.2641 25.8262C20.6795 26.2424 21.8752 25.6108 22.2936 24.2188Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 5.55171V23.1659C5 25.501 6.20703 26.7176 8.52288 26.7176H20.078C22.3939 26.7176 23.5892 25.501 23.5892 23.1659V5.55171C23.5892 3.21664 22.3939 2 20.078 2H8.52288C6.20703 2 5 3.21664 5 5.55171ZM7.29592 5.68483C7.29592 4.7703 7.75975 4.29592 8.69444 4.29592H19.9044C20.8391 4.29592 21.2933 4.7703 21.2933 5.68483V23.0328C21.2933 23.9377 20.8294 24.4217 19.9044 24.4217H8.69444C7.75975 24.4217 7.29592 23.9377 7.29592 23.0328V5.68483ZM14.3005 22.6447C16.9215 22.6447 19.0332 20.5447 19.0332 17.9141C19.0332 15.2771 16.9215 13.1909 14.3005 13.1813C11.6794 13.1675 9.56772 15.2771 9.56772 17.9141C9.56772 20.5447 11.6794 22.6447 14.3005 22.6447ZM14.3005 19.8043C13.261 19.8043 12.406 18.961 12.406 17.9141C12.406 16.8277 13.2301 16.0079 14.3005 16.0079C15.3591 16.0079 16.1928 16.8277 16.1928 17.9141C16.1928 18.961 15.3591 19.8043 14.3005 19.8043ZM14.2984 11.4596C15.7927 11.4596 17.0283 10.257 17.0049 8.75303C16.9932 7.24905 15.7927 6.0678 14.2984 6.05397C12.7944 6.04436 11.5939 7.23944 11.5939 8.75303C11.5939 10.257 12.7944 11.4596 14.2984 11.4596ZM14.3005 10.0069C13.5978 10.0069 13.0465 9.43436 13.0465 8.75303C13.0465 8.02694 13.5978 7.49701 14.3005 7.49701C14.9797 7.49701 15.5544 8.05787 15.5544 8.75303C15.5544 9.43436 14.9914 10.0069 14.3005 10.0069Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.105 24.21C18.7369 24.21 24.2121 18.7273 24.2121 12.105C24.2121 5.47312 18.7273 0 12.0954 0C5.47523 0 0 5.47312 0 12.105C0 18.7273 5.48484 24.21 12.105 24.21ZM12.105 21.8255C6.71085 21.8255 2.39412 17.4991 2.39412 12.105C2.39412 6.71085 6.70124 2.38452 12.0954 2.38452C17.4895 2.38452 21.8276 6.71085 21.8276 12.105C21.8276 17.4991 17.4991 21.8255 12.105 21.8255Z" fill="currentColor"/>
<path d="M12.7392 17.4328C13.4478 17.4328 13.872 16.9985 13.872 16.2073V7.98889C13.872 7.21851 13.4032 6.76124 12.646 6.76124C12.1428 6.76124 11.7727 6.89741 11.2172 7.27312L9.18593 8.64163C8.87328 8.86077 8.73828 9.07663 8.73828 9.42444C8.73828 9.85733 9.06172 10.2373 9.5021 10.2373C9.71281 10.2373 9.84476 10.2117 10.1417 10.0073L11.5416 9.08295H11.6396V16.2073C11.6396 16.9985 12.0596 17.4328 12.7392 17.4328Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 916 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.105 24.21C18.7622 24.21 24.2121 18.7483 24.2121 12.105C24.2121 5.45203 18.7718 0 12.1146 0C11.3986 0 10.999 0.425623 10.999 1.12359V5.44992C10.999 6.04781 11.4138 6.5032 11.9979 6.5032C12.5937 6.5032 13.0064 6.04781 13.0064 5.44992V1.07062L11.9899 2.3592C17.4581 2.30436 21.8159 6.66866 21.8159 12.105C21.8159 17.4907 17.5076 21.8255 12.105 21.8255C6.70452 21.8255 2.37491 17.4907 2.38452 12.105C2.39412 9.77155 3.21397 7.62608 4.58718 5.96459C5.021 5.36413 5.08452 4.7353 4.59022 4.23656C4.10437 3.74414 3.30586 3.79617 2.78368 4.45944C1.06149 6.54327 0 9.22288 0 12.105C0 18.7483 5.45953 24.21 12.105 24.21Z" fill="currentColor"/>
<path d="M13.9884 13.8957C14.9564 12.883 14.7562 11.5084 13.605 10.7244L7.6315 6.60785C6.93588 6.13653 6.2569 6.83145 6.72611 7.51324L10.833 13.4868C11.6266 14.6401 13.0012 14.8499 13.9884 13.8957Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 947 B

View File

@@ -231,7 +231,7 @@ $g-border: solid 1px $gray5;
.isSmall {
.songlist-item {
grid-template-columns: 2fr 5.5rem !important;
grid-template-columns: 2fr 7.5rem !important;
// disable hover on mobile
// to prevent tap effect
@@ -263,7 +263,7 @@ $g-border: solid 1px $gray5;
.isMedium {
// hide album column
.songlist-item {
grid-template-columns: 1.75rem 1.5fr 1fr 5.5rem;
grid-template-columns: 1.75rem 1.5fr 1fr 7.5rem;
}
.song-album {

View File

@@ -175,7 +175,7 @@ button {
}
.spinner {
border: solid 3px rgb(0, 0, 0);
border: solid 3px rgb(221, 217, 217);
border-top: solid 3px transparent;
border-left: solid 3px transparent;
border-radius: 50%;

View File

@@ -1,7 +1,7 @@
/* Total width */
.designatedOS ::-webkit-scrollbar {
background-color: $body;
width: 14px;
width: 12px;
}
/* Background of the scrollbar except button or resizer */
@@ -13,7 +13,7 @@
.designatedOS ::-webkit-scrollbar-thumb {
background-color: $gray2;
border-radius: 16px;
border: 4px solid $body;
border: 3px solid $body;
}
.designatedOS ::-webkit-scrollbar-thumb:hover {
@@ -73,3 +73,12 @@
.designatedOS .settingsmodalcontent::-webkit-scrollbar-thumb {
border-color: $black;
}
/* Login modal */
.designatedOS .loginmodal .alcontent::-webkit-scrollbar-track {
background-color: $black;
}
.designatedOS .loginmodal .alcontent::-webkit-scrollbar-thumb {
border-color: $black;
}

View File

@@ -11,8 +11,8 @@
</template>
<script setup lang="ts">
import { AlbumDisc } from '@/interfaces'
import PlaySvg from '@/assets/icons/play.svg'
import { AlbumDisc } from '@/interfaces'
defineProps<{
album_disc: AlbumDisc
@@ -45,6 +45,7 @@ defineEmits<{
cursor: pointer;
display: flex;
align-items: center;
transition: opacity 0.2s ease-out;
svg {
height: 12px;

View File

@@ -1,134 +1,135 @@
<template>
<button class="speaker" @wheel.passive="handleMouseWheel">
<div class="icon" @click="settings.toggleMute">
<VolumeMuteSvg v-if="settings.mute || settings.volume == 0.0" />
<VolumeMidSvg v-else-if="settings.volume > 0.75" />
<VolumeLowSvg v-else-if="settings.volume > 0" />
</div>
<div class="dialog rounded-sm pad-sm">
<input
id="volume"
type="range"
name="volume"
max="1"
min="0"
step="0.01"
:value="settings.volume"
@input="changeVolume"
:style="{
backgroundSize: `${(settings.volume / 1) * 100}% 100%`,
}"
/>
<div className="volume_indicator">{{ ((settings.volume / 1) * 100).toFixed(0) }}</div>
</div>
</button>
<button class="speaker" @wheel.passive="handleMouseWheel">
<div class="icon" @click="settings.toggleMute">
<VolumeMuteSvg v-if="settings.mute || settings.volume == 0.0" />
<VolumeMidSvg v-else-if="settings.volume > 0.75" />
<VolumeLowSvg v-else-if="settings.volume > 0" />
</div>
<div class="dialog rounded-sm pad-sm">
<input
id="volume"
type="range"
name="volume"
max="1"
min="0"
step="0.01"
:value="settings.volume"
@input="changeVolume"
:style="{
backgroundSize: `${(settings.volume / 1) * 100}% 100%`,
}"
/>
<div className="volume_indicator">{{ ((settings.volume / 1) * 100).toFixed(0) }}</div>
</div>
</button>
</template>
<script setup lang="ts">
import VolumeLowSvg from "@/assets/icons/volume-low.svg";
import VolumeMidSvg from "@/assets/icons/volume-mid.svg";
import VolumeMuteSvg from "@/assets/icons/volume-mute.svg";
import useSettingsStore from "@/stores/settings";
import VolumeLowSvg from '@/assets/icons/volume-low.svg'
import VolumeMidSvg from '@/assets/icons/volume-mid.svg'
import VolumeMuteSvg from '@/assets/icons/volume-mute.svg'
import useSettingsStore from '@/stores/settings'
const settings = useSettingsStore();
const settings = useSettingsStore()
const changeVolume = (event: Event) => {
const target = event.target as HTMLInputElement;
settings.setVolume(parseFloat(target.value));
};
const target = event.target as HTMLInputElement
settings.setVolume(parseFloat(target.value))
}
const handleMouseWheel = (event: WheelEvent) => {
const delta = event.deltaY / 1000;
let newVolume = settings.volume - delta / 3;
const delta = event.deltaY / 1000
let newVolume = settings.volume - delta / 3
if (newVolume > 1) {
newVolume = 1;
}
if (newVolume > 1) {
newVolume = 1
}
if (newVolume < 0) {
newVolume = 0;
}
if (newVolume < 0) {
newVolume = 0
}
settings.setVolume(newVolume);
};
settings.setVolume(newVolume)
}
</script>
<style lang="scss">
.b-bar .right-group button.speaker {
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.speaker {
position: relative;
position: relative;
.icon {
height: 100%;
width: 100%;
display: grid;
place-items: center;
}
svg {
transform: scale(0.75);
}
.dialog {
position: absolute;
cursor: default;
bottom: 56px;
left: -1px;
height: 48px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 4px;
background-color: $gray;
border-top: 1px solid $gray3;
border-bottom: 1px solid $gray3;
border-right: 1px solid $gray3;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
-webkit-font-smoothing: antialiased;
transform: rotate(270deg) translateX(-50%) perspective(1px);
transform-origin: left top;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease-out, visibility 0.2s ease-out;
input {
width: max-content;
max-width: 87px;
margin: 0;
touch-action: pan-x;
&::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
&::-moz-range-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
.icon {
height: 100%;
width: 100%;
display: grid;
place-items: center;
}
svg {
transform: scale(0.75);
}
}
&:hover {
.dialog {
opacity: 1;
visibility: visible;
}
}
position: absolute;
cursor: default;
bottom: 56px;
left: -1px;
height: 48px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 4px;
background-color: $gray;
border-top: 1px solid $gray3;
border-bottom: 1px solid $gray3;
border-right: 1px solid $gray3;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
-webkit-font-smoothing: antialiased;
transform: rotate(270deg) translateX(-50%) perspective(1px);
transform-origin: left top;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease-out, visibility 0.2s ease-out;
.volume_indicator {
font-weight: 600;
width: 24px;
height: 18px;
transform: rotate(90deg) translate3d(0, 0, 0);
}
input {
width: max-content;
max-width: 87px;
margin: 0;
touch-action: pan-x;
background: linear-gradient(to top, #ffffff, #ffffff) 0% 50% no-repeat, $gray4;
&::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
&::-moz-range-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
}
}
&:hover {
.dialog {
opacity: 1;
visibility: visible;
}
}
.volume_indicator {
font-weight: 600;
width: 24px;
height: 18px;
transform: rotate(90deg) translate3d(0, 0, 0);
}
}
</style>

View File

@@ -99,6 +99,11 @@ const browselist = [
action: triggerScan,
class: "reload",
},
{
title: "Stats",
icon: AlbumIcon,
route: Routes.Stats,
}
];
</script>

View File

@@ -21,11 +21,11 @@
<script setup lang="ts">
import useTabStore from '@/stores/tabs'
import AvatarWithDropdown from '@/components/nav/AvatarWithDropdown.vue'
import DashBoard from './Home/Main.vue'
import Queue from './Queue.vue'
import Search from './Search/Main.vue'
import SearchInput from './SearchInput.vue'
import AvatarWithDropdown from '@/components/nav/AvatarWithDropdown.vue'
const tabs = useTabStore()
</script>
@@ -52,6 +52,24 @@ const tabs = useTabStore()
height: 2.5rem;
margin: 1rem;
width: 100%;
#ginner {
button {
width: 2rem;
height: 2rem;
margin-left: 4px;
margin-right: $smallest;
> svg {
width: 1.75rem;
height: 1.75rem;
}
}
> .clear_input {
width: 2rem;
height: 2rem;
}
}
}
.r-content {
@@ -82,6 +100,6 @@ const tabs = useTabStore()
}
.designatedOS .r-sidebar > .r-content > .r-queue > .queue-virtual-scroller > .scroller::-webkit-scrollbar-thumb {
border: 4px solid $gray;
border-color: $gray;
}
</style>

View File

@@ -1,75 +1,75 @@
<template>
<div id="right-tabs" :class="{ tabContent: tabContent }">
<div class="tabheaders">
<button
v-for="tab in tabs"
:key="tab"
class="tab circular"
:class="{ activetab: tab === currentTab }"
@click="$emit('switchTab', tab)"
>
{{ tab }}
</button>
</div>
<div id="right-tabs" :class="{ tabContent: tabContent }">
<div class="tabheaders">
<button
v-for="tab in tabs"
:key="tab"
class="tab circular"
:class="{ activetab: tab === currentTab }"
@click="$emit('switchTab', tab)"
>
{{ tab }}
</button>
</div>
<div v-if="tabContent" id="tab-content" v-auto-animate>
<slot />
<div v-if="tabContent" id="tab-content" v-auto-animate>
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
tabs: string[];
currentTab: string;
tabContent?: boolean;
}>();
tabs: string[]
currentTab: string
tabContent?: boolean
}>()
defineEmits<{
(e: "switchTab", tab: string): void;
}>();
(e: 'switchTab', tab: string): void
}>()
</script>
<style lang="scss">
#right-tabs {
display: grid;
position: absolute; // TODO: Find a way to fix scrollability without using position absolute.
overflow: hidden;
display: grid;
position: absolute; // TODO: Find a way to fix scrollability without using position absolute.
overflow: hidden;
height: 100%;
width: 100%;
height: 100%;
width: 100%;
.tab-buttons-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.tab-buttons-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.vue-recycle-scroller {
padding: 0 $small;
}
.vue-recycle-scroller {
padding: 0 $small;
}
.cardlistrow {
grid-template-columns: repeat(auto-fill, minmax(8.1rem, 1fr));
}
.cardlistrow {
grid-template-columns: repeat(auto-fill, minmax(8.1rem, 1fr));
}
}
#tab-content {
height: 100%;
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: 100%;
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.designatedOS #tab-content::-webkit-scrollbar-track {
background-color: $gray;
background-color: $gray;
}
.designatedOS #tab-content::-webkit-scrollbar-thumb {
border: 4px solid $gray;
border-color: $gray;
}
#right-tabs.tabContent {
grid-template-rows: min-content 1fr;
grid-template-rows: min-content 1fr;
}
</style>

View File

@@ -31,7 +31,11 @@
@blur.prevent="removeFocusedClass"
@focus.prevent="addFocusedClass"
/>
<div class="clear_input circular noSelect" :class="{ active: search.query.length > 0 }" @click.stop="clearInput">
<div
class="clear_input circular noSelect"
:class="{ active: search.query.length > 0 }"
@click.stop="clearInput"
>
<CancelSvg />
</div>
</div>
@@ -39,57 +43,57 @@
</template>
<script setup lang="ts">
import useSearch from "@/stores/search";
import useSettings from "@/stores/settings";
import useTabStore from "@/stores/tabs";
import { ref } from "vue";
import useSearch from '@/stores/search'
import useSettings from '@/stores/settings'
import useTabStore from '@/stores/tabs'
import { ref } from 'vue'
import CancelSvg from "@/assets/icons/a.svg";
import BackSvg from "@/assets/icons/arrow.svg";
import SearchSvg from "@/assets/icons/search.svg";
import { Routes } from "@/router";
import CancelSvg from '@/assets/icons/a.svg'
import BackSvg from '@/assets/icons/arrow.svg'
import SearchSvg from '@/assets/icons/search.svg'
import { Routes } from '@/router'
const props = defineProps<{
on_nav?: boolean;
}>();
on_nav?: boolean
}>()
const tabs = useTabStore();
const search = useSearch();
const settings = useSettings();
const tabs = useTabStore()
const search = useSearch()
const settings = useSettings()
// HANDLE FOCUS
const inputRef = ref<HTMLInputElement | null>(null);
const inputRef = ref<HTMLInputElement | null>(null)
// NOTE: Functions are used because classes are added to the sorrounding element
// and not the input itself.
function addFocusedClass() {
if (inputRef.value) {
inputRef.value.classList.add("search-focused");
inputRef.value.classList.add('search-focused')
}
}
function removeFocusedClass() {
if (inputRef.value) {
inputRef.value.classList.remove("search-focused");
inputRef.value.classList.remove('search-focused')
}
}
function clearInput() {
search.query = "";
search.query = ''
if (inputRef.value) {
inputRef.value.focus();
inputRef.value.focus()
}
}
// @end
function handleButton() {
if (props.on_nav) return;
if (props.on_nav) return
if (tabs.current === tabs.tabs.search) {
tabs.switchToQueue();
tabs.switchToQueue()
} else {
tabs.switchToSearch();
tabs.switchToSearch()
}
}
</script>
@@ -104,7 +108,7 @@ function handleButton() {
<style lang="scss">
.right > .gsearch-input > #ginner > input {
width: 140px;
width: 150px;
@include allPhones {
width: 100%;
@@ -127,10 +131,11 @@ function handleButton() {
button {
background: transparent;
border: none;
width: 2rem;
height: 2rem;
width: 1.625rem;
height: 1.625rem;
padding: 0;
margin-left: 4px;
margin-left: 6px;
margin-right: $smaller;
border-radius: 3rem;
cursor: pointer;
flex-shrink: 0;
@@ -166,6 +171,11 @@ function handleButton() {
font-weight: 600;
padding-right: $small;
}
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.clear_input {
@@ -179,6 +189,7 @@ function handleButton() {
display: grid;
place-items: center;
flex-shrink: 0;
&:hover {
background-color: $gray;

View File

@@ -0,0 +1,179 @@
<template>
<div class="backup-restore">
<button class="backupnow" @click="doBackup">Backup</button>
<div class="separator"></div>
<h4>Restore backup</h4>
<div class="helptext">
You have {{ backups.length }} backup{{ backups.length !== 1 ? 's' : '' }} in your backup directory.
</div>
<div></div>
<br />
<div class="itemlist">
<div class="item rounded-sm" v-for="backup in backups" :key="backup.name">
<div class="texts">
<div class="item__info">
<div class="item__date">
{{ backup.date }}
<!-- <span class="item__name">{{ backup.name }}</span> -->
</div>
</div>
<div class="item__stats">
<div class="item__playlists">
{{ backup.playlists }} playlist{{ backup.playlists !== 1 ? 's' : '' }}
</div>
•
<div class="item__scrobbles">
{{ backup.scrobbles }} scrobble{{ backup.scrobbles !== 1 ? 's' : '' }}
</div>
•
<div class="item__favorites">
{{ backup.favorites }} favorite{{ backup.favorites !== 1 ? 's' : '' }}
</div>
</div>
</div>
<div class="buttons">
<DeleteSvg @click="() => deleteBackup(backup.name)" />
<button class="restore" @click="() => restore(backup.name)">Restore</button>
</div>
</div>
</div>
<button class="restore-all" @click="() => restore()">Restore All</button>
</div>
</template>
<script setup lang="ts">
import { backupNow, getBackups, restoreBackup, deleteBackup as deleteBackupReq } from '@/requests/settings'
import { onMounted, ref } from 'vue'
import { useToast } from '@/stores/notification'
import DeleteSvg from '@/assets/icons/delete.svg'
const toast = useToast()
interface Backup {
name: string
playlists: number
scrobbles: number
favorites: number
date: string
}
const backups = ref<Backup[]>([])
onMounted(async () => {
backups.value = await getBackups()
})
async function doBackup() {
const res = await backupNow()
if (res.status === 200) {
toast.showSuccess('Backup created')
backups.value.unshift(res.data)
} else {
toast.showError(res.data.msg)
}
}
async function restore(backup_dir?: string) {
const res = await restoreBackup(backup_dir)
if (res.status === 200) {
toast.showSuccess(res.data.msg)
} else {
toast.showError(res.data.msg)
}
}
async function deleteBackup(backup_dir: string) {
const res = await deleteBackupReq(backup_dir)
if (res.status === 200) {
toast.showSuccess(res.data.msg)
backups.value = backups.value.filter(backup => backup.name !== backup_dir)
} else {
toast.showError(res.data.msg)
}
}
</script>
<style lang="scss">
.backup-restore {
position: relative;
.itemlist {
display: grid;
gap: $small;
.item {
display: grid;
grid-template-columns: 1fr 100px;
gap: 1rem;
padding: 1rem;
border: solid 1px $gray3;
align-items: center;
.texts {
display: flex;
flex-direction: column;
gap: $small;
}
}
.item .item__info {
display: flex;
justify-content: space-between;
}
.item .item__info .item__name {
font-size: 0.5rem;
color: $gray3;
font-family: 'SF Mono';
}
.item__stats {
display: flex;
gap: $small;
font-size: 0.8rem;
color: $gray1;
}
}
.restore-all {
width: 100%;
margin-top: 2rem;
}
.backupnow {
position: absolute;
right: -0.5rem;
top: -2.75rem;
}
.separator {
width: calc(100% + 0.5rem);
margin-top: 1rem;
}
.helptext {
font-size: small;
color: $gray1;
font-weight: 400;
margin-top: -0.25rem;
}
.buttons {
display: flex;
gap: 1rem;
align-items: center;
svg {
height: 1.2rem;
cursor: pointer;
}
svg:hover {
color: $red;
}
}
}
</style>

View File

@@ -18,28 +18,28 @@
</template>
<script setup lang="ts">
import DeleteSvg from "@/assets/icons/delete.svg";
import FolderSvg from "@/assets/icons/folder.svg";
import DeleteSvg from '@/assets/icons/delete.svg'
import FolderSvg from '@/assets/icons/folder.svg'
const props = defineProps<{
items: {
title: string;
action: () => void;
}[];
icon: "folder";
}>();
title: string
action: () => void
}[]
icon: 'folder'
}>()
function getIcon() {
switch (props.icon) {
case "folder":
return FolderSvg;
case 'folder':
return FolderSvg
default:
return FolderSvg;
return FolderSvg
}
}
const icon_ = getIcon();
const icon_ = getIcon()
</script>
<style lang="scss">
@@ -63,6 +63,7 @@ const icon_ = getIcon();
gap: 1rem;
svg {
flex-shrink: 0;
width: 1.25rem;
display: block;
}
@@ -71,7 +72,7 @@ const icon_ = getIcon();
display: flex;
gap: $small;
align-items: center;
font-family: "SF Mono", monospace;
font-family: 'SF Mono', monospace;
font-weight: 500;
font-size: 0.9rem;

View File

@@ -6,8 +6,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useToast } from '@/stores/notification'
import { ref } from 'vue'
const toast = useToast()
@@ -48,6 +48,11 @@ 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;
outline: none;

View File

@@ -75,6 +75,15 @@
<Accounts v-if="setting.type === SettingType.accounts" />
<About v-if="setting.type === SettingType.about" />
<Pairing v-if="setting.type === SettingType.pairing" />
<DropDown
v-if="setting.type === SettingType.streaming_quality"
:items="(setting.options ?? [] as any)"
:current="(setting.state && setting.state() as any)"
@item-clicked="setting.action"
:reverse="'hide'"
component_key="streaming_quality"
/>
<BackupRestore v-if="setting.type === SettingType.backup" />
</div>
</div>
</div>
@@ -96,6 +105,9 @@ 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 DropDown from '../shared/DropDown.vue'
import settings from '@/settings'
import BackupRestore from './Components/BackupRestore.vue'
defineProps<{
group: SettingGroup

View File

@@ -0,0 +1,139 @@
<template>
<RouterLink :to="getRouterParams()" class="chartitem rounded-sm">
<ArrowSvg class="trend" :class="item.trend?.trend" />
<div class="index">{{ index }}</div>
<img :src="getItemImage(item)" class="chartimage" :class="name" />
<div class="iteminfo">
<div class="title" :title="item.name" v-if="isArtist">
{{ item.name }} <MasterFlag v-if="item.trend?.is_new" :text="item.trend?.is_new ? 'New' : ''" :bitrate="1900"/>
</div>
<div class="title" :title="item.title" v-if="isAlbumOrTrack">
{{ item.title }} <MasterFlag v-if="item.trend?.is_new" :text="item.trend?.is_new ? 'New' : ''" :bitrate="1900"/>
</div>
<div class="artist" v-if="isAlbumOrTrack">
<ArtistName
:artists="item.artists ? item.artists : item.albumartists"
:albumartists="item.albumartists"
/>
</div>
<div class="artist" v-if="isArtist">
{{ item.extra['playcount'] }} track plays
</div>
</div>
<div class="helptext">
{{ item.help_text }}
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { paths } from '@/config'
import { Album, Artist, Track } from '@/interfaces'
import ArrowSvg from '@/assets/icons/arrow.svg'
import ArtistName from '../shared/ArtistName.vue'
import { Routes } from '@/router'
import MasterFlag from '../shared/MasterFlag.vue'
type name = 'artist' | 'album' | 'track'
type ChartItem = Artist | Album | Track
const props = defineProps<{
item: ChartItem
index: number
name: name
}>()
const isArtist = computed(() => props.name === 'artist')
const isAlbumOrTrack = computed(() => props.name === 'album' || props.name === 'track')
function getItemImage(item: ChartItem) {
switch (props.name) {
case 'artist':
return paths.images.artist.medium + item.image
case 'album':
return paths.images.thumb.medium + item.image
case 'track':
return paths.images.thumb.medium + item.image
}
}
function getRouterParams() {
switch (props.name) {
case 'artist':
return { name: Routes.artist, params: { hash: props.item.artisthash } }
default:
return { name: Routes.album, params: { albumhash: props.item.albumhash } }
}
}
</script>
<style lang="scss">
.chartitem {
padding: $small 2rem;
padding-left: 1.25rem;
display: grid;
grid-template-columns: 1rem 1rem max-content 1fr max-content;
gap: 1.5rem;
align-items: center;
margin-bottom: $medium;
.index {
font-size: 1.25rem;
font-weight: 900;
color: $gray2;
text-align: right;
}
.chartimage.artist {
border-radius: 50%;
}
.iteminfo {
.title {
font-size: 1rem;
font-weight: bold;
}
.artist {
font-size: 0.85rem;
color: $gray1;
margin-top: 0.2rem;
}
}
.chartimage {
border-radius: 0.25rem;
height: 48px;
width: auto;
}
.trend {
height: 1.25rem;
}
.trend.rising {
transform: rotate(90deg);
color: rgb(75, 170, 67);
}
.trend.falling {
transform: rotate(-90deg);
color: $red;
}
.is_new {
color: $orange;
}
.helptext {
font-size: 0.75rem;
color: $gray2;
text-align: right;
text-transform: uppercase;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="chartgroup rounded" :class="group">
<ChartsHeader :name="group" @change-period="changePeriod" @change-group="changeGroup" :period="period" />
<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>
<ChartItem
v-for="(item, index) in items"
:key="index"
:item="item"
:index="index + 1"
:name="(group.slice(0, -1) as any)"
/>
<div class="scrobbleinfo rounded-sm">
<div class="date">
<CalendarSvg />
{{ scrobbleInfo?.dates }}
</div>
<div class="scrobbleinfo-trend">
<ArrowSvg class="trend" :class="scrobbleInfo?.trend" />
<div class="text">
{{ scrobbleInfo?.text }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { getChartItem } from '@/requests/stats'
import { Artist, Album, Track } from '@/interfaces'
import ChartItem from './ChartItem.vue'
import ChartsHeader from './ChartsHeader.vue'
import ArrowSvg from '@/assets/icons/arrow.svg'
import CalendarSvg from '@/assets/icons/calendar.svg'
// 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[]>[],
artists: <Artist[]>[],
})
const items = computed(() => {
return items2[group.value]
})
const scrobbleInfo = ref<{
text: string
trend: string
dates: string
} | null>(null)
// Functions
async function getItems() {
items2[group.value] = []
loaded.value = false
let isPending = true
// Set a timeout to show the loader after 250ms
setTimeout(() => {
if (isPending) {
loading.value = true
}
}, 450)
try {
const res = await getChartItem(group.value, period.value, 10, 'playduration')
items2[group.value] = res.data[group.value]
scrobbleInfo.value = res.data.scrobbles
loaded.value = true
} finally {
isPending = false
loading.value = false
loaded.value = true
}
}
async function changePeriod(newPeriod: string) {
period.value = newPeriod
await getItems()
}
async function changeGroup(newGroup: string) {
group.value = newGroup
await getItems()
}
onMounted(async () => {
await getItems()
})
</script>
<style lang="scss">
.chartgroup {
.loading {
display: flex;
gap: $small;
}
.noitems {
height: 3.25rem;
padding: 1rem;
background-color: $gray;
margin: 1rem;
margin-bottom: 2rem;
color: $white;
}
.scrobbleinfo {
display: flex;
align-items: center;
justify-content: space-between;
gap: $small;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 900;
margin: $medium 1.2rem;
color: $gray1;
.date {
display: flex;
align-items: center;
gap: $small;
svg {
width: 1.25rem;
}
}
.scrobbleinfo-trend {
color: #8280f0;
display: flex;
align-items: center;
gap: $small;
}
.trend {
width: 1.25rem;
}
.trend.rising {
transform: rotate(90deg);
color: rgb(75, 170, 67);
}
.trend.falling {
transform: rotate(-90deg);
color: $red;
}
}
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<div
class="chartitem chartitemhashuno rounded"
:style="{
backgroundColor: name === 'artist' ? '' : color,
}"
>
<div
v-if="name === 'artist'"
class="gradient"
:style="`background-image: linear-gradient(to right, transparent 0, ${item.color} 12rem, ${item.color} 100%)`"
></div>
<div class="hashuno shadow-sm">
1
</div>
<img :src="getItemImage(item)" class="rounded-sm" :class="name" />
<div class="iteminfo">
<div>
<div class="helptext">
{{ item.help_text }}
</div>
<div class="artist" v-if="name !== 'artist'">
<ArtistName
:artists="item.artists ? item.artists : item.albumartists"
:albumartists="item.albumartists"
/>
</div>
<div class="title ellip">{{ name === 'artist' ? item.name : item.title }}</div>
</div>
<!-- <div class="index">
<ArrowSvg class="trend" :class="item.trend" /> 1
</div> -->
</div>
<PlayBtn />
</div>
</template>
<script setup lang="ts">
import Vibrant from 'node-vibrant'
import { onMounted, ref } from 'vue'
import { paths } from '@/config'
import { Artist, Album, Track } from '@/interfaces'
import listToRgbString from '@/utils/colortools/listToRgbString'
import ArrowSvg from '@/assets/icons/arrow.svg'
import ArtistName from '../shared/ArtistName.vue'
import PlayBtn from '../shared/PlayBtn.vue'
type name = 'artist' | 'album' | 'track'
type ChartItem = Artist | Album | Track
const props = defineProps<{
item: ChartItem
index: number
name: name
}>()
const color = ref<string | null>(null)
function getItemImage(item: ChartItem, size: 'small' | 'large' | 'medium' = 'large') {
switch (props.name) {
case 'artist':
return paths.images.artist[size] + item.image
case 'album':
return paths.images.thumb[size] + item.image
case 'track':
return paths.images.thumb[size] + item.image
}
}
onMounted(() => {
if (props.name === 'artist') return
const imageurl = getItemImage(props.item)
const vibrant = new Vibrant(imageurl)
vibrant.getPalette().then(palette => {
color.value = listToRgbString(palette.DarkMuted?.getRgb())
})
})
</script>
<style lang="scss">
.chartitemhashuno {
display: grid;
grid-template-columns: max-content 1fr max-content !important;
align-items: flex-end !important;
margin: 1rem;
margin-top: 0;
// padding: 1rem !important;
position: relative;
.hashuno {
background-color: #fff;
color: #000;
position: absolute;
bottom: -1rem;
left: 2rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: bold;
}
.iteminfo {
width: max-content;
display: grid;
grid-template-columns: max-content 1fr;
align-items: flex-end;
// gap: 1rem;
.index {
font-size: 5rem;
font-weight: 900;
color: #fff;
}
.trend {
color: $gray1;
height: 1.5rem;
}
.helptext {
text-align: left
}
.title {
font-size: 2rem !important;
}
}
img {
height: 8rem;
width: 8rem;
object-fit: cover;
}
img.artist {
height: 10rem;
width: 15rem;
object-fit: cover;
margin-left: -1rem;
margin-top: -1rem;
}
.gradient {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
}
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="stats-charts">
<GenericHeader>
<template #name>Charts</template>
<template #description>Your top artists, albums, and tracks</template>
</GenericHeader>
<br>
<div class="chartitemgroupsgrid">
<ChartItemGroup />
</div>
</div>
</template>
<script setup lang="ts">
import GenericHeader from '@/components/shared/GenericHeader.vue'
import ChartItemGroup from './ChartItemGroup.vue'
</script>
<style lang="scss">
.stats-charts {
.chartitemgroupsgrid {
display: grid;
grid-template-columns: 1fr;
gap: 3rem;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="chartheader">
<!-- <div class="title">{{ name }}</div> -->
<div class="group">
<div
class="group-item"
v-for="g in groups"
:key="g"
:class="g === name ? 'active' : ''"
@click="$emit('changeGroup', g)"
>
{{ g }}
</div>
</div>
<div class="period">
<div
class="period-item"
v-for="p in periods"
:key="p"
:class="p === period ? 'active' : ''"
@click="$emit('changePeriod', p)"
>
{{ p }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
name: string
period: string
}>()
defineEmits<{
(e: 'changePeriod', period: string): void
(e: 'changeGroup', group: string): void
}>()
const groups = ['artists', 'albums', 'tracks']
const periods = ['week', 'month', 'year', 'alltime']
</script>
<style lang="scss">
.chartheader {
padding: $smaller 0 1rem 0;
margin: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: solid 1px $gray5;
// margin-left: -1rem;
.title {
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
}
.period,
.group {
display: flex;
gap: 1rem;
text-transform: uppercase;
.period-item,
.group-item {
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
color: $gray2;
&.active {
color: $white;
}
}
}
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<div class="statitem" :class="props.icon">
<svg
class="noise"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svgjs="http://svgjs.dev/svgjs"
viewBox="0 0 700 700"
>
<defs>
<filter
id="nnnoise-filter"
x="-20%"
y="-20%"
width="140%"
height="140%"
filterUnits="objectBoundingBox"
primitiveUnits="userSpaceOnUse"
color-interpolation-filters="linearRGB"
>
<feTurbulence
type="turbulence"
baseFrequency="0.05"
numOctaves="4"
seed="15"
stitchTiles="stitch"
x="0%"
y="0%"
width="100%"
height="100%"
result="turbulence"
></feTurbulence>
<feSpecularLighting
surfaceScale="21"
specularConstant="1.7"
specularExponent="20"
lighting-color="#7957A8"
x="0%"
y="0%"
width="100%"
height="100%"
in="turbulence"
result="specularLighting"
>
<feDistantLight azimuth="3" elevation="84"></feDistantLight>
</feSpecularLighting>
</filter>
</defs>
<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="count ellip2" :title="formattedValue">{{ formattedValue }}</div>
<div class="title">{{ text }}</div>
</div>
<component :is="icon" class="staticon" v-if="props.icon !== 'toptrack'" />
<router-link
: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>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
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 { paths } from '@/config'
import { Routes } from '@/router'
const props = defineProps<{
value: string
text: string
icon: string
image?: string
}>()
const icon = computed(() => {
switch (props.icon) {
case 'streams':
return HeadphoneSvg
case 'playtime':
return StopWatchSvg
case 'trackcount':
return FolderSvg
case 'toptrack':
return Index1Svg
default:
return HeadphoneSvg
}
})
const formattedValue = computed(() => {
return props.value.toLocaleString()
})
</script>
<style lang="scss">
.statitem {
// background-color: $gray2;
// padding: 1rem;
border-radius: 1rem;
height: 12rem;
aspect-ratio: 1;
overflow: hidden;
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;
height: 100%;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: $small;
.count {
font-size: 1.55rem;
font-weight: 900;
}
.title {
font-size: 14px;
font-weight: 500;
}
}
.staticon {
position: absolute;
top: 1rem;
left: 1rem;
width: 1.5rem;
z-index: 1;
}
.statimage {
height: 54px;
width: 54px;
border-radius: $smaller;
}
svg.noise {
position: absolute;
top: 0;
left: 0;
}
}
.statitem.toptrack {
aspect-ratio: 1.5;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="statshead" v-if="statItems.length">
<div class="left">
<StatItem
v-for="item in statItems.slice(0, statItems.length - 1)"
:key="item.cssclass"
:value="item.value"
:text="item.text"
:icon="item.cssclass"
:image="item.image"
/>
</div>
<div class="right">
<StatItem
:value="statItems[statItems.length - 1].value"
:text="statItems[statItems.length - 1].text"
:icon="statItems[statItems.length - 1].cssclass"
/>
</div>
</div>
<div class="statsdates">
<CalendarSvg />
{{ dates }}
</div>
</template>
<script setup lang="ts">
import { getStats } from '@/requests/stats'
import { onMounted, ref } from 'vue'
import StatItem from './StatItem.vue'
import CalendarSvg from '@/assets/icons/calendar.svg'
interface StatItem {
cssclass: string
value: string
text: string
image?: string
}
const statItems = ref<StatItem[]>([])
const dates = ref<string[]>([])
onMounted(async () => {
const res = await getStats()
if (res.status == 200) {
statItems.value = res.data.stats
dates.value = res.data.dates
}
})
</script>
<style lang="scss">
.statshead {
display: grid;
grid-template-columns: 1fr max-content;
gap: 1.5rem;
padding: 1rem;
.left {
display: flex;
gap: 2rem;
}
.streamduration {
padding: 1rem;
}
}
.statsdates {
display: flex;
align-items: center;
gap: $small;
color: $gray1;
padding: 1rem;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 900;
svg {
width: 1.25rem;
}
}
</style>

View File

@@ -43,46 +43,46 @@
</template>
<script setup lang="ts">
import { deletePlaylist as delPlaylist } from "@/requests/playlists";
import useModalStore, { ModalOptions } from "@/stores/modal";
import { useRouter } from "vue-router";
import { deletePlaylist as delPlaylist } from '@/requests/playlists'
import useModalStore, { ModalOptions } from '@/stores/modal'
import { useRouter } from 'vue-router'
import AuthLogin from "./modals/AuthLogin.vue";
import ConfirmModal from "./modals/ConfirmModal.vue";
import NewPlaylist from "./modals/NewPlaylist.vue";
import RootDirsPrompt from "./modals/RootDirsPrompt.vue";
import SetRootDirs from "./modals/SetRootDirs.vue";
import Settings from "./modals/Settings.vue";
import UpdatePlaylist from "./modals/updatePlaylist.vue";
import AuthLogin from './modals/AuthLogin.vue'
import ConfirmModal from './modals/ConfirmModal.vue'
import NewPlaylist from './modals/NewPlaylist.vue'
import RootDirsPrompt from './modals/RootDirsPrompt.vue'
import SetRootDirs from './modals/SetRootDirs.vue'
import Settings from './modals/Settings.vue'
import UpdatePlaylist from './modals/updatePlaylist.vue'
const modal = useModalStore();
const router = useRouter();
const modal = useModalStore()
const router = useRouter()
function setTitle(title: string) {
modal.setTitle(title);
modal.setTitle(title)
}
function hideModal() {
modal.hideModal();
modal.hideModal()
}
function deletePlaylist() {
delPlaylist(modal.props.pid)
.then(() => modal.hideModal())
.then(() => router.back());
.then(() => router.back())
}
</script>
<style lang="scss">
.modal {
position: fixed;
z-index: 20;
z-index: 21;
height: 100vh;
width: 100vw;
display: grid;
place-items: center;
input[type="search"] {
input[type='search'] {
margin: $small 0;
border: none;
background-color: $gray5;

View File

@@ -1,12 +1,6 @@
<template>
<div
class="loginmodal"
v-auto-animate
>
<div
class="head"
:class="{ selected }"
>
<div class="loginmodal" v-auto-animate>
<div class="head" :class="{ selected }">
<button
class="back rounded-sm"
title="Back to selection"
@@ -18,50 +12,24 @@
<span>back</span> <ArrowSvg />
</button>
<Logo />
<button
class="back back2 rounded-sm"
title="Back to selection"
>
<span>back</span> <ArrowSvg />
</button>
<button class="back back2 rounded-sm" title="Back to selection"><span>back</span> <ArrowSvg /></button>
</div>
<div class="alcontent">
<div class="helptext" v-if="!selected">
<div class="h2">Welcome back</div>
</div>
<div
class="selected-user"
v-if="selected"
>
<div class="selected-user" v-if="selected">
<User
:user="
selected.username === ''
? { id: 0, username: username, firstname: '' }
: selected
"
:user="selected.username === '' ? { id: 0, username: username, firstname: '' } : selected"
:selected="true"
/>
</div>
<div
class="userlist"
v-auto-animate
v-else
>
<User
v-for="user in shownUsers"
@click="setUser(user)"
:user="user"
:key="user.id"
/>
<div class="userlist" v-auto-animate v-else>
<User v-for="user in shownUsers" @click="setUser(user)" :user="user" :key="user.id" />
</div>
<form
class="passinput"
v-if="selected"
v-auto-animate
@submit.prevent="loginUser"
>
<form class="passinput" v-if="selected" v-auto-animate @submit.prevent="loginUser">
<!-- Only show username input if there's no user list -->
<Input
placeholder="Enter username"
@@ -76,19 +44,10 @@
@input="(input: string) => password = input"
/>
<!-- v-if="username.length && password.length" -->
<button
class="submit"
:class="{ long: selected.username !== ''}"
>
Login
</button>
<button class="submit" :class="{ long: selected.username !== '' }">Login</button>
</form>
</div>
<div
v-if="guestAllowed"
class="guestlink"
@click="() => guestLogin()"
>
<div v-if="guestAllowed" class="guestlink" @click="() => guestLogin()">
<span>Or continue as guest </span>
</div>
</div>
@@ -97,14 +56,14 @@
<script setup lang="ts">
import { Ref, computed, nextTick, onMounted, ref } from 'vue'
import useAuth from '@/stores/auth'
import { UserSimplified } from '@/interfaces'
import { getAllUsers } from '@/requests/auth'
import useAuth from '@/stores/auth'
import Logo from '../Logo.vue'
import User from '../shared/LoginUserCard.vue'
import ArrowSvg from '../../assets/icons/expand.svg'
import Logo from '../Logo.vue'
import Input from '../shared/Input.vue'
import User from '../shared/LoginUserCard.vue'
const auth = useAuth()
@@ -112,12 +71,10 @@ const username = ref('')
const password = ref('')
const users: Ref<UserSimplified[]> = ref([])
const shownUsers = computed(() => users.value.filter((user) => user.username !== 'guest'))
const shownUsers = computed(() => users.value.filter(user => user.username !== 'guest'))
const selected = ref<UserSimplified | null>(null)
const guestAllowed = computed(() =>
users.value.some((user) => user.username === 'guest')
)
const guestAllowed = computed(() => users.value.some(user => user.username === 'guest'))
async function setUser(user: UserSimplified) {
selected.value = user
@@ -147,10 +104,7 @@ async function loginUser() {
await auth.login(username.value, password.value)
}
async function guestLogin(
username: string = 'guest',
password: string = 'guest'
) {
async function guestLogin(username: string = 'guest', password: string = 'guest') {
await auth.login(username, password)
}
@@ -158,15 +112,12 @@ onMounted(async () => {
let res = await getAllUsers()
// if there are no users, or only the guest user, set the user to empty user
if (
res.users.length === 0 ||
(res.users.length == 1 && res.users[0].username === 'guest')
) {
if (res.users.length === 0 || (res.users.length == 1 && res.users[0].username === 'guest')) {
setUser({ id: 0, username: '', firstname: '' })
}
// if there's only one user, and it's not the guest user, select them
if (res.users.filter((user) => user.username !== 'guest').length === 1) {
if (res.users.filter(user => user.username !== 'guest').length === 1) {
setTimeout(() => {
setUser(res.users[0])
}, 250)
@@ -186,8 +137,14 @@ onMounted(async () => {
display: grid;
grid-template-rows: max-content 1fr max-content;
max-height: calc(100vh - 4rem);
.alcontent {
padding-bottom: 2rem;
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
}
.guestlink {
@@ -281,15 +238,23 @@ 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;
font-size: 1rem;
border: none;
outline: none;
background-color: $gray5;
color: $gray1;
color: $white;
text-align: center;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.submit {
@@ -299,6 +264,11 @@ onMounted(async () => {
height: 3rem;
background-color: $darkblue;
margin-top: 1rem;
transition: color 0.2s ease-out;
&:hover {
color: #ffffff;
}
}
.submit.long {

View File

@@ -19,7 +19,7 @@
</button>
{{ currentGroup?.title }}
<span v-if="currentGroup?.experimental" class="badge experimental circular">
{{ currentGroup?.experimental ? "experimental" : "" }}
{{ currentGroup?.experimental ? 'experimental' : '' }}
</span>
</div>
</div>
@@ -29,49 +29,49 @@
</template>
<script setup lang="ts">
import settingGroups from "@/settings";
import settingGroups from '@/settings'
import ArrowSvg from "@/assets/icons/arrow.svg";
import { SettingGroup } from "@/interfaces/settings";
import { isSmallPhone } from "@/stores/content-width";
import { computed, ref } from "vue";
import Content from "./settings/Content.vue";
import Sidebar from "./settings/Sidebar.vue";
import ArrowSvg from '@/assets/icons/arrow.svg'
import { SettingGroup } from '@/interfaces/settings'
import { isSmallPhone } from '@/stores/content-width'
import { computed, ref } from 'vue'
import Content from './settings/Content.vue'
import Sidebar from './settings/Sidebar.vue'
const emit = defineEmits<{
(e: "setTitle", title: string): void;
}>();
(e: 'setTitle', title: string): void
}>()
const currentTab = ref<string>("");
const currentTab = ref<string>('')
const currentGroup = computed(() => {
for (const group of settingGroups) {
for (const settings of group.groups) {
if (settings.title === currentTab.value) {
return settings;
return settings
}
}
}
if (isSmallPhone.value) {
return null;
return null
}
// select default tab
for (const group of settingGroups) {
for (const settings of group.groups) {
if (settings.title === "Appearance") {
return settings;
if (settings.title === 'Backup') {
return settings
}
}
}
});
})
const showContent = computed(() => {
return currentGroup.value !== null;
});
return currentGroup.value !== null
})
function handleGoBack() {
currentTab.value = "";
currentTab.value = ''
}
</script>
@@ -81,12 +81,10 @@ $modalheight: 38rem;
.settingsmodal {
display: grid;
grid-template-columns: 15rem 1fr;
height: $modalheight;
.content {
display: grid;
grid-template-rows: 4rem 1fr;
height: $modalheight;
.head {
border-bottom: solid 1px $gray4;

View File

@@ -5,7 +5,7 @@
class="group"
v-for="group in settingGroups.filter(g => {
// return true
return g.show_if ? g.show_if() : true;
return g.show_if ? g.show_if() : true
})"
:key="group.title"
>
@@ -36,21 +36,21 @@
</template>
<script setup lang="ts">
import { SettingGroup } from "@/interfaces/settings";
import settingGroups from "@/settings";
import useAuth from "@/stores/auth";
import { SettingGroup } from '@/interfaces/settings'
import settingGroups from '@/settings'
import useAuth from '@/stores/auth'
import Avatar from "@/components/shared/Avatar.vue";
import Avatar from '@/components/shared/Avatar.vue'
const auth = useAuth();
const auth = useAuth()
defineProps<{
currentGroup: SettingGroup;
}>();
currentGroup: SettingGroup
}>()
defineEmits<{
(e: "setTab", title: string): void;
}>();
(e: 'setTab', title: string): void
}>()
</script>
<style lang="scss">
@@ -101,7 +101,7 @@ defineEmits<{
.gtitle {
font-weight: bold;
font-size: 14px;
margin: 1rem 0 $smaller $small;
margin: 1.25rem 0 $smaller $small;
}
.gitems {
@@ -154,7 +154,7 @@ defineEmits<{
}
&.about::before {
content: "";
content: '';
height: 1px;
position: absolute;
top: -$small;

View File

@@ -1,158 +1,158 @@
<template>
<form
id="playlist-update-modal"
class="playlist-modal"
enctype="multipart/form-data"
autocomplete="off"
@submit.prevent="update_playlist"
>
<label for="name">Playlist name</label>
<input
id="modal-playlist-name-input"
v-model="pname"
type="search"
class="rounded-sm"
name="name"
spellcheck="false"
@keypress.enter.prevent="update_playlist"
/>
<form
id="playlist-update-modal"
class="playlist-modal"
enctype="multipart/form-data"
autocomplete="off"
@submit.prevent="update_playlist"
>
<label for="name">Playlist name</label>
<input
id="modal-playlist-name-input"
v-model="pname"
type="search"
class="rounded-sm"
name="name"
spellcheck="false"
@keypress.enter.prevent="update_playlist"
/>
<label for="image">Image</label>
<input
id="update-pl-image-upload"
ref="dropZoneRef"
type="file"
accept="image/*"
name="image"
style="display: none"
@change="handleUpload"
/>
<div id="upload" class="boxed rounded-sm">
<div class="clickable" tabindex="0" @click="selectFiles" @keydown.space.enter.stop="selectFiles">
<ImageIcon />
Click to {{ playlist.has_image ? "update" : "upload" }} cover image
</div>
<div
id="update-pl-img-preview"
class="image"
:style="{
backgroundImage: `url(${playlist.image})`,
}"
tabindex="0"
>
<div v-if="!image && playlist.has_image" class="delete-icon" @click="pStore.removeBanner()">
<DeleteIcon />
<label for="image">Image</label>
<input
id="update-pl-image-upload"
ref="dropZoneRef"
type="file"
accept="image/*"
name="image"
style="display: none"
@change="handleUpload"
/>
<div id="upload" class="boxed rounded-sm">
<div class="clickable" tabindex="0" @click="selectFiles" @keydown.space.enter.stop="selectFiles">
<ImageIcon />
Click to {{ playlist.has_image ? 'update' : 'upload' }} cover image
</div>
<div
id="update-pl-img-preview"
class="image"
:style="{
backgroundImage: `url(${playlist.image})`,
}"
tabindex="0"
>
<div v-if="!image && playlist.has_image" class="delete-icon" @click="pStore.removeBanner()">
<DeleteIcon />
</div>
</div>
</div>
<label v-if="playlist.has_image && !playlist.settings.square_img">Settings</label>
<div v-if="image || playlist.has_image" class="banner-settings rounded-sm">
<div>Show square cover image</div>
<Switch :state="playlist.settings.square_img || false" @click="pStore.toggleSquareImage" />
</div>
<div v-if="playlist.has_image && !playlist.settings.square_img" class="boxed banner-position-adjust rounded-sm">
<div class="t-center">Adjust image position • {{ pStore.info.settings.banner_pos }}%</div>
<div class="buttons">
<button @click.stop.prevent="pStore.minusBannerPos">
<ExpandSvg />
</button>
<button @click.stop.prevent="pStore.plusBannerPos">
<ExpandSvg />
</button>
</div>
</div>
</div>
</div>
<label v-if="playlist.has_image && !playlist.settings.square_img">Settings</label>
<div v-if="image || playlist.has_image" class="banner-settings rounded-sm">
<div>Show square cover image</div>
<Switch :state="playlist.settings.square_img || false" @click="pStore.toggleSquareImage" />
</div>
<div v-if="playlist.has_image && !playlist.settings.square_img" class="boxed banner-position-adjust rounded-sm">
<div class="t-center">Adjust image position • {{ pStore.info.settings.banner_pos }}%</div>
<div class="buttons">
<button @click.stop.prevent="pStore.minusBannerPos">
<ExpandSvg />
</button>
<button @click.stop.prevent="pStore.plusBannerPos">
<ExpandSvg />
</button>
</div>
</div>
<button type="submit">
{{ clicked ? "Saving" : "Update" }}
</button>
</form>
<button type="submit">
{{ clicked ? 'Saving' : 'Update' }}
</button>
</form>
</template>
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { Ref, onMounted, ref } from "vue";
import { storeToRefs } from 'pinia'
import { Ref, onMounted, ref } from 'vue'
import { updatePlaylist } from "@/requests/playlists";
import usePStore from "@/stores/pages/playlist";
import { updatePlaylist } from '@/requests/playlists'
import usePStore from '@/stores/pages/playlist'
import DeleteIcon from "@/assets/icons/delete.svg";
import ExpandSvg from "@/assets/icons/expand.svg";
import ImageIcon from "@/assets/icons/image.svg";
import DeleteIcon from '@/assets/icons/delete.svg'
import ExpandSvg from '@/assets/icons/expand.svg'
import ImageIcon from '@/assets/icons/image.svg'
import Switch from "../SettingsView/Components/Switch.vue";
import Switch from '../SettingsView/Components/Switch.vue'
const pStore = usePStore();
const { info: playlist } = storeToRefs(pStore);
const pStore = usePStore()
const { info: playlist } = storeToRefs(pStore)
const pname = ref(playlist.value.name);
const image: Ref<any> = ref(null);
const pname = ref(playlist.value.name)
const image: Ref<any> = ref(null)
onMounted(() => {
(document.getElementById("modal-playlist-name-input") as HTMLElement).focus();
});
;(document.getElementById('modal-playlist-name-input') as HTMLElement).focus()
})
const emit = defineEmits<{
(e: "setTitle", title: string): void;
(e: "hideModal"): void;
}>();
(e: 'setTitle', title: string): void
(e: 'hideModal'): void
}>()
emit("setTitle", "Update Playlist");
emit('setTitle', 'Update Playlist')
function selectFiles() {
const input = document.getElementById("update-pl-image-upload") as HTMLInputElement;
input.click();
const input = document.getElementById('update-pl-image-upload') as HTMLInputElement
input.click()
}
function handleUpload() {
const input = document.getElementById("update-pl-image-upload") as HTMLInputElement;
const input = document.getElementById('update-pl-image-upload') as HTMLInputElement
if (input.files) {
handleFile(input.files[0]);
}
if (input.files) {
handleFile(input.files[0])
}
}
function handleFile(file: File) {
if (!file || !file.type.startsWith("image/")) {
return;
}
if (!file || !file.type.startsWith('image/')) {
return
}
const preview = document.getElementById("update-pl-img-preview");
const obj_url = URL.createObjectURL(file);
const preview = document.getElementById('update-pl-img-preview')
const obj_url = URL.createObjectURL(file)
if (preview) {
pStore.setImage(obj_url);
pStore.setInitialBannerPos();
}
if (preview) {
pStore.setImage(obj_url)
pStore.setInitialBannerPos()
}
image.value = file;
image.value = file
}
let clicked = ref(false);
let clicked = ref(false)
function update_playlist(e: Event) {
const form = document.getElementById("playlist-update-modal") as HTMLFormElement;
const formData = new FormData(form);
const form = document.getElementById('playlist-update-modal') as HTMLFormElement
const formData = new FormData(form)
const name = formData.get("name") as string;
const name = formData.get('name') as string
const nameChanged = name !== playlist.value.name;
const imgChanged = image.value !== undefined;
const nameChanged = name !== playlist.value.name
const imgChanged = image.value !== undefined
if (!nameChanged && !imgChanged) {
emit("hideModal");
return;
}
if (!nameChanged && !imgChanged) {
emit('hideModal')
return
}
clicked.value = true;
clicked.value = true
formData.append("image", image.value);
formData.append("settings", JSON.stringify(pStore.info.settings));
formData.append('image', image.value)
formData.append('settings', JSON.stringify(pStore.info.settings))
if (name && name.toString().trim() !== "") {
updatePlaylist(playlist.value.id, formData, pStore).then(() => {
emit("hideModal");
});
}
if (name && name.toString().trim() !== '') {
updatePlaylist(playlist.value.id, formData, pStore).then(() => {
emit('hideModal')
})
}
}
// Future TODO: Implement drag and drop for images here
@@ -160,137 +160,142 @@ function update_playlist(e: Event) {
<style lang="scss">
#playlist-update-modal {
input {
height: 3rem !important;
}
input {
height: 3rem !important;
}
}
.playlist-modal {
#modal-playlist-name-input {
margin-bottom: 1rem;
}
#modal-playlist-name-input {
margin-bottom: 1rem;
.boxed {
border: solid 2px $gray4;
color: $gray1;
place-items: center;
display: grid;
grid-template-columns: 1fr max-content;
}
.banner-settings {
font-weight: 500;
padding: 1rem;
background-color: $gray5;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
gap: $small;
margin: $small 0 1rem 0;
}
#upload {
width: 100%;
display: grid;
gap: $small;
border: none;
margin: $small 0 1rem 0;
svg {
height: 2rem;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
#update-pl-img-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: $small;
object-fit: cover;
background-color: $gray4;
position: relative;
.boxed {
border: solid 2px $gray4;
color: $gray1;
place-items: center;
display: grid;
grid-template-columns: 1fr max-content;
}
.clickable {
font-weight: 500;
height: 100%;
width: 100%;
display: flex;
gap: $smaller;
place-items: center;
place-content: center;
border-radius: $small;
border: dashed 1px $gray4;
cursor: pointer;
padding: $medium;
svg {
transform: scale(0.75);
flex-shrink: 0;
}
.banner-settings {
font-weight: 500;
padding: 1rem;
background-color: $gray5;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
gap: $small;
margin: $small 0 1rem 0;
}
.delete-icon {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.521);
border-radius: $small;
transition: all 0.2s ease-out;
display: flex;
place-content: center;
place-items: center;
cursor: pointer;
svg {
transform: scale(1);
color: rgb(255, 255, 255);
}
&:hover {
background-color: $red;
#upload {
width: 100%;
display: grid;
gap: $small;
border: none;
margin: $small 0 1rem 0;
svg {
transform: scale(1.25);
transform-origin: center;
height: 2rem;
}
}
}
}
.banner-position-adjust {
gap: 1rem;
padding: $small 1rem;
margin-bottom: 1rem;
#update-pl-img-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: $small;
object-fit: cover;
background-color: $gray4;
position: relative;
}
.t-center {
position: relative;
font-weight: 500;
font-variant-numeric: tabular-nums;
.clickable {
font-weight: 500;
height: 100%;
width: 100%;
display: flex;
gap: $smaller;
place-items: center;
place-content: center;
border-radius: $small;
border: dashed 1px $gray4;
cursor: pointer;
padding: $medium;
svg {
transform: scale(0.75);
flex-shrink: 0;
}
}
.delete-icon {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.521);
border-radius: $small;
transition: all 0.2s ease-out;
display: flex;
place-content: center;
place-items: center;
cursor: pointer;
svg {
transform: scale(1);
color: rgb(255, 255, 255);
}
&:hover {
background-color: $red;
svg {
transform: scale(1.25);
transform-origin: center;
}
}
}
}
.buttons {
display: grid;
gap: $small;
.banner-position-adjust {
gap: 1rem;
padding: $small 1rem;
margin-bottom: 1rem;
button {
aspect-ratio: 1;
height: 2rem;
width: 2rem;
border: none;
background-color: $gray4;
padding: 0;
&:first-child {
transform: rotate(-90deg);
.t-center {
position: relative;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
&:last-child {
transform: rotate(90deg);
}
.buttons {
display: grid;
gap: $small;
&:hover {
background-color: $blue;
button {
aspect-ratio: 1;
height: 2rem;
width: 2rem;
border: none;
background-color: $gray4;
padding: 0;
&:first-child {
transform: rotate(-90deg);
}
&:last-child {
transform: rotate(90deg);
}
&:hover {
background-color: $blue;
}
}
}
}
}
}
}
</style>

View File

@@ -1,49 +1,54 @@
<template>
<div id="back-forward" class="">
<button class="back" @click="$router.back()">
<ArrowSvg />
</button>
<button class="forward" @click="$router.forward()">
<ArrowSvg />
</button>
</div>
<div id="back-forward" class="">
<button class="back" @click="$router.back()">
<ArrowSvg />
</button>
<button class="forward" @click="$router.forward()">
<ArrowSvg />
</button>
</div>
</template>
<script setup lang="ts">
import ArrowSvg from "../../assets/icons/right-arrow.svg";
import ArrowSvg from '../../assets/icons/right-arrow.svg'
</script>
<style lang="scss">
#back-forward {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding-right: 1rem;
border-right: 1px solid $gray5;
height: max-content;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding-right: 1rem;
border-right: 1px solid $gray5;
height: max-content;
& > * {
width: 2.25rem;
height: 2.25rem;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
border-radius: 5rem;
& > * {
width: 2.25rem;
height: 2.25rem;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
border-radius: 5rem;
background-color: $gray5;
svg {
transform: scale(1.12);
transition: transform 0.2s ease;
&:hover {
background-color: $gray4;
}
&:active {
transform: scale(0.88);
}
svg {
transform: scale(1.12);
transition: transform 0.2s ease;
&:active {
transform: scale(0.88);
}
}
}
}
.back {
transform: rotate(180deg);
}
.back {
transform: rotate(180deg);
}
}
</style>

View File

@@ -1,116 +1,119 @@
<template>
<div class="profiledrop rounded-sm pad-sm shadow-lg">
<div class="info item">
<div class="username ellip2">
Hi {{ auth.user.username }}
</div>
<div class="profiledrop rounded-sm pad-sm shadow-lg">
<div class="info item">
<div class="username ellip2">Hi {{ auth.user.firstname || auth.user.username }}</div>
</div>
<div class="separator"></div>
<div class="item scan" @click="triggerScan">
<div class="label">Quick scan</div>
<ReloadSvg />
</div>
<div class="item" @click="modal.showSettingsModal">
<div class="label">Settings</div>
<SettingsSvg />
</div>
<div class="separator"></div>
<div class="item critical logout" @click="auth.logout">
<div class="label">Log out</div>
<LogoutSvg />
</div>
</div>
<div class="separator"></div>
<div class="item scan" @click="triggerScan">
<div class="label">Quick scan</div>
<ReloadSvg />
</div>
<div class="item" @click="modal.showSettingsModal">
<div class="label">Settings</div>
<SettingsSvg />
</div>
<div class="separator"></div>
<div class="item critical logout" @click="auth.logout">
<div class="label">Log out</div>
<LogoutSvg />
</div>
</div>
</template>
<script setup lang="ts">
import useAuth from "@/stores/auth";
import useModal from "@/stores/modal";
import useAuth from '@/stores/auth'
import useModal from '@/stores/modal'
import SettingsSvg from "@/assets/icons/settings.svg";
import LogoutSvg from "@/assets/icons/logout.svg";
import ReloadSvg from "@/assets/icons/reload.svg";
import { triggerScan } from "@/requests/settings/rootdirs";
import LogoutSvg from '@/assets/icons/logout.svg'
import ReloadSvg from '@/assets/icons/reload.svg'
import SettingsSvg from '@/assets/icons/settings.svg'
import { triggerScan } from '@/requests/settings/rootdirs'
const auth = useAuth();
const modal = useModal();
const auth = useAuth()
const modal = useModal()
</script>
<style lang="scss">
.profiledrop {
position: absolute;
z-index: 10;
top: 2.25rem;
right: 0;
width: 10rem;
font-size: 0.95rem;
font-weight: 400;
display: flex;
flex-direction: column;
border: solid 1px $gray5;
background-color: $gray;
.separator {
height: 1px;
background-color: $gray3;
padding: 0;
}
.item {
position: absolute;
z-index: 10;
top: 2.25rem;
right: 0;
width: 10rem;
font-size: 0.95rem;
font-weight: 400;
display: flex;
align-items: center;
justify-content: space-between;
gap: $smaller;
padding: $small $medium;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray4;
}
svg {
height: 1.5rem;
}
}
.item.logout svg, .scan svg {
// INFO: Though the icons are 1.5rem, it looks larger than the rest
// So, we reduce the size a bit.
height: 1.25rem;
}
.logout svg {
margin-right: 1px;
}
.scan svg {
margin-right: 3px;
}
.info {
flex-direction: column;
align-items: baseline;
gap: $smallest;
cursor: auto;
padding: 0.25rem 0.75rem;
border: solid 1px $gray5;
background-color: $gray;
&:hover {
background-color: transparent;
.separator {
height: 1px;
background-color: $gray3;
padding: 0;
}
.username {
font-weight: 500;
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $smaller;
padding: $small $medium;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray4;
}
svg {
height: 1.5rem;
}
}
}
.critical {
color: $red;
}
.item.scan {
margin-bottom: $smaller;
}
.critical:hover {
background-color:transparent;
outline: solid 1px;
}
.item.logout svg,
.scan svg {
// INFO: Though the icons are 1.5rem, it looks larger than the rest
// So, we reduce the size a bit.
height: 1.25rem;
}
.logout svg {
margin-right: 1px;
}
.scan svg {
margin-right: 3px;
}
.info {
flex-direction: column;
align-items: baseline;
gap: $smallest;
cursor: auto;
padding: 0.25rem 0.75rem;
&:hover {
background-color: transparent;
}
.username {
font-weight: 500;
}
}
.critical {
color: $red;
}
.critical:hover {
background-color: transparent;
outline: solid 1px;
}
}
</style>

View File

@@ -5,10 +5,10 @@
class="selected"
:class="{ showDropDown }"
@click.prevent="handleOpener"
:title="`sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase()"
:title="reverse !== 'hide' ? `sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase() : undefined"
>
<span class="ellip">{{ current.title }}</span>
<ArrowSvg :class="{ reverse }" />
<ArrowSvg :class="{ reverse }" v-if="reverse !== 'hide'" />
</button>
<div v-if="showDropDown" ref="dropOptionsRef" class="options rounded no-scroll shadow-lg">
<div
@@ -25,8 +25,8 @@
</div>
</template>
<script setup lang="ts">
import { Ref, ref } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { Ref, ref } from 'vue'
import ArrowSvg from '@/assets/icons/arrow.svg'
@@ -42,7 +42,7 @@ defineProps<{
items: Item[]
current: Item
component_key: string
reverse: boolean
reverse: boolean | 'hide'
}>()
const emit = defineEmits<{
@@ -111,8 +111,11 @@ onClickOutside(dropOptionsRef, e => {
}
.option {
font-weight: 500;
cursor: pointer;
padding: $small;
border-radius: $small;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray5;

View File

@@ -14,18 +14,18 @@
<style lang="scss">
.generichead {
padding: 1rem 0 1rem $medium;
height: max-content;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
max-width: 100%;
overflow: hidden;
.left {
padding: 0 0 1rem $medium;
height: max-content;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
max-width: 100%;
overflow: hidden;
}
.left {
max-width: 100%;
overflow: hidden;
}
h1 {
width: max-content;
@@ -33,23 +33,23 @@
font-size: 3.25rem;
}
.title {
font-weight: 700;
margin-left: -1px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
font-weight: 700;
margin-left: -1px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.desc {
font-size: 1rem;
line-height: 1.5;
font-weight: 500;
}
.desc {
font-size: 1rem;
line-height: 1.5;
font-weight: 500;
}
@include mediumPhones {
gap: 1rem;
grid-template-columns: repeat(auto-fill, 100%);
}
@include mediumPhones {
gap: 1rem;
grid-template-columns: repeat(auto-fill, 100%);
}
}
</style>

View File

@@ -1,8 +1,5 @@
<template>
<div
class="loginuser rounded-sm"
:class="{ selected }"
>
<div class="loginuser rounded-sm" :class="{ selected }">
<Avatar :name="user.username" />
<div class="username">
{{ (selected ? `Hi ` : '') + user.username }}
@@ -32,6 +29,10 @@ defineProps<{
&.selected {
pointer-events: none;
}
> .username {
font-weight: 500;
}
}
.loginuser:hover {

View File

@@ -1,160 +1,174 @@
<template>
<div
class="songlist-item rounded-sm"
:class="[{ current: isCurrent() }, { contexton: context_menu_showing }]"
@dblclick.prevent="emitUpdate"
@contextmenu.prevent="showMenu"
>
<TrackIndex v-if="!isSmall" :index="index" :is_fav="is_fav" @add-to-fav="addToFav(track.trackhash)" />
<TrackTitle :track="track" :is_current="isCurrent()" :is_current_playing="isCurrentPlaying()" @play="emitUpdate" />
<div class="song-artists">
<ArtistName :artists="track.artists" :albumartists="track.albumartists" />
</div>
<div
class="songlist-item rounded-sm"
:class="[{ current: isCurrent() }, { contexton: context_menu_showing }]"
@dblclick.prevent="emitUpdate"
@contextmenu.prevent="showMenu"
>
<TrackIndex v-if="!isSmall" :index="index" :is_fav="is_fav" @add-to-fav="addToFav(track.trackhash)" />
<TrackTitle
:track="track"
:is_current="isCurrent()"
:is_current_playing="isCurrentPlaying()"
@play="emitUpdate"
/>
<div class="song-artists">
<ArtistName :artists="track.artists" :albumartists="track.albumartists" />
</div>
<TrackAlbum
:album="track.album || 'Unknown'"
:albumhash="track.albumhash || ''"
:hide_album="hide_album || false"
/>
<TrackDuration :duration="track.duration || 0" @showMenu="showMenu" />
</div>
<TrackAlbum
:album="track.album || 'Unknown'"
:albumhash="track.albumhash || ''"
:hide_album="hide_album || false"
/>
<TrackDuration :duration="track.duration || 0" @showMenu="showMenu" :help_text="track.help_text" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
import { onBeforeUnmount, ref, watch } from 'vue'
import { dropSources, favType } from "@/enums";
import { showTrackContextMenu as showContext } from "@/helpers/contextMenuHandler";
import favoriteHandler from "@/helpers/favoriteHandler";
import { Track } from "@/interfaces";
import { isSmall } from "@/stores/content-width";
import useQueueStore from "@/stores/queue";
import { dropSources, favType } from '@/enums'
import { showTrackContextMenu as showContext } from '@/helpers/contextMenuHandler'
import favoriteHandler from '@/helpers/favoriteHandler'
import { Track } from '@/interfaces'
import { isSmall } from '@/stores/content-width'
import useQueueStore from '@/stores/queue'
import ArtistName from "./ArtistName.vue";
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 ArtistName from './ArtistName.vue'
import TrackAlbum from './SongItem/TrackAlbum.vue'
import TrackDuration from './SongItem/TrackDuration.vue'
import TrackIndex from './SongItem/TrackIndex.vue'
import TrackTitle from './SongItem/TrackTitle.vue'
const context_menu_showing = ref(false);
const context_menu_showing = ref(false)
const queue = useQueueStore();
const queue = useQueueStore()
const props = defineProps<{
track: Track;
index: number | string;
hide_album?: boolean;
is_queue_track?: boolean;
droppable?: boolean;
is_last?: boolean;
source: dropSources;
}>();
track: Track
index: number | string
hide_album?: boolean
is_queue_track?: boolean
droppable?: boolean
is_last?: boolean
source: dropSources
}>()
const is_fav = ref(props.track.is_favorite || false);
const is_fav = ref(props.track.is_favorite || false)
const emit = defineEmits<{
(e: "playThis"): void;
(e: "trackDropped", source: dropSources, track: Track, newIndex: number, oldIndex: number): void;
}>();
(e: 'playThis'): void
(e: 'trackDropped', source: dropSources, track: Track, newIndex: number, oldIndex: number): void
}>()
function emitUpdate() {
emit("playThis");
emit('playThis')
}
function showMenu(e: MouseEvent) {
showContext(e, props.track, context_menu_showing);
showContext(e, props.track, context_menu_showing)
}
function isCurrent() {
if (props.is_queue_track) {
return queue.currentindex == parseInt(props.index as string) - 1;
}
if (props.is_queue_track) {
return queue.currentindex == parseInt(props.index as string) - 1
}
return queue.currenttrackhash == props.track.trackhash;
return queue.currenttrackhash == props.track.trackhash
}
function isCurrentPlaying() {
return isCurrent() && queue.playing;
return isCurrent() && queue.playing
}
function addToFav(trackhash: string) {
favoriteHandler(
is_fav.value,
favType.track,
trackhash,
() => (is_fav.value = true),
() => (is_fav.value = false)
);
favoriteHandler(
is_fav.value,
favType.track,
trackhash,
() => (is_fav.value = true),
() => (is_fav.value = false)
)
}
const stopWatcher = watch(
() => props.track.trackhash,
() => {
is_fav.value = props.track.is_favorite;
}
);
() => props.track.trackhash,
() => {
is_fav.value = props.track.is_favorite
}
)
onBeforeUnmount(() => {
stopWatcher();
});
stopWatcher()
})
</script>
<style lang="scss">
// NOTE: CSS for responsiveness is at app-grid.scss
.songlist-item {
display: grid;
grid-template-columns: 1.75rem 1.25fr 1fr 1fr 5.5rem;
align-items: center;
justify-content: flex-start;
gap: 1rem;
font-weight: 500;
line-height: 1.2;
height: $song-item-height;
user-select: none;
padding-left: $small;
position: relative;
transition: background-color 0.2s ease-out;
display: grid;
grid-template-columns: 1.75rem 1.25fr 1fr 1fr 7.5rem;
align-items: center;
justify-content: flex-start;
gap: 1rem;
font-weight: 500;
line-height: 1.2;
height: $song-item-height;
user-select: none;
padding-left: $small;
position: relative;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray5;
&:hover {
background-color: $gray5;
.index {
.text {
transition-delay: 400ms;
transform: translateX(0);
opacity: 0;
}
.heart-icon {
transition-delay: 400ms;
transform: translateX(0);
opacity: 1;
visibility: visible;
}
}
.song-duration.has_help_text {
opacity: 0;
}
// INFO: Show help text on hover
.song-duration.help-text {
opacity: 1;
}
}
.index {
.text {
transition-delay: 400ms;
overflow: unset !important;
transform: translateX(0);
opacity: 0;
}
.heart-icon {
transition-delay: 400ms;
transform: translateX(0);
opacity: 1;
visibility: visible;
}
.heart-icon {
opacity: 0;
visibility: hidden;
}
}
}
.index {
overflow: unset !important;
.heart-icon {
opacity: 0;
visibility: hidden;
.song-artists {
width: fit-content;
max-width: calc(100% - 10px);
}
}
.song-artists {
width: fit-content;
max-width: 100%;
}
}
.songlist-item.current {
background-color: $gray;
background-color: $gray;
}
.songlist-item.contexton {
background-color: $gray4 !important;
background-color: $gray4 !important;
}
</style>

View File

@@ -1,35 +1,36 @@
<template>
<router-link
v-if="!hide_album"
v-tooltip
class="song-album ellip"
:to="{
name: 'AlbumView',
params: {
albumhash: albumhash || 'Unknown',
},
}"
>
{{ album }}
</router-link>
<router-link
v-if="!hide_album"
v-tooltip
class="song-album ellip"
:to="{
name: 'AlbumView',
params: {
albumhash: albumhash || 'Unknown',
},
}"
>
{{ album }}
</router-link>
</template>
<script setup lang="ts">
defineProps<{
hide_album: boolean;
albumhash: string;
album: string;
}>();
hide_album: boolean
albumhash: string
album: string
}>()
</script>
<style lang="scss">
.song-album {
max-width: max-content;
color: inherit;
cursor: pointer !important;
max-width: max-content;
max-width: calc(100% - 10px);
color: inherit;
cursor: pointer !important;
&:hover {
text-decoration: underline;
}
&:hover {
text-decoration: underline;
}
}
</style>

View File

@@ -1,63 +1,81 @@
<template>
<div class="options-and-duration">
<div class="song-duration">{{ formatSeconds(duration) }}</div>
<div class="options-icon circular" @click.stop="$emit('showMenu', $event)" @dblclick.stop="() => {}">
<OptionSvg />
<div class="options-and-duration">
<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 }}
</div>
<div class="options-icon circular" @click.stop="$emit('showMenu', $event)" @dblclick.stop="() => {}">
<OptionSvg />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import OptionSvg from "@/assets/icons/more.svg";
import { formatSeconds } from "@/utils";
import OptionSvg from '@/assets/icons/more.svg'
import { formatSeconds } from '@/utils'
defineProps<{
duration: number;
}>();
duration: number
help_text?: string
}>()
defineEmits<{
(e: "showMenu", event: MouseEvent): void;
}>();
(e: 'showMenu', event: MouseEvent): void
}>()
</script>
<style lang="scss">
.songlist-item > .options-and-duration {
display: flex;
align-items: center;
gap: 1rem;
@include allPhones {
gap: $small;
justify-content: end;
margin-right: $small;
}
.song-duration {
font-size: small;
font-variant-numeric: tabular-nums;
text-align: left;
@include mediumPhones {
display: none;
}
}
.options-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 1;
width: 2rem;
transition: background-color 0.2s ease-out;
justify-content: end;
gap: 1rem;
margin-right: $small;
position: relative;
svg {
stroke: $gray1;
@include allPhones {
gap: $small;
}
&:hover {
background-color: $gray3;
.song-duration {
font-size: small;
font-variant-numeric: tabular-nums;
text-align: left;
@include mediumPhones {
display: none;
}
transition: opacity 0.1s ease-out;
}
.song-duration.help-text {
position: absolute;
// INFO: 3 rem is the width of the options icon (2rem) plus the gap of the flex container (1rem)
right: 3rem;
font-size: $medium;
text-transform: uppercase;
color: $orange;
opacity: 0;
transition: opacity 0.1s ease-out;
}
.options-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 1;
width: 2rem;
transition: background-color 0.2s ease-out;
svg {
stroke: $gray1;
}
&:hover {
background-color: $gray3;
}
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
// WARNING: EXPERIMENT!!!
import axios from 'axios'
export default async function usePlayer(url: string) {
const audioContext = new AudioContext()
const track = audioContext.createBufferSource()
const chunkSize = 524288 // 0.5MB in bytes
let totalSize = 0
let downloadedSize = 0
let playedSize = 0
const fetchAudioChunks = async (start: number, end: number) => {
const response = await axios.get(url, {
responseType: 'arraybuffer',
headers: {
Range: `bytes=${start}-${end}`,
},
withCredentials: true,
})
console.log('response', response.headers)
if (totalSize === 0) {
const contentRange = response.headers['content-range']
totalSize = parseInt(contentRange.split('/')[1])
}
console.log(response.data.byteLength)
const bytes = response.data as ArrayBuffer
downloadedSize += bytes.byteLength
console.log('byte length', bytes)
const audioData = await audioContext.decodeAudioData(bytes)
console.log('downloaded size', downloadedSize)
console.log('audiodata', audioData)
if (!track.buffer) {
track.buffer = audioData
} else {
const newBuffer = audioContext.createBuffer(
audioData.numberOfChannels,
track.buffer.length + audioData.length,
audioData.sampleRate
)
for (let channel = 0; channel < audioData.numberOfChannels; channel++) {
newBuffer.copyToChannel(track.buffer.getChannelData(channel), channel, 0)
newBuffer.copyToChannel(audioData.getChannelData(channel), channel, track.buffer.length)
}
track.buffer = newBuffer
}
if (downloadedSize < totalSize) {
fetchAudioChunks(downloadedSize, Math.min(downloadedSize + chunkSize - 1, totalSize - 1))
}
}
await fetchAudioChunks(0, chunkSize)
track.connect(audioContext.destination)
track.onended = () => {
playedSize = totalSize
}
const updatePlayedSize = () => {
if (track.buffer) {
playedSize = Math.floor(track.context.currentTime * track.buffer.sampleRate)
if (playedSize + chunkSize > downloadedSize && downloadedSize < totalSize) {
fetchAudioChunks(downloadedSize, Math.min(downloadedSize + chunkSize - 1, totalSize - 1))
}
}
requestAnimationFrame(updatePlayedSize)
}
return {
track,
play: () => {
track.start()
updatePlayedSize()
},
pause: () => track.stop(),
getProgress: () => ({
downloaded: downloadedSize / totalSize,
played: playedSize / totalSize,
}),
}
}

View File

@@ -19,12 +19,12 @@ const imageRoutes = {
large: '/thumbnail/',
small: '/thumbnail/xsmall/',
smallish: '/thumbnail/small/',
medium: "/thumbnail/medium/"
medium: '/thumbnail/medium/',
},
artist: {
large: '/artist/',
small: '/artist/small/',
medium: "/artist/medium/"
medium: '/artist/medium/',
},
playlist: '/playlist/',
}
@@ -135,9 +135,9 @@ export const paths = {
get trigger_scan() {
return this.base + '/trigger-scan'
},
get updateConfig(){
return this.base + "/update"
}
get updateConfig() {
return this.base + '/update'
},
},
files: base_url + '/file',
home: {
@@ -166,7 +166,7 @@ export const paths = {
get addUser() {
return this.base + '/profile/create'
},
get addGuestUser(){
get addGuestUser() {
return this.base + '/profile/guest/create'
},
get updateProfile() {
@@ -175,9 +175,36 @@ export const paths = {
get deleteUser() {
return this.base + '/profile/delete'
},
get pair(){
get pair() {
return this.base + '/getpaircode'
}
},
},
backups: {
base: base_url + '/backup',
get get_backups() {
return this.base + '/list'
},
get create_backup() {
return this.base + '/create'
},
get restore_backup() {
return this.base + '/restore'
},
get delete_backup() {
return this.base + '/delete'
},
},
stats: {
base: base_url + '/logger',
get topArtists() {
return this.base + '/top-artists'
},
get topAlbums() {
return this.base + '/top-albums'
},
get topTracks() {
return this.base + '/top-tracks'
},
},
},
images: {
@@ -185,12 +212,12 @@ export const paths = {
small: baseImgUrl + imageRoutes.thumb.small,
smallish: baseImgUrl + imageRoutes.thumb.smallish,
large: baseImgUrl + imageRoutes.thumb.large,
medium: baseImgUrl + imageRoutes.thumb.medium
medium: baseImgUrl + imageRoutes.thumb.medium,
},
artist: {
small: baseImgUrl + imageRoutes.artist.small,
large: baseImgUrl + imageRoutes.artist.large,
medium: baseImgUrl + imageRoutes.artist.medium
medium: baseImgUrl + imageRoutes.artist.medium,
},
playlist: baseImgUrl + imageRoutes.playlist,
},

View File

@@ -36,6 +36,10 @@ export interface Track extends AlbumDisc {
master_index?: number
help_text?: string
time?: string
trend?: {
trend: 'rising' | 'falling' | 'stable',
is_new: boolean
}
}
export interface Folder {
@@ -68,6 +72,10 @@ export interface Album {
is_favorite: boolean
genres: Genre[]
versions: string[]
trend?: {
trend: 'rising' | 'falling' | 'stable',
is_new: boolean
}
}
export interface Artist {
@@ -82,6 +90,13 @@ export interface Artist {
help_text?: string
time?: string
genres: Genre[]
// available in charts
trend?: {
trend: 'rising' | 'falling' | 'stable',
is_new: boolean
}
extra?: any
}
export interface Option {

View File

@@ -2,6 +2,7 @@ import { paths } from '@/config'
import { Artist, Playlist, Track } from '@/interfaces'
import { NotifType, Notification, useToast } from '@/stores/notification'
import useAxios from './useAxios'
import useFolder from '@/stores/pages/folder'
const { new: newPlaylistUrl, base: basePlaylistUrl, artists: playlistArtistsUrl } = paths.api.playlist
@@ -109,9 +110,14 @@ export function addAlbumToPlaylist(playlist: Playlist, albumhash: string) {
}
export function addFolderToPlaylist(playlist: Playlist, path: string) {
const folder = useFolder()
return addItemToPlaylist(playlist, {
itemtype: 'folder',
itemhash: path,
sortoptions: {
tracksortby: folder.trackSortBy,
tracksortreverse: folder.trackSortReverse,
},
})
}
@@ -161,9 +167,14 @@ export function saveAlbumAsPlaylist(playlist_name: string, itemhash: string) {
}
export function saveFolderAsPlaylist(playlist_name: string, itemhash: string) {
const folder = useFolder()
return saveItemAsPlaylist('folder', {
itemhash,
playlist_name,
sortoptions: {
tracksortby: folder.trackSortBy,
tracksortreverse: folder.trackSortReverse,
},
})
}

View File

@@ -23,3 +23,49 @@ export async function updateConfig(key: string, value: any) {
},
})
}
// SECTION: BACKUPS
export async function getBackups() {
interface Backup {
name: string
playlists: number
scrobbles: number
favorites: number
date: string
}
const { data, status } = await useAxios({
url: paths.api.backups.get_backups,
method: 'GET',
})
return data.backups as Backup[]
}
export async function restoreBackup(backup_dir?: string) {
return await useAxios({
url: paths.api.backups.restore_backup,
method: 'POST',
props: {
backup_dir,
},
})
}
export async function backupNow() {
return await useAxios({
url: paths.api.backups.create_backup,
method: 'POST',
})
}
export async function deleteBackup(backup_dir: string) {
return await useAxios({
url: paths.api.backups.delete_backup,
method: 'DELETE',
props: {
backup_dir,
},
})
}

32
src/requests/stats.ts Normal file
View File

@@ -0,0 +1,32 @@
import { paths } from '@/config'
import useAxios from './useAxios'
export async function getChartItem(item: string, duration: string, limit: number, order_by: string) {
const res = await useAxios({
url: paths.api.stats.base + `/top-${item}?duration=${duration}&limit=${limit}&order_by=${order_by}`,
method: 'GET',
})
return res
}
export async function getTopArtists(duration: number, limit: number, order_by: string) {
return await getChartItem('artists', duration, limit, order_by)
}
export async function getTopAlbums(duration: number, limit: number, order_by: string) {
return await getChartItem('albums', duration, limit, order_by)
}
export async function getTopTracks(duration: string, limit: number, order_by: string) {
return await getChartItem('tracks', duration, limit, order_by)
}
export async function getStats() {
const res = await useAxios({
url: paths.api.stats.base + "/stats",
method: 'GET',
})
return res
}

View File

@@ -25,6 +25,7 @@ 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 folder = {
path: "/folder/:path",
@@ -175,6 +176,12 @@ const AlbumListView = {
component: AlbumList,
};
const Stats = {
path: "/stats",
name: "StatsView",
component: StatsView,
};
const ArtistListView = {
...AlbumListView,
path: "/artists",
@@ -201,6 +208,7 @@ const routes = [
AlbumListView,
ArtistListView,
LyricsView,
Stats,
];
const Routes = {
@@ -223,6 +231,7 @@ const Routes = {
AlbumList: AlbumListView.name,
ArtistList: ArtistListView.name,
Lyrics: LyricsView.name,
Stats: Stats.name,
};
const router = createRouter({

View File

@@ -38,4 +38,45 @@ const crossfade: Setting = {
show_if: () => settings().use_crossfade,
}
export default [use_legacy_streaming_endpoint, use_silence, use_crossfade, 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 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]

View File

@@ -13,5 +13,7 @@ export enum SettingType {
profile,
accounts,
pairing,
about
about,
streaming_quality,
backup
}

View File

@@ -0,0 +1,21 @@
import { Setting } from '@/interfaces/settings'
import { SettingType } from '../enums'
const automatic_backups: Setting = {
title: 'Automatic backups',
desc: 'Automatically backup your data, every 6 hours',
type: SettingType.binary,
state: () => false,
action: () => {},
inactive: () => true,
}
const restore: Setting = {
title: 'Backup now',
desc: 'Backup directory: ~/swingmusic.backups',
type: SettingType.backup,
state: () => true,
action: () => {},
}
export default [automatic_backups, restore]

View File

@@ -1,26 +1,26 @@
import { loggedInUserIsAdmin } from '../utils'
import useSettings from '@/stores/settings'
import { loggedInUserIsAdmin } from '../utils'
import { SettingCategory } from '@/interfaces/settings'
import * as strings from '../strings'
import albums from './albums'
import restore from './backup'
import circularArtistImg from './circular-artist-img'
import contextChildrenShowMode from './context-children-show-mode'
import extendWidth from './extend-width'
import nowPlaying from './now-playing-group'
import sidebarSettings from './sidebar'
import rootDirSettings from './root-dirs'
import albums from './albums'
import separators from './separators'
import tracks from './tracks'
import circularArtistImg from './circular-artist-img'
import layout from './layout'
import folderlistmode from './folderlistmode'
import layout from './layout'
import nowPlaying from './now-playing-group'
import rootDirSettings from './root-dirs'
import separators from './separators'
import sidebarSettings from './sidebar'
import tracks from './tracks'
// icons
import AppearanceSvg from '@/assets/icons/paintbrush.svg?raw'
import FolderSvg from '@/assets/icons/folder.svg?raw'
import TrackSvg from '@/assets/icons/mic.svg?raw'
import AlbumSvg from '@/assets/icons/album.svg?raw'
import AvatarSvg from '@/assets/icons/artist.svg?raw'
import FolderSvg from '@/assets/icons/folder.svg?raw'
import TrackSvg from '@/assets/icons/mic.svg?raw'
import AppearanceSvg from '@/assets/icons/paintbrush.svg?raw'
const npStrings = strings.nowPlayingStrings
const rootRootStrings = strings.manageRootDirsStrings
@@ -79,6 +79,12 @@ export const library = {
desc: 'Customize artist separators',
settings: [separators],
},
{
title: "Backup",
icon: AvatarSvg,
desc: "Backup and restore your settings",
settings: [...restore],
}
],
} as SettingCategory

View File

@@ -16,10 +16,10 @@ export default defineStore('FolderDirs&Tracks', {
allDirs: <Folder[]>[],
allTracks: <Track[]>[],
trackTotal: 0,
trackSortBy: 'playcount',
folderSortBy: 'default',
trackSortReverse: true,
folderSortReverse: true,
trackSortBy: 'default',
folderSortBy: 'lastmod',
trackSortReverse: false,
folderSortReverse: false,
}),
actions: {
async fetchAll(fpath: string, restart?: boolean) {

View File

@@ -16,7 +16,11 @@ import { crossFade } from '@/utils/audio/crossFade'
import updatePageTitle from '@/utils/updatePageTitle'
export function getUrl(filepath: string, trackhash: string, use_legacy: boolean) {
return `${paths.api.files}/${trackhash + (use_legacy ? '/legacy' : '')}?filepath=${encodeURIComponent(filepath)}`
const { streaming_container, streaming_quality } = useSettings()
return `${paths.api.files}/${trackhash + (use_legacy ? '/legacy' : '')}?filepath=${encodeURIComponent(
filepath
)}&container=${streaming_container}&quality=${streaming_quality}`
}
let audio = new Audio()
@@ -62,7 +66,9 @@ export const usePlayer = defineStore('player', () => {
function clearNextAudioData() {
nextAudioData.filepath = ''
nextAudioData.audio.removeEventListener('canplay', () => null)
nextAudioData.audio = new Audio()
nextAudioData.loaded = false
nextAudioData.ticking = false
nextAudioData.silence = {
@@ -170,7 +176,16 @@ export const usePlayer = defineStore('player', () => {
const onAudioEnded = () => {
const { submitData } = tracker
submitData()
// queue.autoPlayNext()
console.log('audio ended')
console.log(nextAudioData)
// INFO: if next audio is not loaded, manually move forward
if (nextAudioData.loaded === false) {
console.log('next audio not loaded')
clearNextAudioData()
queue.autoPlayNext()
}
}
const onAudioPlay = () => {
@@ -202,6 +217,9 @@ export const usePlayer = defineStore('player', () => {
}
const handleNextAudioCanPlay = async () => {
// INFO: Keep a key for this query to ignore the result if the track has changed
const key = queue.currenttrack.trackhash + queue.next.trackhash
if (!settings.use_silence_skip) {
nextAudioData.silence.starting_file = 0
currentAudioData.silence.ending_file = Math.floor(audio.duration * 1000)
@@ -217,7 +235,13 @@ export const usePlayer = defineStore('player', () => {
})
worker.onmessage = e => {
// INFO: if the track has changed, abort.
if (queue.currenttrack.trackhash + queue.next.trackhash !== key) {
return
}
const silence = e.data
nextAudioData.silence.starting_file = silence.starting_file
currentAudioData.silence.ending_file = silence.ending_file
nextAudioData.loaded = silence !== null

View File

@@ -40,6 +40,8 @@ export default defineStore('settings', {
// client
useCircularArtistImg: true,
nowPlayingTrackOnTabTitle: false,
streaming_quality: 'original',
streaming_container: 'mp3',
// plugins
use_lyrics_plugin: <boolean | undefined>false,
@@ -275,6 +277,9 @@ export default defineStore('settings', {
'show_albums_as_singles'
)
},
setStreamingQuality(quality: string) {
this.streaming_quality = quality
},
},
getters: {
can_extend_width(): boolean {

86
src/utils/stats.ts Normal file
View File

@@ -0,0 +1,86 @@
export function getDuration(time: string): number {
return 23731580
const now = new Date()
let start: Date
switch (time) {
case 'day':
start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
break
case 'week':
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay())
break
case 'month':
start = new Date(now.getFullYear(), now.getMonth(), 1)
break
case 'year':
start = new Date(now.getFullYear(), 0, 1)
break
default:
console.warn(`Invalid time unit: ${time}. Returning 0.`)
return 0
}
return Math.floor((now.getTime() - start.getTime()) / 1000)
}
export function getDateRange(time: string): string {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
const now = new Date()
let start: Date
let end: Date
switch (time) {
case 'day':
start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
end = new Date(start)
break
case 'week':
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay())
end = new Date(start)
end.setDate(end.getDate() + 6)
break
case 'month':
start = new Date(now.getFullYear(), now.getMonth(), 1)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
break
case 'year':
start = new Date(now.getFullYear(), 0, 1)
end = new Date(now.getFullYear(), 11, 31)
break
default:
console.warn(`Invalid time unit: ${time}. Returning empty string.`)
return ''
}
const formatDate = (date: Date) => `${date.getDate()} ${months[date.getMonth()]}, ${date.getFullYear()}`
return `${formatDate(start)} - ${formatDate(end)}`
}
export function getDateRangeFromSecondsAgo(seconds: number): string {
const now = new Date();
const past = new Date(now.getTime() - seconds * 1000);
const formatDate = (date: Date): string => {
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return `${date.getDate()} ${months[date.getMonth()]}, ${date.getFullYear()}`;
};
return `${formatDate(past)} - ${formatDate(now)}`;
}

View File

@@ -9,9 +9,13 @@ export default function createSubPaths(newpath: string, oldpath: string): [strin
if (oldpath === undefined) oldpath = ''
if (newpath === undefined) newpath = ''
newpath = newpath.replace(/\\/g, '/')
oldpath = oldpath.replace(/\\/g, '/')
if (newpath.endsWith('/')) {
newpath = newpath.slice(0, -1)
}
if (oldpath.endsWith('/')) {
oldpath = oldpath.slice(0, -1)
}
@@ -24,31 +28,33 @@ export default function createSubPaths(newpath: string, oldpath: string): [strin
newpath = newpath.replace('/', '')
}
const newlist = newpath.split('/')
const newlist = newpath.split('/').filter(Boolean)
// if (newlist[0] == "$home") {
// newlist.shift();
// }
if (oldpath.includes(newpath)) {
const oldlist = oldpath.split('/')
const oldlist = oldpath.split('/').filter(Boolean)
const current = newlist.slice(-1)[0]
return [oldpath, createSubs(oldlist, current)]
} else {
const current = newlist.slice(-1)[0]
return [newpath, createSubs(newlist, current)]
}
function createSubs(list: string[], current: string) {
const paths = list.map((path, index) => {
return {
active: false,
name: path,
path: list.slice(0, index + 1).join('/'),
}
})
const paths = list
.map((path, index) => {
return {
active: false,
name: path,
path: list.slice(0, index + 1).join('/'),
}
})
.filter(item => item.name)
paths.reverse()
for (let i = 0; i < paths.length; i++) {

View File

@@ -243,7 +243,7 @@ onBeforeRouteLeave(() => {
overflow: visible;
.songlist-item {
grid-template-columns: 1.75rem 1fr 1fr 5.5rem;
grid-template-columns: 1.75rem 1fr 1fr 7.5rem;
}
}
</style>

View File

@@ -123,7 +123,7 @@ async function playFromPage(index: number) {
let tracks = folder.allTracks
if (folder.trackTotal !== folder.allTracks.length) {
const { tracks: newTracks } = await getFiles(folder.path, 0, folder.trackTotal, true, {
const { tracks: newTracks } = await getFiles(folder.path, 0, -1, true, {
sorttracksby: folder.trackSortBy,
tracksort_reverse: folder.trackSortReverse,
sortfoldersby: folder.folderSortBy,

View File

@@ -2,13 +2,11 @@
<div class="homepageview content-page">
<GenericHeader>
<template #name>Home</template>
<template #description>{{
getGreetings(auth.user.username)
}}</template>
<template #description>{{ getGreetings(auth.user.username) }}</template>
</GenericHeader>
<Browse />
<RecentItems
v-if="home.recentlyPlayedFetched && home.recentlyPlayed.length"
v-if="!home.recentlyPlayedFetched || home.recentlyPlayed.length > 0"
:title="'Recently Played'"
:items="home.recentlyPlayed"
:play-source="playSources.track"
@@ -16,7 +14,7 @@
:see-all-text="'VIEW HISTORY'"
/>
<RecentItems
v-if="home.recentlyAddedFetched && home.recentlyAdded.length"
v-if="!home.recentlyAddedFetched || home.recentlyAdded.length > 0"
:title="'Recently Added'"
:items="home.recentlyAdded"
:play-source="playSources.recentlyAdded"
@@ -61,14 +59,12 @@ function getGreetings(username: string) {
onMounted(async () => {
updatePageTitle('Home')
await home.fetchRecentlyAdded()
nextTick().then(updateCardWidth)
await home.fetchRecentlyPlayed()
nextTick().then(updateCardWidth)
await home.fetchRecentlyAdded()
})
onBeforeRouteLeave(() => home.resetAll())
// onBeforeRouteLeave(() => home.resetAll())
</script>
<style lang="scss">

View File

@@ -31,97 +31,102 @@
</template>
<script setup lang="ts">
import { debouncedRef } from "@vueuse/core";
import { computed, onMounted, ref } from "vue";
import { debouncedRef } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import usePStore from "@/stores/pages/playlists";
import { useFuse } from "@/utils";
import updatePageTitle from "@/utils/updatePageTitle";
import usePStore from '@/stores/pages/playlists'
import { useFuse } from '@/utils'
import updatePageTitle from '@/utils/updatePageTitle'
import PlaylistSvg from "@/assets/icons/playlist-1.svg";
import PlusSvg from "@/assets/icons/plus.svg";
import PlaylistCardGroup from "@/components/PlaylistsList/PlaylistCardGroup.vue";
import Header from "@/components/shared/GenericHeader.vue";
import NoItems from "@/components/shared/NoItems.vue";
import useModalStore from "@/stores/modal";
import PlaylistSvg from '@/assets/icons/playlist-1.svg'
import PlusSvg from '@/assets/icons/plus.svg'
import PlaylistCardGroup from '@/components/PlaylistsList/PlaylistCardGroup.vue'
import Header from '@/components/shared/GenericHeader.vue'
import NoItems from '@/components/shared/NoItems.vue'
import useModalStore from '@/stores/modal'
const pStore = usePStore();
const { showNewPlaylistModal } = useModalStore();
const pStore = usePStore()
const { showNewPlaylistModal } = useModalStore()
const input = ref("");
const query = debouncedRef(input, 300);
const input = ref('')
const query = debouncedRef(input, 300)
const description = `You can create a playlist by right clicking on a track and selecting the
"Add to Playlist" option`;
"Add to Playlist" option`
// TODO: When you add a song to playlist when you are in this page, increase the count on the card.
const pinnedPlaylists = computed(() => {
return pStore.playlists.filter((p) => p.pinned);
});
return pStore.playlists.filter(p => p.pinned)
})
onMounted(() => {
updatePageTitle("Playlists");
});
updatePageTitle('Playlists')
})
const playlists = computed(() => {
if (!query.value) {
return pStore.playlists.filter((p) => !p.pinned);
}
if (!query.value) {
return pStore.playlists.filter(p => !p.pinned)
}
const p = useFuse(query.value, pStore.playlists, {
keys: ["name"],
});
const p = useFuse(query.value, pStore.playlists, {
keys: ['name'],
})
return p.value.map((r) => r.item);
});
return p.value.map(r => r.item)
})
</script>
<style lang="scss">
#p-view {
padding-bottom: $content-padding-bottom;
height: 100%;
overflow: auto;
padding-bottom: $content-padding-bottom;
height: 100%;
overflow: auto;
.playlist-button {
svg {
height: 1.5rem;
.playlist-button {
svg {
height: 1.5rem;
}
}
}
.grid {
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
gap: 2.5rem 1.5rem;
.grid {
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
gap: 2.5rem 1.5rem;
@include mediumPhones {
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 1rem;
@include mediumPhones {
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 1rem;
}
}
}
#playlistsearch {
width: 16rem;
max-width: 100%;
margin-top: 1rem;
background-color: $gray5;
color: white;
font-size: 0.95rem;
font-weight: 500;
padding: $medium;
outline: none;
appearance: none;
}
#playlistsearch {
width: 16rem;
max-width: 100%;
margin-top: 1rem;
background-color: $gray5;
color: white;
font-size: 0.95rem;
font-weight: 500;
padding: $medium;
outline: none;
appearance: none;
.playlist-button {
padding-right: $medium;
}
.nothing {
height: 50%;
svg {
margin-bottom: 0;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.playlist-button {
padding-right: $medium;
}
.nothing {
height: 50%;
svg {
margin-bottom: 0;
}
}
}
}
</style>

17
src/views/Stats/main.vue Normal file
View File

@@ -0,0 +1,17 @@
<template>
<div class="content-page" style="height: 100%; overflow: auto">
<Charts />
<br><br>
<GenericHeader>
<template #name>Stats</template>
<template #description>Your listening stats for the past week</template>
</GenericHeader>
<Stats />
</div>
</template>
<script setup lang="ts">
import Charts from '@/components/Stats/Charts.vue'
import GenericHeader from '@/components/shared/GenericHeader.vue'
import Stats from '@/components/Stats/Stats.vue'
</script>