mirror of
https://github.com/swingmx/webclient.git
synced 2025-12-24 19:30:20 +00:00
Compare commits
36 Commits
another-on
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2d9537ff | ||
|
|
6f4a59f971 | ||
|
|
7b21853f97 | ||
|
|
663dbd2a7c | ||
|
|
c7a0b5ab7e | ||
|
|
ad8eeb7a2a | ||
|
|
e799c96872 | ||
|
|
234aed54d7 | ||
|
|
574d7fd5e7 | ||
|
|
4a1106d784 | ||
|
|
d9f7e5fb14 | ||
|
|
571c4a5264 | ||
|
|
e71bc7164c | ||
|
|
77f18ac640 | ||
|
|
78d57a64b9 | ||
|
|
ff502521e8 | ||
|
|
7caa70b9d6 | ||
|
|
cc3b372090 | ||
|
|
c297f75132 | ||
|
|
7c954ef805 | ||
|
|
9222e94b6c | ||
|
|
54c165b64a | ||
|
|
591509ebaf | ||
|
|
80a0bdbbf1 | ||
|
|
2e27da3f9f | ||
|
|
74bf8f5d78 | ||
|
|
bfdefc37fd | ||
|
|
44a877b9c9 | ||
|
|
db93fd554e | ||
|
|
40a7ad084c | ||
|
|
e44aa01d63 | ||
|
|
192e705890 | ||
|
|
50f92b65ab | ||
|
|
a5aea45cd6 | ||
|
|
56b1ab35d3 | ||
|
|
cc93fe7419 |
2
TODO.md
2
TODO.md
@@ -4,7 +4,6 @@
|
||||
- Check out the mobile sidebar and navbar
|
||||
- Remove old settings page files
|
||||
- Fix: track loading indicator in bottom bar
|
||||
|
||||
- Unfuck javascript controlled responsiveness
|
||||
|
||||
- Redesign the album page header for mobile
|
||||
@@ -14,7 +13,6 @@
|
||||
- Add trailing slash to folder url accessed from the breadcrumb
|
||||
- Clip the browseable items on the homepage
|
||||
- Fix: The responsiveness glitch between 900px - 964px 😅
|
||||
- Fix: Queue repeat
|
||||
- Make All Albums/Artists view sort banner sticky
|
||||
|
||||
# DONE ✅
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"qr-code-styling": "^1.6.0-rc.1",
|
||||
"v-wave": "^1.5.0",
|
||||
"vue": "^v3.2.45",
|
||||
"vue": "^v3.5.13",
|
||||
"vue-boring-avatars": "^1.4.0",
|
||||
"vue-debounce": "^3.0.2",
|
||||
"vue-router": "^4.1.3",
|
||||
@@ -45,6 +45,7 @@
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^3.0.4",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"vite-plugin-singlefile": "^0.13.5",
|
||||
"vite-svg-loader": "^4.0.0",
|
||||
|
||||
4
src/assets/icons/explicit.svg
Normal file
4
src/assets/icons/explicit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.84421 21.8972H18.0295C20.5685 21.8972 21.8737 20.5919 21.8737 18.0914V3.82921C21.8737 1.32656 20.5685 0.0234375 18.0295 0.0234375H3.84421C1.31484 0.0234375 0 1.31695 0 3.82921V18.0914C0 20.6016 1.31484 21.8972 3.84421 21.8972Z" fill="#aeaeaf"/>
|
||||
<path d="M8.24921 16.3608C7.44976 16.3608 7.04688 15.8618 7.04688 15.0368V6.67026C7.04688 5.84948 7.45187 5.34839 8.24921 5.34839H13.795C14.3777 5.34839 14.7607 5.68026 14.7607 6.26643C14.7607 6.8376 14.3777 7.19619 13.795 7.19619H9.33695V9.92808H13.5377C14.0824 9.92808 14.4464 10.2356 14.4464 10.7923C14.4464 11.3222 14.0824 11.6255 13.5377 11.6255H9.33695V14.513H13.795C14.3777 14.513 14.7607 14.8545 14.7607 15.4406C14.7607 16.0118 14.3777 16.3608 13.795 16.3608H8.24921Z" fill="#111111"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 831 B |
11
src/assets/icons/lastfm.svg
Normal file
11
src/assets/icons/lastfm.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="currentColor" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M14.131 22.948l-1.172-3.193c0 0-1.912 2.131-4.771 2.131-2.537 0-4.333-2.203-4.333-5.729 0-4.511 2.276-6.125 4.515-6.125 3.224 0 4.245 2.089 5.125 4.772l1.161 3.667c1.161 3.561 3.365 6.421 9.713 6.421 4.548 0 7.631-1.391 7.631-5.068 0-2.968-1.697-4.511-4.844-5.244l-2.344-0.511c-1.624-0.371-2.104-1.032-2.104-2.131 0-1.249 0.985-1.984 2.604-1.984 1.767 0 2.704 0.661 2.865 2.24l3.661-0.444c-0.297-3.301-2.584-4.656-6.323-4.656-3.308 0-6.532 1.251-6.532 5.245 0 2.5 1.204 4.077 4.245 4.807l2.484 0.589c1.865 0.443 2.484 1.224 2.484 2.287 0 1.359-1.323 1.921-3.828 1.921-3.703 0-5.244-1.943-6.124-4.625l-1.204-3.667c-1.541-4.765-4.005-6.531-8.891-6.531-5.287-0.016-8.151 3.385-8.151 9.192 0 5.573 2.864 8.595 8.005 8.595 4.14 0 6.125-1.943 6.125-1.943z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
src/assets/icons/pencil.svg
Normal file
4
src/assets/icons/pencil.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.42607 18.5857L16.593 5.42412L14.344 3.16546L1.1674 16.3366L0.0267015 19.0816C-0.10197 19.4303 0.258496 19.8049 0.592479 19.6708L3.42607 18.5857ZM17.715 4.32139L18.9829 3.07476C19.6122 2.44546 19.6378 1.7482 19.0703 1.16906L18.6128 0.709452C18.0454 0.139922 17.3439 0.200625 16.7125 0.808593L15.4467 2.06273L17.715 4.32139Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -57,6 +57,7 @@ $g-border: solid 1px $gray5;
|
||||
.b-bar {
|
||||
grid-area: bottombar;
|
||||
border-top: $g-border;
|
||||
// background-color: $bars;
|
||||
}
|
||||
|
||||
.content-page {
|
||||
@@ -127,7 +128,8 @@ $g-border: solid 1px $gray5;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
background-color: $gray;
|
||||
// background-color: $bars;
|
||||
border-bottom: $g-border;
|
||||
}
|
||||
|
||||
.vue-recycle-scroller,
|
||||
|
||||
@@ -267,4 +267,9 @@ button {
|
||||
.badge.new {
|
||||
background-color: $blue;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.explicit-icon {
|
||||
width: 0.9rem;
|
||||
margin-left: $smaller;
|
||||
}
|
||||
@@ -21,13 +21,13 @@ $content-padding-bottom: 2rem;
|
||||
$black: #181a1c;
|
||||
$white: #ffffffde;
|
||||
|
||||
$gray: #1c1c1e;
|
||||
$gray: #1a1919;
|
||||
$gray1: #8e8e93;
|
||||
$gray2: #636366;
|
||||
$gray3: #48484a;
|
||||
$gray4: #3a3a3c;
|
||||
$gray5: #2c2c2e;
|
||||
$body: #111111;
|
||||
$body: #000;
|
||||
|
||||
$red: #f7635c;
|
||||
$blue: #0a84ff;
|
||||
@@ -41,6 +41,7 @@ $brown: #ac8e68;
|
||||
$indigo: #5e5ce6;
|
||||
$teal: rgb(64, 200, 224);
|
||||
$lightbrown: #ebca89;
|
||||
$bars: #111111;
|
||||
|
||||
$primary: $gray4;
|
||||
$accent: $gray1;
|
||||
|
||||
@@ -1,170 +1,177 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!on_sidebar"
|
||||
class="artist-header-ambient rounded-lg"
|
||||
:class="{ isSmallPhone }"
|
||||
style="height: 100%; width: 100%"
|
||||
:style="{
|
||||
boxShadow: !useCircularImage ? (colors.bg.length ? `0 .5rem 2rem ${colors.bg}` : undefined) : undefined,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
ref="artistheader"
|
||||
class="artist-page-header rounded-lg no-scroll"
|
||||
:class="{ isSmallPhone, useCircularImage }"
|
||||
:style="{
|
||||
height: `${isSmallPhone ? '25rem' : containerHeight}`,
|
||||
}"
|
||||
>
|
||||
<Info :artist="artist" :use-circular-image="useCircularImage" />
|
||||
<div
|
||||
class="artist-img no-select"
|
||||
:style="{
|
||||
height: containerHeight,
|
||||
}"
|
||||
>
|
||||
<img id="artist-avatar" :src="paths.images.artist.large + artist.image" @load="store.setBgColor" />
|
||||
<div class="headparent">
|
||||
<div
|
||||
v-if="!on_sidebar"
|
||||
class="artist-header-ambient rounded-lg"
|
||||
:class="{ isSmallPhone }"
|
||||
:style="{
|
||||
boxShadow: !useCircularImage ? (colors.bg.length ? `0 .5rem 2rem ${colors.bg}` : undefined) : undefined,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
ref="artistheader"
|
||||
class="artist-page-header rounded-lg no-scroll"
|
||||
:class="{ isSmallPhone, useCircularImage }"
|
||||
:style="{
|
||||
height: `${isSmallPhone ? '25rem' : containerHeight}`,
|
||||
}"
|
||||
>
|
||||
<Info :artist="artist" :use-circular-image="useCircularImage" />
|
||||
<div
|
||||
class="artist-img no-select"
|
||||
:style="{
|
||||
height: containerHeight,
|
||||
}"
|
||||
>
|
||||
<img id="artist-avatar" :src="paths.images.artist.large + artist.image" @load="store.setBgColor" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!useCircularImage"
|
||||
class="gradient"
|
||||
:style="{
|
||||
backgroundImage: colors.bg
|
||||
? `linear-gradient(${gradientDirection}, transparent ${
|
||||
isSmall ? 60 : gradientTransparentWidth - (width < 700 ? 40 : width < 900 ? 20 : 10)
|
||||
}%,
|
||||
${colors.bg} ${gradientWidth}%,
|
||||
${colors.bg} 100%)`
|
||||
: '',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!useCircularImage"
|
||||
class="gradient"
|
||||
:style="{
|
||||
backgroundImage: colors.bg
|
||||
? `linear-gradient(${gradientDirection}, transparent ${
|
||||
isSmall ? 60 : gradientTransparentWidth - (width < 700 ? 40 : width < 900 ? 20 : 10)
|
||||
}%,
|
||||
${colors.bg} ${gradientWidth}%,
|
||||
${colors.bg} 100%)`
|
||||
: '',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { Ref, computed, onMounted, ref } from "vue";
|
||||
import { onBeforeRouteUpdate } from "vue-router";
|
||||
import useSettingsStore from '@/stores/settings'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Ref, computed, onMounted, ref } from 'vue'
|
||||
import { onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
import { paths } from "@/config";
|
||||
import updatePageTitle from "@/utils/updatePageTitle";
|
||||
import { paths } from '@/config'
|
||||
import updatePageTitle from '@/utils/updatePageTitle'
|
||||
|
||||
import { isSmall } from "@/stores/content-width";
|
||||
import useArtistStore from "@/stores/pages/artist";
|
||||
import Info from "./HeaderComponents/Info.vue";
|
||||
import { isSmall } from '@/stores/content-width'
|
||||
import useArtistStore from '@/stores/pages/artist'
|
||||
import Info from './HeaderComponents/Info.vue'
|
||||
|
||||
const image_width_px = 450;
|
||||
const store = useArtistStore();
|
||||
const settings = useSettingsStore();
|
||||
const image_width_px = 450
|
||||
const store = useArtistStore()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const props = defineProps<{
|
||||
on_sidebar?: boolean;
|
||||
}>();
|
||||
on_sidebar?: boolean
|
||||
}>()
|
||||
|
||||
const { info: artist, colors } = storeToRefs(store);
|
||||
const { info: artist, colors } = storeToRefs(store)
|
||||
|
||||
function updateTitle() {
|
||||
props.on_sidebar ? () => {} : updatePageTitle(artist.value.name);
|
||||
props.on_sidebar ? () => {} : updatePageTitle(artist.value.name)
|
||||
}
|
||||
|
||||
onMounted(() => updateTitle());
|
||||
onBeforeRouteUpdate(() => updateTitle());
|
||||
onMounted(() => updateTitle())
|
||||
onBeforeRouteUpdate(() => updateTitle())
|
||||
|
||||
const artistheader: Ref<HTMLElement | null> = ref(null);
|
||||
const { width } = useElementSize(artistheader);
|
||||
const artistheader: Ref<HTMLElement | null> = ref(null)
|
||||
const { width } = useElementSize(artistheader)
|
||||
|
||||
const gradientTransparentWidth = computed(() => Math.floor((image_width_px / (width.value || 1)) * 100));
|
||||
const gradientTransparentWidth = computed(() => Math.floor((image_width_px / (width.value || 1)) * 100))
|
||||
|
||||
const isSmallPhone = computed(() => width.value <= 660);
|
||||
const useCircularImage = computed(() => !isSmallPhone.value && settings.useCircularArtistImg);
|
||||
const isSmallPhone = computed(() => width.value <= 660)
|
||||
const useCircularImage = computed(() => !isSmallPhone.value && settings.useCircularArtistImg)
|
||||
|
||||
const gradientDirection = computed(() => (isSmallPhone.value ? "210deg" : "to left"));
|
||||
const gradientDirection = computed(() => (isSmallPhone.value ? '210deg' : 'to left'))
|
||||
|
||||
const gradientWidth = computed(() => {
|
||||
return isSmallPhone.value ? "80" : Math.min(gradientTransparentWidth.value, 50);
|
||||
});
|
||||
return isSmallPhone.value ? '80' : Math.min(gradientTransparentWidth.value, 50)
|
||||
})
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
return useCircularImage.value ? "13rem" : "18rem";
|
||||
});
|
||||
return useCircularImage.value ? '13rem' : '18rem'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.headparent {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.artist-header-ambient {
|
||||
height: 17rem;
|
||||
position: absolute;
|
||||
opacity: 0.25;
|
||||
margin-right: -1rem;
|
||||
height: 18rem;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.artist-page-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 450px;
|
||||
position: relative;
|
||||
|
||||
.artist-img {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
order: 1;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
}
|
||||
|
||||
&.useCircularImage {
|
||||
grid-template-columns: min-content 1fr;
|
||||
|
||||
.artist-img {
|
||||
padding: 1rem;
|
||||
order: -1;
|
||||
z-index: 10;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
height: calc(100% - 0rem);
|
||||
width: unset;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gradient {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(to left, transparent 10%, $gray 50%, $gray 100%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.isSmallPhone {
|
||||
background-image: linear-gradient(210deg, transparent 20%, $gray 80%, $gray 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&.isSmallPhone {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 450px;
|
||||
position: relative;
|
||||
|
||||
.artist-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
height: 100% !important;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
order: 1;
|
||||
|
||||
img {
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
}
|
||||
|
||||
&.useCircularImage {
|
||||
grid-template-columns: min-content 1fr;
|
||||
|
||||
.artist-img {
|
||||
padding: 1rem;
|
||||
order: -1;
|
||||
z-index: 10;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
height: calc(100% - 0rem);
|
||||
width: unset;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gradient {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(to left, transparent 10%, $gray 50%, $gray 100%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
|
||||
&.isSmallPhone {
|
||||
background-image: linear-gradient(210deg, transparent 20%, $gray 80%, $gray 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&.isSmallPhone {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
position: relative;
|
||||
|
||||
.artist-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
height: 100% !important;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: 0% 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,7 +63,6 @@ function handleFav() {
|
||||
|
||||
<style lang="scss">
|
||||
.b-bar {
|
||||
background-color: rgb(22, 22, 22);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content 1fr;
|
||||
align-items: center;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<span class="ellip">
|
||||
{{ queue.currenttrack?.title || 'Hello there' }}
|
||||
</span>
|
||||
<ExplicitIcon class="explicit-icon" v-if="queue.currenttrack?.explicit" />
|
||||
<MasterFlag :bitrate="queue.currenttrack?.bitrate || 0" />
|
||||
</div>
|
||||
<ArtistName
|
||||
@@ -61,6 +62,7 @@ import HotKeys from '../LeftSidebar/NP/HotKeys.vue'
|
||||
import HeartSvg from '../shared/HeartSvg.vue'
|
||||
import MasterFlag from '../shared/MasterFlag.vue'
|
||||
import Actions from './Right.vue'
|
||||
import ExplicitIcon from '@/assets/icons/explicit.svg'
|
||||
|
||||
const queue = useQStore()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<Volume />
|
||||
<button
|
||||
class="repeat"
|
||||
:class="{ 'repeat-disabled': settings.no_repeat }"
|
||||
:title="settings.repeat_all ? 'Repeat all' : settings.no_repeat ? 'No repeat' : 'Repeat one'"
|
||||
:class="{ 'repeat-disabled': settings.repeat == 'none' }"
|
||||
:title="settings.repeat == 'all' ? 'Repeat all' : settings.repeat == 'one' ? 'Repeat one' : 'No repeat'"
|
||||
@click="settings.toggleRepeatMode"
|
||||
>
|
||||
<RepeatOneSvg v-if="settings.repeat_one" />
|
||||
<RepeatOneSvg v-if="settings.repeat == 'one'" />
|
||||
<RepeatAllSvg v-else />
|
||||
</button>
|
||||
<button title="Shuffle" @click="queue.shuffleQueue">
|
||||
|
||||
@@ -94,7 +94,7 @@ const res_type = computed(() => {
|
||||
type It = Album & Artist & Track
|
||||
|
||||
const item = computed(() => {
|
||||
return top_results.value.top_result.item as It
|
||||
return top_results.value.top_result as It
|
||||
})
|
||||
|
||||
const context_menu_showing = ref(false)
|
||||
@@ -106,7 +106,7 @@ function showMenu(e: MouseEvent) {
|
||||
|
||||
<style lang="scss">
|
||||
.top-result-item {
|
||||
background-color: $gray5;
|
||||
background-color: $gray;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<div class="item__favorites">
|
||||
{{ backup.favorites }} favorite{{ backup.favorites !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
•
|
||||
<div class="item__collections">
|
||||
{{ backup.collections }} collection{{ backup.collections !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
@@ -55,6 +59,7 @@ interface Backup {
|
||||
playlists: number
|
||||
scrobbles: number
|
||||
favorites: number
|
||||
collections: number
|
||||
date: string
|
||||
}
|
||||
const backups = ref<Backup[]>([])
|
||||
|
||||
80
src/components/SettingsView/Components/SecretInput.vue
Normal file
80
src/components/SettingsView/Components/SecretInput.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<form class="secretinput" @submit.prevent="$emit('submit', input)">
|
||||
<div class="left rounded-sm no-scroll">
|
||||
<input :type="showText ? 'text' : 'password'" v-model="input" @input="() => (showTextManual = true)" />
|
||||
<button @click.prevent="showTextManual = !showTextManual">
|
||||
<EyeSvg v-if="showText" />
|
||||
<EyeSlashSvg v-else />
|
||||
</button>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import EyeSvg from '@/assets/icons/eye.svg'
|
||||
import EyeSlashSvg from '@/assets/icons/eye.slash.svg'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
const showTextManual = ref(false)
|
||||
const showText = computed(() => {
|
||||
if (showTextManual.value) return true
|
||||
|
||||
return input.value.length == 0
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'submit', value: string): void
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
if (props.text) {
|
||||
input.value = props.text
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.secretinput {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
background-color: $gray5;
|
||||
|
||||
input {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
padding: $small;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono';
|
||||
color: #ffffff00;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -84,6 +84,11 @@
|
||||
component_key="streaming_quality"
|
||||
/>
|
||||
<BackupRestore v-if="setting.type === SettingType.backup" />
|
||||
<SecretInput
|
||||
v-if="setting.type === SettingType.secretinput"
|
||||
:text="setting.state ? setting.state() : ''"
|
||||
@submit="setting.action"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,6 +112,7 @@ import Pairing from '../modals/settings/custom/Pairing.vue'
|
||||
import DropDown from '../shared/DropDown.vue'
|
||||
import About from './About.vue'
|
||||
import BackupRestore from './Components/BackupRestore.vue'
|
||||
import SecretInput from './Components/SecretInput.vue'
|
||||
|
||||
defineProps<{
|
||||
group: SettingGroup
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="statitem" :class="props.icon">
|
||||
<div class="statitem" :class="props.icon" :style="dynamicBackgroundStyle">
|
||||
<svg
|
||||
class="noise"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -35,7 +35,7 @@
|
||||
surfaceScale="21"
|
||||
specularConstant="1.7"
|
||||
specularExponent="20"
|
||||
lighting-color="#7957A8"
|
||||
lighting-color="transparent"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
@@ -50,20 +50,20 @@
|
||||
<rect width="700" height="700" fill="transparent"></rect>
|
||||
<rect width="700" height="700" fill="#7957a8" filter="url(#nnnoise-filter)"></rect>
|
||||
</svg>
|
||||
<div class="itemcontent">
|
||||
<div class="itemcontent" :style="{ color: textColor }">
|
||||
<div class="count ellip2" :title="formattedValue">{{ formattedValue }}</div>
|
||||
<div class="title">{{ text }}</div>
|
||||
</div>
|
||||
|
||||
<component :is="icon" class="staticon" v-if="!props.icon.startsWith('top')" />
|
||||
<component :is="icon" v-if="!props.icon.startsWith('top')" class="staticon" :style="{ color: textColor }" />
|
||||
<router-link
|
||||
v-if="props.icon.startsWith('top') && props.image"
|
||||
:to="{
|
||||
name: Routes.album,
|
||||
params: {
|
||||
albumhash: props.image?.replace('.webp', ''),
|
||||
},
|
||||
}"
|
||||
v-if="props.icon.startsWith('top') && props.image"
|
||||
>
|
||||
<img class="staticon statimage shadow-sm" :src="paths.images.thumb.small + props.image" alt="" />
|
||||
</router-link>
|
||||
@@ -81,6 +81,11 @@ import SparklesSvg from '@/assets/icons/sparkles.svg'
|
||||
|
||||
import { paths } from '@/config'
|
||||
import { Routes } from '@/router'
|
||||
import useArtistStore from '@/stores/pages/artist'
|
||||
import useAlbumStore from '@/stores/pages/album'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getTextColor } from '@/utils/colortools/shift'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
@@ -89,6 +94,13 @@ const props = defineProps<{
|
||||
image?: string
|
||||
}>()
|
||||
|
||||
// Get current route and colors from stores
|
||||
const route = useRoute()
|
||||
const artistStore = useArtistStore()
|
||||
const albumStore = useAlbumStore()
|
||||
const { colors: artistColors } = storeToRefs(artistStore)
|
||||
const { colors: albumColors } = storeToRefs(albumStore)
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.icon) {
|
||||
case 'streams':
|
||||
@@ -110,6 +122,61 @@ const icon = computed(() => {
|
||||
const formattedValue = computed(() => {
|
||||
return props.value.toLocaleString()
|
||||
})
|
||||
|
||||
// Determine which dynamic color to use based on current route
|
||||
const dynamicColor = computed(() => {
|
||||
switch (route.name) {
|
||||
// Album-related pages should use album colors
|
||||
case Routes.album:
|
||||
return albumColors.value?.bg || null
|
||||
|
||||
// Artist-related pages should use artist colors
|
||||
case Routes.artist:
|
||||
return artistColors.value?.bg || null
|
||||
|
||||
// All other pages should use default colors
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Default hardcoded background styles
|
||||
const defaultBackgroundStyles = computed(() => {
|
||||
switch (props.icon) {
|
||||
case 'streams':
|
||||
return 'linear-gradient(to top, #c79081 0%, #dfa579 100%)'
|
||||
case 'playtime':
|
||||
return 'linear-gradient(-225deg, #3d4e81 0%, #5753c9 48%, #6e7ff3 100%)'
|
||||
case 'trackcount':
|
||||
return 'linear-gradient(to top, #6a66b9 0%, #7777db 52%, #7b7bd4 100%)'
|
||||
case 'toptrack':
|
||||
return 'linear-gradient(-225deg, #65379b 0%, #6750b3 53%, #6457c6 100%)'
|
||||
default:
|
||||
return 'linear-gradient(to top right, rgb(120, 76, 129), #9643da91, rgb(132, 80, 228))'
|
||||
}
|
||||
})
|
||||
|
||||
// Computed style that uses dynamic color or falls back to hardcoded
|
||||
const dynamicBackgroundStyle = computed(() => {
|
||||
if (dynamicColor.value) {
|
||||
return {
|
||||
backgroundColor: dynamicColor.value,
|
||||
backgroundImage: 'none',
|
||||
}
|
||||
}
|
||||
return {
|
||||
backgroundImage: defaultBackgroundStyles.value,
|
||||
}
|
||||
})
|
||||
|
||||
// Computed text color based on background using the same logic as headers
|
||||
const textColor = computed(() => {
|
||||
if (dynamicColor.value) {
|
||||
return getTextColor(dynamicColor.value)
|
||||
}
|
||||
// Return default white color when using gradients
|
||||
return '#ffffff'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -121,25 +188,10 @@ const formattedValue = computed(() => {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
|
||||
// Default background - will be overridden by dynamic styles
|
||||
background-image: linear-gradient(to top right, rgb(120, 76, 129), #9643da91, rgb(132, 80, 228));
|
||||
position: relative;
|
||||
|
||||
&.streams {
|
||||
background-image: linear-gradient(to top, #c79081 0%, #dfa579 100%);
|
||||
}
|
||||
|
||||
&.playtime {
|
||||
background-image: linear-gradient(-225deg, #3d4e81 0%, #5753c9 48%, #6e7ff3 100%);
|
||||
}
|
||||
|
||||
&.trackcount {
|
||||
background-image: linear-gradient(to top, #6a66b9 0%, #7777db 52%, #7b7bd4 100%);
|
||||
}
|
||||
|
||||
&.toptrack {
|
||||
background-image: linear-gradient(-225deg, #65379b 0%, #6750b3 53%, #6457c6 100%);
|
||||
}
|
||||
|
||||
.itemcontent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -57,6 +57,10 @@ onMounted(async () => {
|
||||
date.value = res.data.dates
|
||||
}
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
@hideModal="hideModal"
|
||||
@setTitle="setTitle"
|
||||
/>
|
||||
<CrudPage
|
||||
v-if="modal.component == modal.options.page"
|
||||
@hideModal="hideModal"
|
||||
@setTitle="setTitle"
|
||||
v-bind="modal.props"
|
||||
/>
|
||||
<UpdatePlaylist
|
||||
v-if="modal.component == modal.options.updatePlaylist"
|
||||
v-bind="modal.props"
|
||||
@@ -49,6 +55,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import AuthLogin from './modals/AuthLogin.vue'
|
||||
import ConfirmModal from './modals/ConfirmModal.vue'
|
||||
import CrudPage from './modals/CrudPage.vue'
|
||||
import NewPlaylist from './modals/NewPlaylist.vue'
|
||||
import RootDirsPrompt from './modals/RootDirsPrompt.vue'
|
||||
import SetRootDirs from './modals/SetRootDirs.vue'
|
||||
|
||||
82
src/components/modals/CrudPage.vue
Normal file
82
src/components/modals/CrudPage.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<form action="" v-if="delete">
|
||||
<br>
|
||||
<div>Are you sure you want to delete this collection?</div>
|
||||
<br />
|
||||
<button @click.prevent="submit" class="critical">Yes, Delete</button>
|
||||
</form>
|
||||
<form class="playlist-modal" @submit.prevent="submit" v-else>
|
||||
<label for="name">Collection name</label>
|
||||
<br />
|
||||
<input type="search" class="rounded-sm" id="name" :value="collection?.name" />
|
||||
<br />
|
||||
<label for="description">Description</label>
|
||||
<br />
|
||||
<input type="search" class="rounded-sm" id="description" :value="collection?.extra.description" />
|
||||
<br /><br />
|
||||
<button type="submit">{{ collection ? 'Update' : 'Create' }}</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Collection } from '@/interfaces'
|
||||
import { createNewCollection, deleteCollection, updateCollection } from '@/requests/collections'
|
||||
import { router } from '@/router'
|
||||
import { NotifType, Notification } from '@/stores/notification'
|
||||
|
||||
const props = defineProps<{
|
||||
collection?: Collection
|
||||
hash?: string
|
||||
type?: string
|
||||
extra?: any
|
||||
delete?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'hideModal'): void
|
||||
(e: 'setTitle', title: string): void
|
||||
}>()
|
||||
|
||||
emit('setTitle', (props.collection ? (props.delete ? 'Delete' : 'Update') : 'New') + ' Collection')
|
||||
|
||||
async function submit(e: Event) {
|
||||
if (props.delete && props.collection) {
|
||||
const deleted = await deleteCollection(props.collection.id)
|
||||
if (deleted) {
|
||||
new Notification('Collection deleted', NotifType.Success)
|
||||
emit('hideModal')
|
||||
router.push('/')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
const name = (e.target as any).elements['name'].value
|
||||
const description = (e.target as any).elements['description'].value
|
||||
|
||||
// If the page is null, we are creating a new page
|
||||
if (props.collection == null) {
|
||||
const created = await createNewCollection(name, description, [
|
||||
{
|
||||
hash: props.hash as string,
|
||||
type: props.type as string,
|
||||
extra: props.extra,
|
||||
},
|
||||
])
|
||||
|
||||
if (created) {
|
||||
new Notification('New collection created', NotifType.Success)
|
||||
emit('hideModal')
|
||||
}
|
||||
} else {
|
||||
const updatedPage = await updateCollection(props.collection, name, description)
|
||||
|
||||
if (updatedPage) {
|
||||
props.collection.name = updatedPage.name
|
||||
props.collection.extra.description = updatedPage.extra.description
|
||||
new Notification('Collection updated', NotifType.Success)
|
||||
emit('hideModal')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -5,6 +5,8 @@
|
||||
params: { albumhash: album.albumhash },
|
||||
}"
|
||||
class="album-card"
|
||||
@contextmenu.prevent="showMenu"
|
||||
:class="{ 'context-menu-open': contextMenuFlag }"
|
||||
>
|
||||
<div class="with-img rounded-sm no-scroll">
|
||||
<div
|
||||
@@ -56,7 +58,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Routes } from '@/router'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { Album } from '../../interfaces'
|
||||
@@ -66,9 +68,11 @@ import { playSources } from '@/enums'
|
||||
import useAlbumStore from '@/stores/pages/album'
|
||||
import { paths } from '../../config'
|
||||
import MasterFlag from './MasterFlag.vue'
|
||||
import { showAlbumContextMenu } from '@/helpers/contextMenuHandler'
|
||||
|
||||
const imguri = paths.images.thumb.medium
|
||||
const route = useRoute()
|
||||
const contextMenuFlag = ref(false)
|
||||
const imguri = paths.images.thumb.medium
|
||||
|
||||
const props = defineProps<{
|
||||
album: Album
|
||||
@@ -94,6 +98,10 @@ const artists = computed(() => {
|
||||
|
||||
return albumartists
|
||||
})
|
||||
|
||||
function showMenu(e: MouseEvent) {
|
||||
showAlbumContextMenu(e, contextMenuFlag, props.album)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -105,6 +113,10 @@ const artists = computed(() => {
|
||||
height: max-content;
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
&.context-menu-open {
|
||||
background-color: $gray5;
|
||||
}
|
||||
|
||||
.with-img {
|
||||
position: relative;
|
||||
|
||||
@@ -130,10 +142,6 @@ const artists = computed(() => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
img {
|
||||
/* border-radius: 0 0 $medium $medium; Not sure why this one was added, fugly with animation */
|
||||
}
|
||||
|
||||
.gradient {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
},
|
||||
}"
|
||||
class="artist-card"
|
||||
@contextmenu.prevent="showContextMenu"
|
||||
:class="{ 'context-menu-open': contextMenuFlag }"
|
||||
>
|
||||
<div class="image circular">
|
||||
<img class="artist-image circular" :src="imguri + artist.image" />
|
||||
@@ -38,12 +40,19 @@ import { Routes } from '@/router'
|
||||
|
||||
import { playSources } from '@/enums'
|
||||
import PlayBtn from './PlayBtn.vue'
|
||||
import { ref } from 'vue'
|
||||
import { showArtistContextMenu } from '@/helpers/contextMenuHandler'
|
||||
|
||||
const imguri = paths.images.artist.medium
|
||||
const contextMenuFlag = ref(false)
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
artist: Artist
|
||||
}>()
|
||||
|
||||
const showContextMenu = (e: MouseEvent) => {
|
||||
showArtistContextMenu(e, contextMenuFlag, props.artist.artisthash, props.artist.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -59,6 +68,10 @@ defineProps<{
|
||||
height: max-content;
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
&.context-menu-open {
|
||||
background-color: $gray5;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
|
||||
|
||||
@@ -1,36 +1,66 @@
|
||||
<template>
|
||||
<div v-if="type == 'album'" class="cardlistrow">
|
||||
<AlbumCard v-for="item in items" :key="item.albumhash" class="hlistitem" :album="(item as Album)" />
|
||||
</div>
|
||||
<div v-else-if="type == 'artist'" class="cardlistrow">
|
||||
<ArtistCard v-for="item in items" :key="item.artisthash" class="hlistitem" :artist="(item as Artist)" />
|
||||
</div>
|
||||
<div v-else-if="type == 'mix'" class="cardlistrow">
|
||||
<MixCard v-for="item in items" :key="item.sourcehash" class="hlistitem" :mix="(item as Mix)" />
|
||||
</div>
|
||||
<div class="cardlistrow">
|
||||
<component v-for="item in items" :key="item.key" :is="item.component" v-bind="item.props" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Album, Artist, Mix } from "@/interfaces";
|
||||
import AlbumCard from "./AlbumCard.vue";
|
||||
import ArtistCard from "./ArtistCard.vue";
|
||||
import MixCard from "../Mixes/MixCard.vue";
|
||||
import { Album, Artist, Mix } from '@/interfaces'
|
||||
import AlbumCard from './AlbumCard.vue'
|
||||
import ArtistCard from './ArtistCard.vue'
|
||||
import MixCard from '../Mixes/MixCard.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
type: string | "album" | "artist" | "mix";
|
||||
items: Album[] | Artist[] | Mix[];
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
items: Album[] | Artist[] | Mix[]
|
||||
}>()
|
||||
|
||||
const items = computed(() => {
|
||||
return props.items.map((item: any) => {
|
||||
const i = {
|
||||
component: <any>null,
|
||||
props: {},
|
||||
key: '',
|
||||
}
|
||||
|
||||
switch (item['type']) {
|
||||
case 'album':
|
||||
i.component = AlbumCard
|
||||
i.key = item.albumhash
|
||||
i.props = {
|
||||
album: item,
|
||||
}
|
||||
break
|
||||
case 'artist':
|
||||
i.component = ArtistCard
|
||||
i.key = item.artisthash
|
||||
i.props = {
|
||||
artist: item,
|
||||
}
|
||||
break
|
||||
case 'mix':
|
||||
i.component = MixCard
|
||||
i.key = item.sourcehash
|
||||
i.props = {
|
||||
mix: item,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return i
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.cardlistrow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
|
||||
padding-bottom: 2rem;
|
||||
z-index: -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
|
||||
padding-bottom: 2rem;
|
||||
z-index: -1;
|
||||
|
||||
@include mediumPhones {
|
||||
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
||||
}
|
||||
@include mediumPhones {
|
||||
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
<div class="cardscroller">
|
||||
<div class="rinfo">
|
||||
<div class="rtitle">
|
||||
<b>{{ title }}</b>
|
||||
<SeeAll v-if="route && itemlist.length >= maxAbumCards" :route="route" :text="seeAllText" />
|
||||
<b>
|
||||
<RouterLink :to="route || ''">
|
||||
{{ title }}
|
||||
</RouterLink>
|
||||
</b>
|
||||
<!-- INFO: This SEE ALL is shown when there's no description. Eg. in favorites page -->
|
||||
<SeeAll
|
||||
v-if="!description && route && itemlist.length >= maxAbumCards"
|
||||
:route="route"
|
||||
:text="seeAllText"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="description" class="rdesc">
|
||||
{{ description }}
|
||||
<RouterLink :to="route || ''">
|
||||
{{ description }}
|
||||
</RouterLink>
|
||||
<!-- INFO: This SEE ALL is shown when there's a description. Eg. in the home page -->
|
||||
<SeeAll v-if="route && itemlist.length >= maxAbumCards" :route="route" :text="seeAllText" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="recentitems">
|
||||
@@ -84,7 +97,7 @@ function getComponent(type: string) {
|
||||
return FolderCard
|
||||
case 'playlist':
|
||||
return PlaylistCard
|
||||
case 'favorite_tracks':
|
||||
case 'favorite':
|
||||
return FavoritesCard
|
||||
case 'mix':
|
||||
return MixCard
|
||||
@@ -121,7 +134,7 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
|
||||
return {
|
||||
playlist: item.item,
|
||||
}
|
||||
case 'favorite_tracks':
|
||||
case 'favorite':
|
||||
return {
|
||||
item: item.item,
|
||||
}
|
||||
@@ -165,6 +178,9 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
|
||||
.rdesc {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.747);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,80 +1,126 @@
|
||||
<template>
|
||||
<RouterLink :to="{ name: Routes.favoriteTracks }" class="favoritescard rounded">
|
||||
<div class="img">
|
||||
<svg width="100" height="100" viewBox="0 0 28 28" fill="#ff453a" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.9912 22.1445C14.2197 22.1445 14.5449 21.9775 14.8086 21.8105C19.7217 18.6465 22.8682 14.9375 22.8682 11.1758C22.8682 7.9502 20.6445 5.7002 17.8408 5.7002C16.0918 5.7002 14.7822 6.66699 13.9912 8.11719C13.2178 6.67578 11.8994 5.7002 10.1504 5.7002C7.34668 5.7002 5.11426 7.9502 5.11426 11.1758C5.11426 14.9375 8.26074 18.6465 13.1738 21.8105C13.4463 21.9775 13.7715 22.1445 13.9912 22.1445Z"
|
||||
/>
|
||||
</svg>
|
||||
<PlayBtn :source="playSources.favorite" />
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="rhelp playlist">
|
||||
<span class="help">PLAYLIST</span>
|
||||
<span class="time">{{ item.time }}</span>
|
||||
</div>
|
||||
<div class="title">Favorite Tracks</div>
|
||||
<div class="fcount">
|
||||
<b>{{ item.count + ` Track${item.count == 1 ? "" : "s"}` }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: Routes.favoriteTracks }" class="favoritescard rounded">
|
||||
<div class="img">
|
||||
<div class="blur" :style="{ backgroundImage: `url(${paths.images.thumb.small + item.image})` }"></div>
|
||||
</div>
|
||||
<div class="overlay">
|
||||
<PlayBtn :source="playSources.favorite" />
|
||||
<svg
|
||||
class="heart"
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 28 28"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
:style="{ color: color }"
|
||||
<path
|
||||
d="M13.9912 22.1445C14.2197 22.1445 14.5449 21.9775 14.8086 21.8105C19.7217 18.6465 22.8682 14.9375 22.8682 11.1758C22.8682 7.9502 20.6445 5.7002 17.8408 5.7002C16.0918 5.7002 14.7822 6.66699 13.9912 8.11719C13.2178 6.67578 11.8994 5.7002 10.1504 5.7002C7.34668 5.7002 5.11426 7.9502 5.11426 11.1758C5.11426 14.9375 8.26074 18.6465 13.1738 21.8105C13.4463 21.9775 13.7715 22.1445 13.9912 22.1445Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="rhelp playlist">
|
||||
<span class="help">PLAYLIST</span>
|
||||
<span class="time">{{ item.time }}</span>
|
||||
</div>
|
||||
<div class="title">Favorite Tracks</div>
|
||||
<div class="fcount">
|
||||
<b>{{ item.count + ` Track${item.count == 1 ? '' : 's'}` }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { playSources } from "@/enums";
|
||||
import { Routes } from "@/router";
|
||||
import PlayBtn from "../shared/PlayBtn.vue";
|
||||
import { paths } from '@/config'
|
||||
import { Routes } from '@/router'
|
||||
import { playSources } from '@/enums'
|
||||
import PlayBtn from '../shared/PlayBtn.vue'
|
||||
|
||||
defineProps<{
|
||||
item: any;
|
||||
}>();
|
||||
item: {
|
||||
time: string
|
||||
count: number
|
||||
image: string
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.favoritescard {
|
||||
padding: $medium;
|
||||
|
||||
.img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
background-color: $gray5;
|
||||
border-radius: $small;
|
||||
margin-bottom: $medium;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-image: linear-gradient(37deg, $gray5, $gray, $gray);
|
||||
justify-content: center;
|
||||
padding: $medium;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
position: absolute;
|
||||
width: 4rem;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.img,
|
||||
.overlay {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: $small;
|
||||
margin-bottom: $medium;
|
||||
}
|
||||
|
||||
.fcount {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.img {
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray4;
|
||||
.blur {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(37deg, $gray5, $gray, $gray);
|
||||
// background-image: url('http://localhost:1980/img/thumbnail/xsmall/e74d8c49e8d6340f.webp?pathhash=24bf8142d7150965');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: brightness(0.5) blur(15px);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
$size: calc(100% - $medium * 2);
|
||||
position: absolute;
|
||||
top: $medium;
|
||||
left: $medium;
|
||||
width: $size;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heart {
|
||||
color: $pink;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(-1rem);
|
||||
position: absolute;
|
||||
width: 4rem;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
.fcount {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $gray4;
|
||||
|
||||
.play-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(-1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
grid-template-columns: 1fr max-content;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.after {
|
||||
margin-top: 2rem;
|
||||
margin-left: -$medium;
|
||||
|
||||
@@ -28,7 +28,7 @@ defineProps<{
|
||||
font-weight: 600;
|
||||
margin-left: $smaller;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.75;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
<div
|
||||
class="songlist-item rounded-sm"
|
||||
:class="[{ current: isCurrent() }, { contexton: context_menu_showing }]"
|
||||
@dblclick.prevent="emitUpdate"
|
||||
@dblclick="emitUpdate"
|
||||
@contextmenu.prevent="showMenu"
|
||||
>
|
||||
<TrackIndex v-if="!isSmall" :index="index" :is_fav="is_fav" @add-to-fav="addToFav(track.trackhash)" />
|
||||
<TrackIndex
|
||||
v-if="!isSmall"
|
||||
:index="index"
|
||||
:is_fav="is_fav"
|
||||
:show-inline-fav-icon="settings.showInlineFavIcon"
|
||||
@add-to-fav="addToFav(track.trackhash)"
|
||||
/>
|
||||
|
||||
<TrackTitle
|
||||
:track="track"
|
||||
:is_current="isCurrent()"
|
||||
@@ -23,10 +30,13 @@
|
||||
/>
|
||||
<TrackDuration
|
||||
:duration="track.duration || 0"
|
||||
@showMenu="showMenu"
|
||||
:help_text="track.help_text"
|
||||
:is_fav="is_fav"
|
||||
:showFavIcon="!isFavoritesPage"
|
||||
:showInlineFavIcon="settings.showInlineFavIcon"
|
||||
:highlightFavoriteTracks="settings.highlightFavoriteTracks"
|
||||
@showMenu="showMenu"
|
||||
@toggleFav="addToFav(track.trackhash)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -47,7 +57,9 @@ import TrackAlbum from './SongItem/TrackAlbum.vue'
|
||||
import TrackDuration from './SongItem/TrackDuration.vue'
|
||||
import TrackIndex from './SongItem/TrackIndex.vue'
|
||||
import TrackTitle from './SongItem/TrackTitle.vue'
|
||||
import useSettings from '@/stores/settings'
|
||||
|
||||
const settings = useSettings()
|
||||
const context_menu_showing = ref(false)
|
||||
|
||||
const queue = useQueueStore()
|
||||
@@ -131,9 +143,9 @@ const isFavoritesPage = route.path.startsWith('/favorites')
|
||||
transition: background-color 0.2s ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray5;
|
||||
background-color: $gray;
|
||||
|
||||
.index {
|
||||
.index.ready {
|
||||
.text {
|
||||
transition-delay: 400ms;
|
||||
|
||||
@@ -157,6 +169,10 @@ const isFavoritesPage = route.path.startsWith('/favorites')
|
||||
.song-duration.help-text {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.options-and-duration .heart-icon.showInlineFavIcon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.index {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="options-and-duration">
|
||||
<div v-if="is_fav && showFavIcon !== false" class="heart-icon is-favorited">
|
||||
<div
|
||||
v-if="showInlineFavIcon"
|
||||
class="heart-icon"
|
||||
:class="{ showInlineFavIcon, 'is_fav': is_fav && highlightFavoriteTracks }"
|
||||
@click.stop="$emit('toggleFav')"
|
||||
>
|
||||
<HeartSvg :state="is_fav" :no_emit="true" />
|
||||
</div>
|
||||
<div class="song-duration" :class="{ has_help_text: help_text }">{{ formatSeconds(duration) }}</div>
|
||||
@@ -21,12 +26,15 @@ import HeartSvg from '../HeartSvg.vue'
|
||||
defineProps<{
|
||||
duration: number
|
||||
is_fav: boolean
|
||||
showInlineFavIcon: boolean
|
||||
highlightFavoriteTracks: boolean
|
||||
showFavIcon?: boolean
|
||||
help_text?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'showMenu', event: MouseEvent): void
|
||||
(e: 'toggleFav'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -39,23 +47,24 @@ defineEmits<{
|
||||
margin-right: $small;
|
||||
position: relative;
|
||||
|
||||
@include allPhones {
|
||||
gap: $small;
|
||||
}
|
||||
|
||||
@include mediumPhones {
|
||||
> .heart-icon.is-favorited {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .heart-icon.is-favorited {
|
||||
display: block;
|
||||
.heart-icon {
|
||||
display: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease-out;
|
||||
transform: scale(0.8);
|
||||
margin-right: $small;
|
||||
|
||||
svg {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
@include mediumPhones {
|
||||
display: none;
|
||||
@@ -66,6 +75,10 @@ defineEmits<{
|
||||
}
|
||||
}
|
||||
|
||||
.heart-icon.is_fav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
font-size: small;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
class="index t-center ellip"
|
||||
@click.prevent="$emit('addToFav')"
|
||||
@dblclick.prevent.stop="() => {}"
|
||||
:class="{ 'ready': !showInlineFavIcon }"
|
||||
>
|
||||
<div class="text">
|
||||
{{ index }}
|
||||
</div>
|
||||
<div class="heart-icon">
|
||||
<div class="heart-icon" v-if="!showInlineFavIcon">
|
||||
<HeartSvg :state="is_fav" :no_emit="true" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,6 +20,7 @@ import HeartSvg from "../HeartSvg.vue";
|
||||
defineProps<{
|
||||
index: number | string;
|
||||
is_fav: boolean | undefined;
|
||||
showInlineFavIcon: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
@@ -53,7 +55,6 @@ defineEmits<{
|
||||
transition: all 0.2s;
|
||||
transform: translateX(-1.5rem);
|
||||
|
||||
|
||||
button {
|
||||
border: none;
|
||||
width: 2rem;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<span class="title ellip">
|
||||
{{ track.title }}
|
||||
</span>
|
||||
<ExplicitIcon class="explicit-icon" v-if="track.explicit" />
|
||||
<MasterFlag :bitrate="track.bitrate" />
|
||||
</div>
|
||||
<div class="isSmallArtists">
|
||||
@@ -40,6 +41,7 @@ const imguri = paths.images.thumb.small;
|
||||
|
||||
import ArtistName from "../ArtistName.vue";
|
||||
import MasterFlag from "../MasterFlag.vue";
|
||||
import ExplicitIcon from "@/assets/icons/explicit.svg";
|
||||
|
||||
import { paths } from "@/config";
|
||||
|
||||
@@ -59,6 +61,10 @@ defineEmits<{
|
||||
position: relative;
|
||||
align-items: center;
|
||||
|
||||
.explicit-icon {
|
||||
margin-left: $small;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
margin-right: $medium;
|
||||
display: flex;
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const development = import.meta.env.DEV
|
||||
|
||||
export function getBaseUrl() {
|
||||
const base_url = window.location.origin
|
||||
|
||||
if (!development) {
|
||||
return base_url
|
||||
return ''
|
||||
}
|
||||
|
||||
const base_url = window.location.origin
|
||||
const splits = base_url.split(':')
|
||||
return base_url.replace(splits[splits.length - 1], '1980')
|
||||
}
|
||||
|
||||
const base_url = getBaseUrl()
|
||||
axios.defaults.baseURL = base_url
|
||||
|
||||
const baseImgUrl = base_url + '/img'
|
||||
|
||||
const imageRoutes = {
|
||||
@@ -31,7 +34,7 @@ const imageRoutes = {
|
||||
|
||||
export const paths = {
|
||||
api: {
|
||||
favorites: base_url + '/favorites',
|
||||
favorites: '/favorites',
|
||||
get favAlbums() {
|
||||
return this.favorites + '/albums'
|
||||
},
|
||||
@@ -50,15 +53,15 @@ export const paths = {
|
||||
get removeFavorite() {
|
||||
return this.favorites + '/remove'
|
||||
},
|
||||
artist: base_url + '/artist',
|
||||
lyrics: base_url + '/lyrics',
|
||||
plugins: base_url + '/plugins',
|
||||
artist: '/artist',
|
||||
lyrics: '/lyrics',
|
||||
plugins: '/plugins',
|
||||
get mixes() {
|
||||
return this.plugins + '/mixes'
|
||||
},
|
||||
|
||||
// Single album
|
||||
album: base_url + '/album',
|
||||
album: '/album',
|
||||
get albumartists() {
|
||||
return this.album + '/artists'
|
||||
},
|
||||
@@ -72,12 +75,12 @@ export const paths = {
|
||||
return this.album + '/other-versions'
|
||||
},
|
||||
folder: {
|
||||
base: base_url + '/folder',
|
||||
showInFiles: base_url + '/folder/show-in-files',
|
||||
base: '/folder',
|
||||
showInFiles: '/folder/show-in-files',
|
||||
},
|
||||
dir_browser: base_url + '/folder/dir-browser',
|
||||
dir_browser: '/folder/dir-browser',
|
||||
playlist: {
|
||||
base: base_url + '/playlists',
|
||||
base: '/playlists',
|
||||
get new() {
|
||||
return this.base + '/new'
|
||||
},
|
||||
@@ -85,8 +88,11 @@ export const paths = {
|
||||
return this.base + '/artists'
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
base: '/collections',
|
||||
},
|
||||
search: {
|
||||
base: base_url + '/search',
|
||||
base: '/search',
|
||||
get top() {
|
||||
return this.base + '/top?q='
|
||||
},
|
||||
@@ -104,13 +110,13 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
base: base_url + '/logger',
|
||||
base: '/logger',
|
||||
get logTrack() {
|
||||
return this.base + '/track/log'
|
||||
},
|
||||
},
|
||||
getall: {
|
||||
base: base_url + '/getall',
|
||||
base: '/getall',
|
||||
get albums() {
|
||||
return this.base + '/albums'
|
||||
},
|
||||
@@ -119,7 +125,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
base: base_url + '/colors',
|
||||
base: '/colors',
|
||||
get album() {
|
||||
return this.base + '/album'
|
||||
},
|
||||
@@ -142,9 +148,9 @@ export const paths = {
|
||||
return this.base + '/update'
|
||||
},
|
||||
},
|
||||
files: base_url + '/file',
|
||||
files: '/file',
|
||||
home: {
|
||||
base: base_url + '/home',
|
||||
base: '/nothome',
|
||||
get recentlyAdded() {
|
||||
return this.base + '/recents/added'
|
||||
},
|
||||
@@ -183,7 +189,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
backups: {
|
||||
base: base_url + '/backup',
|
||||
base: '/backup',
|
||||
get get_backups() {
|
||||
return this.base + '/list'
|
||||
},
|
||||
@@ -198,7 +204,7 @@ export const paths = {
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
base: base_url + '/logger',
|
||||
base: '/logger',
|
||||
get topArtists() {
|
||||
return this.base + '/top-artists'
|
||||
},
|
||||
|
||||
@@ -1,56 +1,109 @@
|
||||
import useModal from "@/stores/modal";
|
||||
import useAlbum from "@/stores/pages/album";
|
||||
import useTracklist from "@/stores/queue/tracklist";
|
||||
import { router, Routes } from '@/router'
|
||||
|
||||
import { Option, Playlist } from "@/interfaces";
|
||||
import { addAlbumToPlaylist } from "@/requests/playlists";
|
||||
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
|
||||
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
|
||||
import useAlbum from '@/stores/pages/album'
|
||||
import useCollection from '@/stores/pages/collections'
|
||||
import useTracklist from '@/stores/queue/tracklist'
|
||||
|
||||
export default async () => {
|
||||
const album = useAlbum();
|
||||
import { getAlbumTracks } from '@/requests/album'
|
||||
import { addOrRemoveItemFromCollection } from '@/requests/collections'
|
||||
import { addAlbumToPlaylist } from '@/requests/playlists'
|
||||
|
||||
const play_next = <Option>{
|
||||
label: "Play next",
|
||||
action: () => {
|
||||
const tracks = album.tracks.filter(
|
||||
(track) => !track.is_album_disc_number
|
||||
);
|
||||
useTracklist().insertAfterCurrent(tracks);
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
};
|
||||
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
|
||||
import { Album, Collection, Option, Playlist, Track } from '@/interfaces'
|
||||
import { get_find_on_social, getAddToCollectionOptions, getAddToPlaylistOptions } from './utils'
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: "Add to queue",
|
||||
action: () => {
|
||||
const tracks = album.tracks.filter(
|
||||
(track) => !track.is_album_disc_number
|
||||
);
|
||||
useTracklist().addTracks(tracks);
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
};
|
||||
export default async (album?: Album) => {
|
||||
const albumStore = useAlbum()
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
const store = album;
|
||||
addAlbumToPlaylist(playlist, store.info.albumhash);
|
||||
};
|
||||
if (!album) {
|
||||
album = albumStore.info
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: "Add to Playlist",
|
||||
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
albumhash: album.info.albumhash,
|
||||
playlist_name: album.info.title,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
};
|
||||
const play_next = <Option>{
|
||||
label: 'Play next',
|
||||
action: async () => {
|
||||
let tracks: Track[] = []
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
get_find_on_social(),
|
||||
];
|
||||
};
|
||||
if (album) {
|
||||
tracks = await getAlbumTracks(album.albumhash)
|
||||
} else {
|
||||
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
|
||||
}
|
||||
|
||||
useTracklist().insertAfterCurrent(tracks)
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
}
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: 'Add to queue',
|
||||
action: async () => {
|
||||
let tracks: Track[] = []
|
||||
|
||||
if (album) {
|
||||
tracks = await getAlbumTracks(album.albumhash)
|
||||
} else {
|
||||
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
|
||||
}
|
||||
|
||||
useTracklist().addTracks(tracks)
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
}
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addAlbumToPlaylist(playlist, album.albumhash)
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: 'Add to Playlist',
|
||||
children: () =>
|
||||
getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
albumhash: album.albumhash,
|
||||
playlist_name: album.title,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const addToPageAction = (page: Collection) => {
|
||||
addOrRemoveItemFromCollection(page.id, album, 'album', 'add')
|
||||
}
|
||||
|
||||
const add_to_page: Option = {
|
||||
label: 'Add to Collection',
|
||||
children: () =>
|
||||
getAddToCollectionOptions(addToPageAction, {
|
||||
collection: null,
|
||||
hash: album.albumhash,
|
||||
type: 'album',
|
||||
extra: {},
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const remove_from_page: Option = {
|
||||
label: 'Remove item',
|
||||
action: async () => {
|
||||
const success = await addOrRemoveItemFromCollection(
|
||||
parseInt(router.currentRoute.value.params.collection as string),
|
||||
album,
|
||||
'album',
|
||||
'remove'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
useCollection().removeLocalItem(album, 'album')
|
||||
}
|
||||
},
|
||||
icon: DeleteIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
...[router.currentRoute.value.name === Routes.Page ? remove_from_page : add_to_page],
|
||||
get_find_on_social('album', '', album),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,54 +1,101 @@
|
||||
import modal from "@/stores/modal";
|
||||
import useTracklist from "@/stores/queue/tracklist";
|
||||
import { Routes, router } from '@/router'
|
||||
|
||||
import { getArtistTracks } from "@/requests/artists";
|
||||
import { addArtistToPlaylist } from "@/requests/playlists";
|
||||
import useCollection from '@/stores/pages/collections'
|
||||
import useTracklist from '@/stores/queue/tracklist'
|
||||
|
||||
import { Option, Playlist } from "@/interfaces";
|
||||
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
|
||||
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
|
||||
import { getArtistTracks } from '@/requests/artists'
|
||||
import { addOrRemoveItemFromCollection } from '@/requests/collections'
|
||||
import { addArtistToPlaylist } from '@/requests/playlists'
|
||||
|
||||
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
|
||||
import { Artist, Collection, Option, Playlist } from '@/interfaces'
|
||||
import { getAddToCollectionOptions, getAddToPlaylistOptions, get_find_on_social } from './utils'
|
||||
|
||||
export default async (artisthash: string, artistname: string) => {
|
||||
const play_next = <Option>{
|
||||
label: "Play next",
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then((tracks) => {
|
||||
const store = useTracklist();
|
||||
store.insertAfterCurrent(tracks);
|
||||
});
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
};
|
||||
const play_next = <Option>{
|
||||
label: 'Play next',
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then(tracks => {
|
||||
const store = useTracklist()
|
||||
store.insertAfterCurrent(tracks)
|
||||
})
|
||||
},
|
||||
icon: PlayNextIcon,
|
||||
}
|
||||
|
||||
const add_to_queue = <Option>{
|
||||
label: "Add to queue",
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then((tracks) => {
|
||||
const store = useTracklist();
|
||||
store.addTracks(tracks);
|
||||
});
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
};
|
||||
const add_to_queue = <Option>{
|
||||
label: 'Add to queue',
|
||||
action: () => {
|
||||
getArtistTracks(artisthash).then(tracks => {
|
||||
const store = useTracklist()
|
||||
store.addTracks(tracks)
|
||||
})
|
||||
},
|
||||
icon: AddToQueueIcon,
|
||||
}
|
||||
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addArtistToPlaylist(playlist, artisthash);
|
||||
};
|
||||
// Action for each playlist option
|
||||
const AddToPlaylistAction = (playlist: Playlist) => {
|
||||
addArtistToPlaylist(playlist, artisthash)
|
||||
}
|
||||
|
||||
const add_to_playlist: Option = {
|
||||
label: "Add to Playlist",
|
||||
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
artisthash,
|
||||
playlist_name: `This is ${artistname}`,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
};
|
||||
const add_to_playlist: Option = {
|
||||
label: 'Add to Playlist',
|
||||
children: () =>
|
||||
getAddToPlaylistOptions(AddToPlaylistAction, {
|
||||
artisthash,
|
||||
playlist_name: `This is ${artistname}`,
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
get_find_on_social("artist"),
|
||||
];
|
||||
};
|
||||
const addToCollectionAction = (collection: Collection) => {
|
||||
addOrRemoveItemFromCollection(
|
||||
collection.id,
|
||||
{
|
||||
artisthash,
|
||||
} as Artist,
|
||||
'artist',
|
||||
'add'
|
||||
)
|
||||
}
|
||||
|
||||
const add_to_page: Option = {
|
||||
label: 'Add to Collection',
|
||||
children: () =>
|
||||
getAddToCollectionOptions(addToCollectionAction, {
|
||||
collection: null,
|
||||
hash: artisthash,
|
||||
type: 'artist',
|
||||
extra: {},
|
||||
}),
|
||||
icon: PlusIcon,
|
||||
}
|
||||
|
||||
const remove_from_collection: Option = {
|
||||
label: 'Remove item',
|
||||
action: async () => {
|
||||
const success = await addOrRemoveItemFromCollection(
|
||||
parseInt(router.currentRoute.value.params.collection as string),
|
||||
{
|
||||
artisthash,
|
||||
} as Artist,
|
||||
'artist',
|
||||
'remove'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
useCollection().removeLocalItem({ artisthash } as Artist, 'artist')
|
||||
}
|
||||
},
|
||||
icon: DeleteIcon,
|
||||
}
|
||||
|
||||
return [
|
||||
play_next,
|
||||
add_to_queue,
|
||||
add_to_playlist,
|
||||
...[router.currentRoute.value.name === Routes.Page ? remove_from_collection : add_to_page],
|
||||
get_find_on_social('artist'),
|
||||
]
|
||||
}
|
||||
|
||||
22
src/context_menus/hashing.ts
Normal file
22
src/context_menus/hashing.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
|
||||
export function getLastFmApiSig(data: {[key: string]: any}, secret: string): string {
|
||||
// Sort keys alphabetically
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
|
||||
// Concatenate parameters in name+value format
|
||||
const concatenatedString = sortedKeys.reduce((acc, key) => {
|
||||
// Ensure values are properly encoded
|
||||
const value = encodeURIComponent(data[key].toString());
|
||||
return acc + key + value;
|
||||
}, '');
|
||||
|
||||
// Append secret
|
||||
const stringToHash = concatenatedString + secret;
|
||||
|
||||
// Generate MD5 hash
|
||||
return crypto.createHash('md5')
|
||||
.update(stringToHash)
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import modal from '@/stores/modal'
|
||||
import useAlbum from '@/stores/pages/album'
|
||||
import useArtist from '@/stores/pages/artist'
|
||||
|
||||
import { SearchIcon } from '@/icons'
|
||||
import { Option, Playlist } from '@/interfaces'
|
||||
import { Album, Collection, Option, Playlist } from '@/interfaces'
|
||||
import { getAllCollections } from '@/requests/collections'
|
||||
import { getAllPlaylists } from '@/requests/playlists'
|
||||
|
||||
export const separator: Option = {
|
||||
type: 'separator',
|
||||
}
|
||||
|
||||
export function get_new_playlist_option(
|
||||
new_playlist_modal_props: any = {}
|
||||
): Option {
|
||||
export function get_new_playlist_option(new_playlist_modal_props: any = {}): Option {
|
||||
return {
|
||||
label: 'New playlist',
|
||||
action: () => {
|
||||
@@ -21,6 +19,15 @@ export function get_new_playlist_option(
|
||||
}
|
||||
}
|
||||
|
||||
export function get_new_collection_option(new_collection_modal_props: any = {}): Option {
|
||||
return {
|
||||
label: 'New Collection',
|
||||
action: () => {
|
||||
modal().showCollectionModal(new_collection_modal_props)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type action = (playlist: Playlist) => void
|
||||
|
||||
/**
|
||||
@@ -29,10 +36,7 @@ type action = (playlist: Playlist) => void
|
||||
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
|
||||
* @returns A list of options to be used in a context menu
|
||||
*/
|
||||
export async function getAddToPlaylistOptions(
|
||||
addToPlaylist: action,
|
||||
new_playlist_modal_props: any = {}
|
||||
) {
|
||||
export async function getAddToPlaylistOptions(addToPlaylist: action, new_playlist_modal_props: any = {}) {
|
||||
const new_playlist = get_new_playlist_option(new_playlist_modal_props)
|
||||
const p = await getAllPlaylists(true)
|
||||
|
||||
@@ -44,7 +48,7 @@ export async function getAddToPlaylistOptions(
|
||||
|
||||
let playlists = <Option[]>[]
|
||||
|
||||
playlists = p.map((playlist) => {
|
||||
playlists = p.map(playlist => {
|
||||
return <Option>{
|
||||
label: playlist.name,
|
||||
action: () => {
|
||||
@@ -56,20 +60,45 @@ export async function getAddToPlaylistOptions(
|
||||
return [...items, separator, ...playlists]
|
||||
}
|
||||
|
||||
export const get_find_on_social = (page = 'album', query = '') => {
|
||||
/**
|
||||
*
|
||||
* @param addToPlaylist Function to be called when a playlist is selected
|
||||
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
|
||||
* @returns A list of options to be used in a context menu
|
||||
*/
|
||||
export async function getAddToCollectionOptions(
|
||||
addToCollection: (collection: Collection) => void,
|
||||
new_page_modal_props: any = {}
|
||||
) {
|
||||
const new_page = get_new_collection_option(new_page_modal_props)
|
||||
const data = await getAllCollections()
|
||||
|
||||
let items = [new_page]
|
||||
|
||||
if (data.length === 0) {
|
||||
return items
|
||||
}
|
||||
|
||||
let collections = <Option[]>[]
|
||||
|
||||
collections = data.map(collection => {
|
||||
return <Option>{
|
||||
label: collection.name,
|
||||
action: () => {
|
||||
addToCollection(collection)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [...items, separator, ...collections]
|
||||
}
|
||||
|
||||
export const get_find_on_social = (page = 'album', query = '', album?: Album) => {
|
||||
const is_album = page === 'album'
|
||||
const getAlbumSearchTerm = () => {
|
||||
const store = useAlbum()
|
||||
|
||||
return `${store.info.title} - ${store.info.albumartists
|
||||
.map((a) => a.name)
|
||||
.join(', ')}`
|
||||
return `${album?.title} - ${album?.albumartists.map(a => a.name).join(', ')}`
|
||||
}
|
||||
const search_term = query
|
||||
? query
|
||||
: is_album
|
||||
? getAlbumSearchTerm()
|
||||
: useArtist().info.name
|
||||
const search_term = query ? query : is_album ? getAlbumSearchTerm() : useArtist().info.name
|
||||
|
||||
return <Option>{
|
||||
label: 'Search on',
|
||||
@@ -77,67 +106,36 @@ export const get_find_on_social = (page = 'album', query = '') => {
|
||||
children: async () => [
|
||||
{
|
||||
label: 'Google',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.google.com/search?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.google.com/search?q=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'YouTube',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.youtube.com/results?search_query=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.youtube.com/results?search_query=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Spotify',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://open.spotify.com/search/${search_term}/${page}s`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://open.spotify.com/search/${search_term}/${page}s`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Tidal',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://listen.tidal.com/search/${page}s?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://listen.tidal.com/search/${page}s?q=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Apple Music',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://music.apple.com/search?term=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://music.apple.com/search?term=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Deezer',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.deezer.com/search/${search_term}/${page}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.deezer.com/search/${search_term}/${page}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Wikipedia',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
window.open(`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`, '_blank'),
|
||||
},
|
||||
{
|
||||
label: 'Last.fm',
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://www.last.fm/search/${page}s?q=${search_term}`,
|
||||
'_blank'
|
||||
),
|
||||
action: () => window.open(`https://www.last.fm/search/${page}s?q=${search_term}`, '_blank'),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -102,4 +102,8 @@ export interface DBSettings {
|
||||
scanInterval: number
|
||||
plugins: Plugin[];
|
||||
version: string;
|
||||
lastfmApiKey: string;
|
||||
lastfmApiSecret: string;
|
||||
lastfmSessionKey: string;
|
||||
showPlaylistsInFolderView: boolean;
|
||||
}
|
||||
|
||||
@@ -1,88 +1,78 @@
|
||||
import { Store } from "pinia";
|
||||
import { Ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Store } from 'pinia'
|
||||
import { Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { ContextSrc } from "@/enums";
|
||||
import { Track } from "@/interfaces";
|
||||
import useContextStore from "@/stores/context";
|
||||
import { ContextSrc } from '@/enums'
|
||||
import { Album, Track } from '@/interfaces'
|
||||
import useContextStore from '@/stores/context'
|
||||
|
||||
import albumContextItems from "@/context_menus/album";
|
||||
import artistContextItems from "@/context_menus/artist";
|
||||
import folderContextItems from "@/context_menus/folder";
|
||||
import trackContextItems from "@/context_menus/track";
|
||||
import queueContextItems from "@/context_menus/queue";
|
||||
import albumContextItems from '@/context_menus/album'
|
||||
import artistContextItems from '@/context_menus/artist'
|
||||
import folderContextItems from '@/context_menus/folder'
|
||||
import trackContextItems from '@/context_menus/track'
|
||||
import queueContextItems from '@/context_menus/queue'
|
||||
|
||||
let stop_prev_watcher = () => {};
|
||||
let stop_prev_watcher = () => {}
|
||||
|
||||
function flagWatcher(menu: Store, flag: Ref<boolean>) {
|
||||
stop_prev_watcher();
|
||||
stop_prev_watcher()
|
||||
|
||||
if (flag.value) {
|
||||
return (flag.value = false);
|
||||
}
|
||||
if (flag.value) {
|
||||
return (flag.value = false)
|
||||
}
|
||||
|
||||
// watch for context menu visibility and reset flag
|
||||
stop_prev_watcher = menu.$subscribe((mutation, state) => {
|
||||
//@ts-ignore
|
||||
flag.value = state.visible;
|
||||
});
|
||||
// watch for context menu visibility and reset flag
|
||||
stop_prev_watcher = menu.$subscribe((mutation, state) => {
|
||||
//@ts-ignore
|
||||
flag.value = state.visible
|
||||
})
|
||||
}
|
||||
|
||||
export const showTrackContextMenu = (
|
||||
e: MouseEvent,
|
||||
track: Track,
|
||||
flag: Ref<boolean>,
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
const options = () => trackContextItems(track);
|
||||
export const showTrackContextMenu = (e: MouseEvent, track: Track, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore()
|
||||
const options = () => trackContextItems(track)
|
||||
|
||||
menu.showContextMenu(e, options, ContextSrc.Track);
|
||||
menu.showContextMenu(e, options, ContextSrc.Track)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore();
|
||||
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>, album?: Album) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => albumContextItems();
|
||||
menu.showContextMenu(e, options, ContextSrc.AlbumHeader);
|
||||
const options = () => albumContextItems(album)
|
||||
menu.showContextMenu(e, options, ContextSrc.AlbumHeader)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showFolderContextMenu = (
|
||||
e: MouseEvent,
|
||||
flag: Ref<boolean>,
|
||||
source: ContextSrc,
|
||||
path: string
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
export const showFolderContextMenu = (e: MouseEvent, flag: Ref<boolean>, source: ContextSrc, path: string) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => folderContextItems(path);
|
||||
menu.showContextMenu(e, options, source);
|
||||
const options = () => folderContextItems(path)
|
||||
menu.showContextMenu(e, options, source)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showArtistContextMenu = (
|
||||
e: MouseEvent,
|
||||
flag: Ref<boolean>,
|
||||
artisthash: string,
|
||||
artistname: string
|
||||
) => {
|
||||
const menu = useContextStore();
|
||||
export const showArtistContextMenu = (e: MouseEvent, flag: Ref<boolean>, artisthash: string, artistname: string) => {
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => artistContextItems(artisthash, artistname);
|
||||
menu.showContextMenu(e, options, ContextSrc.ArtistHeader);
|
||||
const options = () => artistContextItems(artisthash, artistname)
|
||||
menu.showContextMenu(e, options, ContextSrc.ArtistHeader)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
export const showQueueContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
|
||||
const menu = useContextStore();
|
||||
const menu = useContextStore()
|
||||
|
||||
const options = () => queueContextItems();
|
||||
menu.showContextMenu(e, options, ContextSrc.Queue);
|
||||
const options = () => queueContextItems()
|
||||
menu.showContextMenu(e, options, ContextSrc.Queue)
|
||||
|
||||
flagWatcher(menu, flag);
|
||||
};
|
||||
flagWatcher(menu, flag)
|
||||
}
|
||||
|
||||
// export const showAlbumCardContextMenu = (e: MouseEvent, flag: Ref<boolean>, album: Album) => {
|
||||
|
||||
// }
|
||||
|
||||
@@ -84,12 +84,10 @@ export async function playFromFolderCard(folderpath: string) {
|
||||
export async function playFromFavorites(track: Track | undefined) {
|
||||
const queue = useQueue()
|
||||
const tracklist = useTracklist()
|
||||
console.log(track)
|
||||
|
||||
// if our tracklist is not from favorites, we need to fetch the favorites
|
||||
if (tracklist.from.type !== FromOptions.favorite) {
|
||||
const res = await getFavTracks(0, -1)
|
||||
console.log(res)
|
||||
tracklist.setFromFav(res.tracks)
|
||||
}
|
||||
|
||||
@@ -99,7 +97,6 @@ export async function playFromFavorites(track: Track | undefined) {
|
||||
index = tracklist.tracklist.findIndex(t => t.trackhash === track?.trackhash)
|
||||
}
|
||||
|
||||
console.log(tracklist.tracklist)
|
||||
queue.play(index)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Track extends AlbumDisc {
|
||||
trackhash: string
|
||||
filetype: string
|
||||
is_favorite: boolean
|
||||
explicit: boolean
|
||||
type?: string
|
||||
|
||||
og_title: string
|
||||
og_album: string
|
||||
@@ -117,6 +119,7 @@ export interface HomePageItem {
|
||||
items: { type: string; item?: any; with_helptext?: boolean }[]
|
||||
path?: string
|
||||
seeAllText?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
@@ -131,6 +134,7 @@ export interface Artist {
|
||||
help_text?: string
|
||||
time?: string
|
||||
genres: Genre[]
|
||||
type?: string
|
||||
|
||||
// available in charts
|
||||
trend?: {
|
||||
@@ -178,6 +182,15 @@ export interface Playlist {
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: number
|
||||
name: string
|
||||
items: (Album | Artist | Mix | Playlist)[]
|
||||
extra: {
|
||||
description: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Radio {
|
||||
name: string
|
||||
image: string
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Album, Artist, Genre, StatItem, Track } from '@/interfaces'
|
||||
import { NotifType, useToast } from '@/stores/notification'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
export const getArtistData = async (hash: string, limit: number = 5, albumlimit: number = 7) => {
|
||||
export const getArtistData = async (hash: string, limit: number = 15, albumlimit: number = 7) => {
|
||||
interface ArtistData {
|
||||
artist: Artist
|
||||
tracks: Track[]
|
||||
@@ -19,7 +19,7 @@ export const getArtistData = async (hash: string, limit: number = 5, albumlimit:
|
||||
|
||||
const { data, error, status } = await useAxios({
|
||||
method: 'GET',
|
||||
url: paths.api.artist + `/${hash}?limit=${limit}&albumlimit=${albumlimit}`,
|
||||
url: paths.api.artist + `/${hash}?tracklimit=${limit}&albumlimit=${albumlimit}`,
|
||||
})
|
||||
|
||||
if (status == 404) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import useAxios from './useAxios'
|
||||
import { User, UserSimplified } from '@/interfaces'
|
||||
|
||||
export async function getAllUsers<T extends boolean>(simple: T = true as T) {
|
||||
interface res {
|
||||
interface Response {
|
||||
users: T extends true ? UserSimplified[] : User[]
|
||||
settings: { [key: string]: any }
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export async function getAllUsers<T extends boolean>(simple: T = true as T) {
|
||||
})
|
||||
|
||||
if (res.status === 200) {
|
||||
return res.data as res
|
||||
return res.data as Response
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
|
||||
138
src/requests/collections.ts
Normal file
138
src/requests/collections.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { paths } from '@/config'
|
||||
import { Album, Artist, Collection, Mix, Playlist } from '@/interfaces'
|
||||
import { Notification, NotifType } from '@/stores/notification'
|
||||
import useAxios from './useAxios'
|
||||
|
||||
const { base: baseCollectionUrl } = paths.api.collections
|
||||
|
||||
export async function getAllCollections() {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return data as Collection[]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function getCollection(collection_id: string) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}`,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return data as Collection
|
||||
}
|
||||
|
||||
export async function createNewCollection(
|
||||
name: string,
|
||||
description: string,
|
||||
items?: { hash: string; type: string; extra: any }[]
|
||||
) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl,
|
||||
props: {
|
||||
name,
|
||||
description,
|
||||
items,
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (status == 201) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function updateCollection(collection: Collection, name: string, description: string) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection.id}`,
|
||||
props: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
method: 'PUT',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return data as Collection
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function addOrRemoveItemFromCollection(
|
||||
collection_id: number,
|
||||
item: Album | Artist | Mix | Playlist,
|
||||
type: string,
|
||||
command: 'add' | 'remove'
|
||||
) {
|
||||
const payload = {
|
||||
type: type,
|
||||
hash: '',
|
||||
extra: {},
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'album':
|
||||
payload.hash = (item as Album).albumhash
|
||||
break
|
||||
case 'artist':
|
||||
payload.hash = (item as Artist).artisthash
|
||||
break
|
||||
case 'mix':
|
||||
payload.hash = (item as Mix).sourcehash
|
||||
break
|
||||
case 'playlist':
|
||||
payload.hash = (item as Playlist).id.toString()
|
||||
break
|
||||
}
|
||||
|
||||
if (payload.hash === '') {
|
||||
throw new Error('Invalid item type. Item not added to collection.')
|
||||
}
|
||||
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}/items`,
|
||||
props: {
|
||||
item: payload,
|
||||
},
|
||||
method: command == 'add' ? 'POST' : 'DELETE',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
new Notification(
|
||||
`${payload.type[0].toUpperCase() + payload.type.slice(1)} ${
|
||||
command == 'add' ? 'added' : 'removed'
|
||||
} to page`,
|
||||
NotifType.Success
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (status == 400) {
|
||||
new Notification(`${payload.type[0].toUpperCase() + payload.type.slice(1)} already in collection`, NotifType.Error)
|
||||
return false
|
||||
}
|
||||
|
||||
new Notification('Failed: ' + data.error, NotifType.Error)
|
||||
return false
|
||||
}
|
||||
|
||||
export async function deleteCollection(collection_id: number) {
|
||||
const { data, status } = await useAxios({
|
||||
url: baseCollectionUrl + `/${collection_id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (status == 200) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export async function getBackups() {
|
||||
playlists: number
|
||||
scrobbles: number
|
||||
favorites: number
|
||||
collections: number
|
||||
date: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { FetchProps } from '@/interfaces'
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import useModal from '@/stores/modal'
|
||||
|
||||
import useLoaderStore from '@/stores/loader'
|
||||
import { logoutUser } from './auth'
|
||||
|
||||
const development = import.meta.env.DEV
|
||||
|
||||
export function getBaseUrl() {
|
||||
const base_url = window.location.origin
|
||||
|
||||
if (!development) {
|
||||
return base_url
|
||||
}
|
||||
|
||||
const splits = base_url.split(':')
|
||||
return base_url.replace(splits[splits.length - 1], '1980')
|
||||
if (window.location.protocol === 'https:') {
|
||||
const meta = document.createElement('meta');
|
||||
meta.httpEquiv = 'Content-Security-Policy';
|
||||
meta.content = 'upgrade-insecure-requests';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
|
||||
axios.defaults.baseURL = getBaseUrl()
|
||||
|
||||
export default async (args: FetchProps) => {
|
||||
export default async (args: FetchProps, withCredentials: boolean = true) => {
|
||||
const on_ngrok = args.url.includes('ngrok')
|
||||
const ngrok_config = {
|
||||
'ngrok-skip-browser-warning': 'stupid-SOAB!',
|
||||
@@ -37,7 +29,7 @@ export default async (args: FetchProps) => {
|
||||
method: args.method || 'POST',
|
||||
// INFO: Add ngrok header and provided headers
|
||||
headers: { ...args.headers, ...(on_ngrok ? ngrok_config : {}) },
|
||||
withCredentials: true,
|
||||
withCredentials: withCredentials,
|
||||
})
|
||||
|
||||
stopLoading()
|
||||
@@ -61,7 +53,7 @@ export default async (args: FetchProps) => {
|
||||
try {
|
||||
isSignatureError = error.response.data.msg == 'Signature verification failed'
|
||||
} catch (error) {
|
||||
console.log('Error:', error)
|
||||
console.error('Error:', error)
|
||||
}
|
||||
|
||||
if (error.response?.status === 422 && isSignatureError) {
|
||||
|
||||
@@ -1,261 +1,269 @@
|
||||
import { createRouter, createWebHashHistory, RouterOptions } from "vue-router";
|
||||
import { createRouter, createWebHashHistory, RouterOptions } from 'vue-router'
|
||||
|
||||
import state from "@/composables/state";
|
||||
import useAlbumPageStore from "@/stores/pages/album";
|
||||
import useFolderPageStore from "@/stores/pages/folder";
|
||||
import usePlaylistPageStore from "@/stores/pages/playlist";
|
||||
import usePlaylistListPageStore from "@/stores/pages/playlists";
|
||||
import useArtistPageStore from "@/stores/pages/artist";
|
||||
import state from '@/composables/state'
|
||||
import useAlbumPageStore from '@/stores/pages/album'
|
||||
import useFolderPageStore from '@/stores/pages/folder'
|
||||
import usePlaylistPageStore from '@/stores/pages/playlist'
|
||||
import usePlaylistListPageStore from '@/stores/pages/playlists'
|
||||
import useArtistPageStore from '@/stores/pages/artist'
|
||||
|
||||
|
||||
import HomeView from "@/views/HomeView";
|
||||
const Lyrics = () => import("@/views/LyricsView");
|
||||
const ArtistView = () => import("@/views/ArtistView");
|
||||
const NotFound = () => import("@/views/NotFound.vue");
|
||||
const NowPlaying = () => import("@/views/NowPlaying");
|
||||
const SearchView = () => import("@/views/SearchView");
|
||||
const AlbumList = () => import("@/views/AlbumListView");
|
||||
const FolderView = () => import("@/views/FolderView.vue");
|
||||
const FavoritesView = () => import("@/views/Favorites.vue");
|
||||
const SettingsView = () => import("@/views/SettingsView.vue");
|
||||
const AlbumView = () => import("@/views/AlbumView/index.vue");
|
||||
const ArtistTracksView = () => import("@/views/ArtistTracks.vue");
|
||||
const PlaylistListView = () => import("@/views/PlaylistList.vue");
|
||||
const FavoriteTracks = () => import("@/views/FavoriteTracks.vue");
|
||||
const PlaylistView = () => import("@/views/PlaylistView/index.vue");
|
||||
const ArtistDiscographyView = () => import("@/views/ArtistDiscography.vue");
|
||||
const FavoriteCardScroller = () => import("@/views/FavoriteCardScroller.vue");
|
||||
const StatsView = () => import("@/views/Stats/main.vue");
|
||||
const MixView = () => import("@/views/MixView.vue");
|
||||
const MixListView = () => import("@/views/MixListView.vue");
|
||||
import HomeView from '@/views/HomeView'
|
||||
const Lyrics = () => import('@/views/LyricsView')
|
||||
const ArtistView = () => import('@/views/ArtistView')
|
||||
const NotFound = () => import('@/views/NotFound.vue')
|
||||
const NowPlaying = () => import('@/views/NowPlaying')
|
||||
const SearchView = () => import('@/views/SearchView')
|
||||
const AlbumList = () => import('@/views/AlbumListView')
|
||||
const FolderView = () => import('@/views/FolderView.vue')
|
||||
const FavoritesView = () => import('@/views/Favorites.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const AlbumView = () => import('@/views/AlbumView/index.vue')
|
||||
const ArtistTracksView = () => import('@/views/ArtistTracks.vue')
|
||||
const PlaylistListView = () => import('@/views/PlaylistList.vue')
|
||||
const FavoriteTracks = () => import('@/views/FavoriteTracks.vue')
|
||||
const PlaylistView = () => import('@/views/PlaylistView/index.vue')
|
||||
const ArtistDiscographyView = () => import('@/views/ArtistDiscography.vue')
|
||||
const FavoriteCardScroller = () => import('@/views/FavoriteCardScroller.vue')
|
||||
const StatsView = () => import('@/views/Stats/main.vue')
|
||||
const MixView = () => import('@/views/MixView.vue')
|
||||
const MixListView = () => import('@/views/MixListView.vue')
|
||||
const Collection = () => import('@/views/Collections/Collection.vue')
|
||||
|
||||
const folder = {
|
||||
path: "/folder/:path",
|
||||
name: "FolderView",
|
||||
component: FolderView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
await useFolderPageStore()
|
||||
.fetchAll(to.params.path, true)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/folder/:path',
|
||||
name: 'FolderView',
|
||||
component: FolderView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
await useFolderPageStore()
|
||||
.fetchAll(to.params.path, true)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const playlists = {
|
||||
path: "/playlists",
|
||||
name: "PlaylistList",
|
||||
component: PlaylistListView,
|
||||
beforeEnter: async () => {
|
||||
state.loading.value = true;
|
||||
await usePlaylistListPageStore()
|
||||
.fetchAll()
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/playlists',
|
||||
name: 'PlaylistList',
|
||||
component: PlaylistListView,
|
||||
beforeEnter: async () => {
|
||||
state.loading.value = true
|
||||
await usePlaylistListPageStore()
|
||||
.fetchAll()
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const playlistView = {
|
||||
path: "/playlist/:pid",
|
||||
name: "PlaylistView",
|
||||
component: PlaylistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
await usePlaylistPageStore()
|
||||
.fetchAll(to.params.pid)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
path: '/playlist/:pid',
|
||||
name: 'PlaylistView',
|
||||
component: PlaylistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
await usePlaylistPageStore()
|
||||
.fetchAll(to.params.pid)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const albumView = {
|
||||
path: "/albums/:albumhash",
|
||||
name: "AlbumView",
|
||||
component: AlbumView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
const store = useAlbumPageStore();
|
||||
path: '/albums/:albumhash',
|
||||
name: 'AlbumView',
|
||||
component: AlbumView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
const store = useAlbumPageStore()
|
||||
|
||||
await store.fetchTracksAndArtists(to.params.albumhash).then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
await store.fetchTracksAndArtists(to.params.albumhash).then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const artistView = {
|
||||
path: "/artists/:hash",
|
||||
name: "ArtistView",
|
||||
component: ArtistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true;
|
||||
path: '/artists/:hash',
|
||||
name: 'ArtistView',
|
||||
component: ArtistView,
|
||||
beforeEnter: async (to: any) => {
|
||||
state.loading.value = true
|
||||
|
||||
await useArtistPageStore()
|
||||
.getData(to.params.hash)
|
||||
.then(() => {
|
||||
state.loading.value = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
await useArtistPageStore()
|
||||
.getData(to.params.hash)
|
||||
.then(() => {
|
||||
state.loading.value = false
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const NowPlayingView = {
|
||||
path: "/nowplaying/:tab",
|
||||
name: "NowPlaying",
|
||||
component: NowPlaying,
|
||||
};
|
||||
path: '/nowplaying/:tab',
|
||||
name: 'NowPlaying',
|
||||
component: NowPlaying,
|
||||
}
|
||||
|
||||
const LyricsView = {
|
||||
path: "/lyrics",
|
||||
name: "LyricsView",
|
||||
component: Lyrics,
|
||||
};
|
||||
path: '/lyrics',
|
||||
name: 'LyricsView',
|
||||
component: Lyrics,
|
||||
}
|
||||
|
||||
const ArtistTracks = {
|
||||
path: "/artists/:hash/tracks",
|
||||
name: "ArtistTracks",
|
||||
component: ArtistTracksView,
|
||||
};
|
||||
path: '/artists/:hash/tracks',
|
||||
name: 'ArtistTracks',
|
||||
component: ArtistTracksView,
|
||||
}
|
||||
|
||||
const artistDiscography = {
|
||||
path: "/artists/:hash/discography/:type",
|
||||
name: "ArtistDiscographyView",
|
||||
component: ArtistDiscographyView,
|
||||
};
|
||||
path: '/artists/:hash/discography/:type',
|
||||
name: 'ArtistDiscographyView',
|
||||
component: ArtistDiscographyView,
|
||||
}
|
||||
|
||||
const settings = {
|
||||
path: "/settings/:tab",
|
||||
name: "SettingsView",
|
||||
component: SettingsView,
|
||||
};
|
||||
path: '/settings/:tab',
|
||||
name: 'SettingsView',
|
||||
component: SettingsView,
|
||||
}
|
||||
|
||||
const search = {
|
||||
path: "/search/:page",
|
||||
name: "SearchView",
|
||||
component: SearchView,
|
||||
};
|
||||
path: '/search/:page',
|
||||
name: 'SearchView',
|
||||
component: SearchView,
|
||||
}
|
||||
|
||||
const favorites = {
|
||||
path: "/favorites",
|
||||
name: "FavoritesView",
|
||||
component: FavoritesView,
|
||||
};
|
||||
path: '/favorites',
|
||||
name: 'FavoritesView',
|
||||
component: FavoritesView,
|
||||
}
|
||||
|
||||
const favoriteAlbums = {
|
||||
path: "/favorites/albums",
|
||||
name: "FavoriteAlbums",
|
||||
component: FavoriteCardScroller,
|
||||
};
|
||||
path: '/favorites/albums',
|
||||
name: 'FavoriteAlbums',
|
||||
component: FavoriteCardScroller,
|
||||
}
|
||||
|
||||
const favoriteArtists = {
|
||||
path: "/favorites/artists",
|
||||
name: "FavoriteArtists",
|
||||
component: FavoriteCardScroller,
|
||||
};
|
||||
path: '/favorites/artists',
|
||||
name: 'FavoriteArtists',
|
||||
component: FavoriteCardScroller,
|
||||
}
|
||||
|
||||
const favoriteTracks = {
|
||||
path: "/favorites/tracks",
|
||||
name: "FavoriteTracks",
|
||||
component: FavoriteTracks,
|
||||
};
|
||||
path: '/favorites/tracks',
|
||||
name: 'FavoriteTracks',
|
||||
component: FavoriteTracks,
|
||||
}
|
||||
|
||||
const notFound = {
|
||||
name: "NotFound",
|
||||
path: "/:pathMatch(.*)",
|
||||
component: NotFound,
|
||||
};
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)',
|
||||
component: NotFound,
|
||||
}
|
||||
|
||||
const Home = {
|
||||
path: "/",
|
||||
name: "Home",
|
||||
component: HomeView,
|
||||
};
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: HomeView,
|
||||
}
|
||||
|
||||
const AlbumListView = {
|
||||
path: "/albums",
|
||||
name: "AlbumListView",
|
||||
component: AlbumList,
|
||||
};
|
||||
path: '/albums',
|
||||
name: 'AlbumListView',
|
||||
component: AlbumList,
|
||||
}
|
||||
|
||||
const Stats = {
|
||||
path: "/stats",
|
||||
name: "StatsView",
|
||||
component: StatsView,
|
||||
};
|
||||
path: '/stats',
|
||||
name: 'StatsView',
|
||||
component: StatsView,
|
||||
}
|
||||
|
||||
const ArtistListView = {
|
||||
...AlbumListView,
|
||||
path: "/artists",
|
||||
name: "ArtistListView",
|
||||
};
|
||||
...AlbumListView,
|
||||
path: '/artists',
|
||||
name: 'ArtistListView',
|
||||
}
|
||||
|
||||
const Mix = {
|
||||
path: "/mix/:mixid",
|
||||
name: "MixView",
|
||||
component: MixView,
|
||||
};
|
||||
path: '/mix/:mixid',
|
||||
name: 'MixView',
|
||||
component: MixView,
|
||||
}
|
||||
|
||||
const MixList = {
|
||||
path: "/mixes/:type",
|
||||
name: "MixListView",
|
||||
component: MixListView,
|
||||
};
|
||||
path: '/mixes/:type',
|
||||
name: 'MixListView',
|
||||
component: MixListView,
|
||||
}
|
||||
|
||||
const PageView = {
|
||||
path: '/collections/:collection',
|
||||
name: 'Collection',
|
||||
component: Collection,
|
||||
}
|
||||
|
||||
const routes = [
|
||||
folder,
|
||||
playlists,
|
||||
playlistView,
|
||||
albumView,
|
||||
artistView,
|
||||
artistDiscography,
|
||||
settings,
|
||||
search,
|
||||
notFound,
|
||||
ArtistTracks,
|
||||
favorites,
|
||||
favoriteAlbums,
|
||||
favoriteTracks,
|
||||
favoriteArtists,
|
||||
NowPlayingView,
|
||||
Home,
|
||||
AlbumListView,
|
||||
ArtistListView,
|
||||
LyricsView,
|
||||
Stats,
|
||||
Mix,
|
||||
MixList,
|
||||
];
|
||||
folder,
|
||||
playlists,
|
||||
playlistView,
|
||||
albumView,
|
||||
artistView,
|
||||
artistDiscography,
|
||||
settings,
|
||||
search,
|
||||
notFound,
|
||||
ArtistTracks,
|
||||
favorites,
|
||||
favoriteAlbums,
|
||||
favoriteTracks,
|
||||
favoriteArtists,
|
||||
NowPlayingView,
|
||||
Home,
|
||||
AlbumListView,
|
||||
ArtistListView,
|
||||
LyricsView,
|
||||
Stats,
|
||||
Mix,
|
||||
MixList,
|
||||
PageView,
|
||||
]
|
||||
|
||||
const Routes = {
|
||||
folder: folder.name,
|
||||
playlists: playlists.name,
|
||||
playlist: playlistView.name,
|
||||
album: albumView.name,
|
||||
artist: artistView.name,
|
||||
artistDiscography: artistDiscography.name,
|
||||
settings: settings.name,
|
||||
search: search.name,
|
||||
notFound: notFound.name,
|
||||
artistTracks: ArtistTracks.name,
|
||||
favorites: favorites.name,
|
||||
favoriteAlbums: favoriteAlbums.name,
|
||||
favoriteTracks: favoriteTracks.name,
|
||||
favoriteArtists: favoriteArtists.name,
|
||||
nowPlaying: NowPlayingView.name,
|
||||
Home: Home.name,
|
||||
AlbumList: AlbumListView.name,
|
||||
ArtistList: ArtistListView.name,
|
||||
Lyrics: LyricsView.name,
|
||||
Stats: Stats.name,
|
||||
Mix: Mix.name,
|
||||
MixList: MixList.name,
|
||||
};
|
||||
folder: folder.name,
|
||||
playlists: playlists.name,
|
||||
playlist: playlistView.name,
|
||||
album: albumView.name,
|
||||
artist: artistView.name,
|
||||
artistDiscography: artistDiscography.name,
|
||||
settings: settings.name,
|
||||
search: search.name,
|
||||
notFound: notFound.name,
|
||||
artistTracks: ArtistTracks.name,
|
||||
favorites: favorites.name,
|
||||
favoriteAlbums: favoriteAlbums.name,
|
||||
favoriteTracks: favoriteTracks.name,
|
||||
favoriteArtists: favoriteArtists.name,
|
||||
nowPlaying: NowPlayingView.name,
|
||||
Home: Home.name,
|
||||
AlbumList: AlbumListView.name,
|
||||
ArtistList: ArtistListView.name,
|
||||
Lyrics: LyricsView.name,
|
||||
Stats: Stats.name,
|
||||
Mix: Mix.name,
|
||||
MixList: MixList.name,
|
||||
Page: PageView.name,
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
mode: "hash",
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
} as RouterOptions);
|
||||
mode: 'hash',
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
} as RouterOptions)
|
||||
|
||||
export { router, Routes };
|
||||
export { router, Routes }
|
||||
|
||||
@@ -7,7 +7,7 @@ export enum SettingType {
|
||||
root_dirs,
|
||||
free_number_input,
|
||||
locked_number_input,
|
||||
|
||||
|
||||
// custom components 👇
|
||||
quick_actions,
|
||||
profile,
|
||||
@@ -15,5 +15,6 @@ export enum SettingType {
|
||||
pairing,
|
||||
about,
|
||||
streaming_quality,
|
||||
backup
|
||||
backup,
|
||||
secretinput,
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const library = {
|
||||
show_if: loggedInUserIsAdmin,
|
||||
groups: [
|
||||
{
|
||||
title: "Root directories",
|
||||
title: "Folders",
|
||||
icon: FolderSvg,
|
||||
desc: rootRootStrings.desc,
|
||||
settings: [...rootDirSettings],
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { SettingType } from "../enums";
|
||||
import { Setting } from "@/interfaces/settings";
|
||||
import { SettingType } from '../enums'
|
||||
import { Setting } from '@/interfaces/settings'
|
||||
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import useSettingsStore from '@/stores/settings'
|
||||
|
||||
const settings = useSettingsStore;
|
||||
const settings = useSettingsStore
|
||||
|
||||
const disable_np_img: Setting = {
|
||||
title: "Hide album art from the left sidebar",
|
||||
type: SettingType.binary,
|
||||
state: () => !settings().use_np_img,
|
||||
action: () => settings().toggleUseNPImg(),
|
||||
show_if: () => !settings().is_alt_layout,
|
||||
};
|
||||
title: 'Hide album art from the left sidebar',
|
||||
type: SettingType.binary,
|
||||
state: () => !settings().use_np_img,
|
||||
action: () => settings().toggleUseNPImg(),
|
||||
show_if: () => !settings().is_alt_layout,
|
||||
}
|
||||
|
||||
const showNowPlayingOnTabTitle: Setting = {
|
||||
title: "Show Now Playing track on tab title",
|
||||
desc: "Replace current page info with Now Playing track info",
|
||||
type: SettingType.binary,
|
||||
state: () => settings().nowPlayingTrackOnTabTitle,
|
||||
action: () => settings().toggleNowPlayingTrackOnTabTitle(),
|
||||
};
|
||||
title: 'Show Now Playing track on tab title',
|
||||
desc: 'Replace current page info with Now Playing track info',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().nowPlayingTrackOnTabTitle,
|
||||
action: () => settings().toggleNowPlayingTrackOnTabTitle(),
|
||||
}
|
||||
|
||||
const showInlineFavIcon: Setting = {
|
||||
title: 'Show inline favorite icon',
|
||||
desc: 'Show the favorite button next to the track duration',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().showInlineFavIcon,
|
||||
action: () => settings().toggleShowInlineFavIcon(),
|
||||
}
|
||||
|
||||
const highlightFavoriteTracks: Setting = {
|
||||
title: 'Highlight favorite tracks',
|
||||
desc: 'Always show the favorite button for favorited tracks',
|
||||
type: SettingType.binary,
|
||||
state: () => settings()._highlightFavoriteTracks,
|
||||
action: () => settings().toggleHighlightFavoriteTracks(),
|
||||
show_if: () => settings().showInlineFavIcon,
|
||||
}
|
||||
|
||||
export default [disable_np_img, showNowPlayingOnTabTitle];
|
||||
export default [disable_np_img, showNowPlayingOnTabTitle, showInlineFavIcon, highlightFavoriteTracks]
|
||||
|
||||
@@ -1,63 +1,70 @@
|
||||
import { Setting } from "@/interfaces/settings";
|
||||
import {
|
||||
addRootDirs as editRootDirs,
|
||||
triggerScan,
|
||||
} from "@/requests/settings/rootdirs";
|
||||
import { SettingType } from "../enums";
|
||||
import { manageRootDirsStrings as data } from "../strings";
|
||||
import { Setting } from '@/interfaces/settings'
|
||||
import { addRootDirs as editRootDirs, triggerScan } from '@/requests/settings/rootdirs'
|
||||
import { SettingType } from '../enums'
|
||||
import { manageRootDirsStrings as data } from '../strings'
|
||||
|
||||
import useModalStore from "@/stores/modal";
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
import useModalStore from '@/stores/modal'
|
||||
import settings from '@/stores/settings'
|
||||
|
||||
const text = data.settings;
|
||||
const text = data.settings
|
||||
|
||||
const change_root_dirs: Setting = {
|
||||
title: text.change,
|
||||
type: SettingType.button,
|
||||
state: null,
|
||||
button_text: () =>
|
||||
`\xa0 \xa0 ${
|
||||
useSettingsStore().root_dirs.length ? "Modify" : "Configure"
|
||||
} \xa0 \xa0`,
|
||||
action: () => useModalStore().showRootDirsPromptModal(),
|
||||
};
|
||||
title: text.change,
|
||||
type: SettingType.button,
|
||||
state: null,
|
||||
button_text: () => `\xa0 \xa0 ${settings().root_dirs.length ? 'Modify' : 'Configure'} \xa0 \xa0`,
|
||||
action: () => useModalStore().showRootDirsPromptModal(),
|
||||
}
|
||||
|
||||
const list_root_dirs: Setting = {
|
||||
title: text.list_root_dirs,
|
||||
type: SettingType.root_dirs,
|
||||
state: () =>
|
||||
useSettingsStore().root_dirs.map((d) => ({
|
||||
title: d,
|
||||
action: () => {
|
||||
editRootDirs([], [d]).then((all_dirs) => {
|
||||
useSettingsStore().setRootDirs(all_dirs);
|
||||
});
|
||||
},
|
||||
})),
|
||||
defaultAction: () => {},
|
||||
action: () => triggerScan(),
|
||||
};
|
||||
title: text.list_root_dirs,
|
||||
type: SettingType.root_dirs,
|
||||
state: () =>
|
||||
settings().root_dirs.map(d => ({
|
||||
title: d,
|
||||
action: () => {
|
||||
editRootDirs([], [d]).then(all_dirs => {
|
||||
settings().setRootDirs(all_dirs)
|
||||
})
|
||||
},
|
||||
})),
|
||||
defaultAction: () => {},
|
||||
action: () => triggerScan(),
|
||||
}
|
||||
|
||||
const enable_scans: Setting = {
|
||||
title: "Enable periodic scans",
|
||||
type: SettingType.binary,
|
||||
state: () => useSettingsStore().enablePeriodicScans,
|
||||
action: () => useSettingsStore().togglePeriodicScans(),
|
||||
};
|
||||
const show_playlists_in_folders: Setting = {
|
||||
title: 'Show playlists in folder view',
|
||||
desc: 'Browse playlists and favorites in folders screen (meant for mobile app)',
|
||||
type: SettingType.binary,
|
||||
state: () => settings().show_playlists_in_folders,
|
||||
action: () => settings().toggleShowPlaylistsInFolders(),
|
||||
}
|
||||
|
||||
const useWatchdog: Setting = {
|
||||
title: "Watch root dirs for new music",
|
||||
experimental: true,
|
||||
type: SettingType.binary,
|
||||
state: () => useSettingsStore().enableWatchDog,
|
||||
action: () => useSettingsStore().toggleWatchdog(),
|
||||
};
|
||||
// const enable_scans: Setting = {
|
||||
// title: "Enable periodic scans",
|
||||
// type: SettingType.binary,
|
||||
// state: () => useSettingsStore().enablePeriodicScans,
|
||||
// action: () => useSettingsStore().togglePeriodicScans(),
|
||||
// };
|
||||
|
||||
const periodicScanInterval: Setting = {
|
||||
title: "Periodic scan interval (minutes)",
|
||||
type: SettingType.free_number_input,
|
||||
state: () => useSettingsStore().periodicInterval,
|
||||
action: (newValue: number) => useSettingsStore().updatePeriodicInterval(newValue),
|
||||
};
|
||||
// const useWatchdog: Setting = {
|
||||
// title: "Watch root dirs for new music",
|
||||
// experimental: true,
|
||||
// type: SettingType.binary,
|
||||
// state: () => useSettingsStore().enableWatchDog,
|
||||
// action: () => useSettingsStore().toggleWatchdog(),
|
||||
// };
|
||||
|
||||
export default [change_root_dirs, list_root_dirs, useWatchdog, enable_scans, periodicScanInterval];
|
||||
// const periodicScanInterval: Setting = {
|
||||
// title: "Periodic scan interval (minutes)",
|
||||
// type: SettingType.free_number_input,
|
||||
// state: () => useSettingsStore().periodicInterval,
|
||||
// action: (newValue: number) => useSettingsStore().updatePeriodicInterval(newValue),
|
||||
// };
|
||||
|
||||
export default [
|
||||
change_root_dirs,
|
||||
list_root_dirs,
|
||||
show_playlists_in_folders,
|
||||
// useWatchdog, enable_scans, periodicScanInterval
|
||||
]
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import lyrics from "./lyrics";
|
||||
import useAuth from "@/stores/auth";
|
||||
import { SettingCategory } from "@/interfaces/settings";
|
||||
import lyrics from './lyrics'
|
||||
import useAuth from '@/stores/auth'
|
||||
import { SettingCategory } from '@/interfaces/settings'
|
||||
|
||||
import LyricsSvg from "@/assets/icons/lyrics.svg?raw";
|
||||
import { loggedInUserIsAdmin } from "../utils";
|
||||
import LyricsSvg from '@/assets/icons/lyrics.svg?raw'
|
||||
import LastfmSvg from '@/assets/icons/lastfm.svg?raw'
|
||||
|
||||
import { loggedInUserIsAdmin } from '../utils'
|
||||
import lastfm from './lastfm'
|
||||
|
||||
export default <SettingCategory>{
|
||||
title: "Plugins",
|
||||
show_if: loggedInUserIsAdmin,
|
||||
groups: [
|
||||
{
|
||||
title: "Lyrics",
|
||||
icon: LyricsSvg,
|
||||
desc: "Finds and displays lyrics from the internet.",
|
||||
settings: lyrics,
|
||||
experimental: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
title: 'Plugins',
|
||||
show_if: loggedInUserIsAdmin,
|
||||
groups: [
|
||||
{
|
||||
title: 'Lyrics',
|
||||
icon: LyricsSvg,
|
||||
desc: 'Finds and displays lyrics from the internet.',
|
||||
settings: lyrics,
|
||||
experimental: true,
|
||||
},
|
||||
{
|
||||
title: 'Last.fm',
|
||||
icon: LastfmSvg,
|
||||
desc: 'Last.fm integration',
|
||||
settings: lastfm,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
61
src/settings/plugins/lastfm.ts
Normal file
61
src/settings/plugins/lastfm.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import useSettings from '@/stores/settings'
|
||||
import { Setting } from '@/interfaces/settings'
|
||||
import { SettingType } from '../enums'
|
||||
|
||||
const authorize = <Setting>{
|
||||
title: 'Connect your account',
|
||||
desc: 'Allow Swing Music to access your Last.fm account',
|
||||
type: SettingType.button,
|
||||
action: () => {
|
||||
const settings = useSettings()
|
||||
if (settings.lastfm_integration_started) {
|
||||
return settings.finishLastfmAuth()
|
||||
}
|
||||
|
||||
if (settings.lastfm_session_key) {
|
||||
return settings.disconnectLastfm()
|
||||
}
|
||||
|
||||
return settings.authorizeLastfmApiKey()
|
||||
},
|
||||
button_text: () => {
|
||||
const settings = useSettings()
|
||||
if (settings.lastfm_integration_started) {
|
||||
return 'Finish'
|
||||
}
|
||||
|
||||
if (settings.lastfm_session_key) {
|
||||
return 'Disconnect'
|
||||
}
|
||||
|
||||
return 'Connect'
|
||||
},
|
||||
}
|
||||
|
||||
// const api_key = <Setting>{
|
||||
// title: 'Use custom API Key',
|
||||
// desc: 'instead of the Swing Music default to authenticate with Last.fm',
|
||||
// type: SettingType.secretinput,
|
||||
// state: () => useSettings().lastfm_api_key,
|
||||
// action: (value: string) => {
|
||||
// if (!value) {
|
||||
// return
|
||||
// }
|
||||
// return useSettings().setLastfmApiKey(value)
|
||||
// },
|
||||
// }
|
||||
|
||||
// const api_secret = <Setting>{
|
||||
// title: 'Use custom API Secret',
|
||||
// desc: 'instead of the Swing Music default to sign your scrobble submission',
|
||||
// type: SettingType.secretinput,
|
||||
// state: () => useSettings().lastfm_api_secret,
|
||||
// action: (value: string) => {
|
||||
// if (!value) {
|
||||
// return
|
||||
// }
|
||||
// return useSettings().setLastfmApiSecret(value)
|
||||
// },
|
||||
// }
|
||||
|
||||
export default [authorize]
|
||||
@@ -30,9 +30,11 @@ export default defineStore('homepage', () => {
|
||||
|
||||
async function fetchAll() {
|
||||
const data: { [key: string]: HomePageItem }[] = await getHomePageData(maxAbumCards.value)
|
||||
let keys = []
|
||||
|
||||
for (const [index, item] of data.entries()) {
|
||||
const key = Object.keys(item)[0]
|
||||
keys.push(key)
|
||||
// @ts-ignore
|
||||
homepageData[key] = item[key]
|
||||
// @ts-ignore
|
||||
@@ -41,6 +43,18 @@ export default defineStore('homepage', () => {
|
||||
homepageData[key].path = routes[key]
|
||||
// @ts-ignore
|
||||
homepageData[key].seeAllText = seeAllTexts[key]
|
||||
|
||||
if (item[key].url) {
|
||||
// @ts-ignore
|
||||
homepageData[key].path = item[key].url
|
||||
}
|
||||
}
|
||||
|
||||
// remove keys not in response
|
||||
for (const key in homepageData) {
|
||||
if (!keys.includes(key)) {
|
||||
delete homepageData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +1,90 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export enum ModalOptions {
|
||||
newPlaylist,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
SetIP,
|
||||
rootDirsPrompt,
|
||||
setRootDirs,
|
||||
saveFolderAsPlaylist,
|
||||
login,
|
||||
settings
|
||||
newPlaylist,
|
||||
page,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
SetIP,
|
||||
rootDirsPrompt,
|
||||
setRootDirs,
|
||||
saveFolderAsPlaylist,
|
||||
login,
|
||||
settings,
|
||||
}
|
||||
|
||||
export default defineStore("newModal", {
|
||||
state: () => ({
|
||||
title: "",
|
||||
options: ModalOptions,
|
||||
component: <any>null,
|
||||
props: <any>{},
|
||||
visible: false,
|
||||
}),
|
||||
actions: {
|
||||
showModal(modalOption: ModalOptions, props: any = {}) {
|
||||
this.component = modalOption;
|
||||
this.visible = true;
|
||||
this.props = props;
|
||||
export default defineStore('newModal', {
|
||||
state: () => ({
|
||||
title: '',
|
||||
options: ModalOptions,
|
||||
component: <any>null,
|
||||
props: <any>{},
|
||||
visible: false,
|
||||
}),
|
||||
actions: {
|
||||
showModal(modalOption: ModalOptions, props: any = {}) {
|
||||
this.component = modalOption
|
||||
this.visible = true
|
||||
this.props = props
|
||||
},
|
||||
showNewPlaylistModal(props: any = {}) {
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showCollectionModal(props: any = {}) {
|
||||
this.showModal(ModalOptions.page, props)
|
||||
},
|
||||
showSaveFolderAsPlaylistModal(path: string) {
|
||||
const playlist_name = path.split('/').pop()
|
||||
const props = {
|
||||
playlist_name,
|
||||
path,
|
||||
}
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showSaveArtistAsPlaylistModal(name: string, artisthash: string) {
|
||||
const props = {
|
||||
artisthash,
|
||||
playlist_name: `This is ${name}`,
|
||||
}
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showSaveQueueAsPlaylistModal(name: string) {
|
||||
const props = {
|
||||
is_queue: true,
|
||||
playlist_name: name,
|
||||
}
|
||||
this.showModal(ModalOptions.newPlaylist, props)
|
||||
},
|
||||
showEditPlaylistModal() {
|
||||
this.showModal(ModalOptions.updatePlaylist)
|
||||
},
|
||||
showDeletePlaylistModal(pid: number) {
|
||||
const props = {
|
||||
pid: pid,
|
||||
}
|
||||
this.showModal(ModalOptions.deletePlaylist, props)
|
||||
},
|
||||
showSetIPModal() {
|
||||
this.showModal(ModalOptions.SetIP)
|
||||
},
|
||||
showRootDirsPromptModal() {
|
||||
this.showModal(ModalOptions.rootDirsPrompt)
|
||||
},
|
||||
showSetRootDirsModal() {
|
||||
this.showModal(ModalOptions.setRootDirs)
|
||||
},
|
||||
showLoginModal() {
|
||||
this.showModal(ModalOptions.login)
|
||||
},
|
||||
showSettingsModal() {
|
||||
this.showModal(ModalOptions.settings)
|
||||
},
|
||||
hideModal() {
|
||||
this.visible = false
|
||||
this.setTitle('')
|
||||
},
|
||||
setTitle(new_title: string) {
|
||||
this.title = new_title
|
||||
},
|
||||
},
|
||||
showNewPlaylistModal(props: any = {}) {
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showSaveFolderAsPlaylistModal(path: string) {
|
||||
const playlist_name = path.split("/").pop();
|
||||
const props = {
|
||||
playlist_name,
|
||||
path,
|
||||
};
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showSaveArtistAsPlaylistModal(name: string, artisthash: string) {
|
||||
const props = {
|
||||
artisthash,
|
||||
playlist_name: `This is ${name}`,
|
||||
};
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showSaveQueueAsPlaylistModal(name: string) {
|
||||
const props = {
|
||||
is_queue: true,
|
||||
playlist_name: name,
|
||||
};
|
||||
this.showModal(ModalOptions.newPlaylist, props);
|
||||
},
|
||||
showEditPlaylistModal() {
|
||||
this.showModal(ModalOptions.updatePlaylist);
|
||||
},
|
||||
showDeletePlaylistModal(pid: number) {
|
||||
const props = {
|
||||
pid: pid,
|
||||
};
|
||||
this.showModal(ModalOptions.deletePlaylist, props);
|
||||
},
|
||||
showSetIPModal() {
|
||||
this.showModal(ModalOptions.SetIP);
|
||||
},
|
||||
showRootDirsPromptModal() {
|
||||
this.showModal(ModalOptions.rootDirsPrompt);
|
||||
},
|
||||
showSetRootDirsModal() {
|
||||
this.showModal(ModalOptions.setRootDirs);
|
||||
},
|
||||
showLoginModal(){
|
||||
this.showModal(ModalOptions.login);
|
||||
},
|
||||
showSettingsModal(){
|
||||
this.showModal(ModalOptions.settings);
|
||||
},
|
||||
hideModal() {
|
||||
this.visible = false;
|
||||
this.setTitle("");
|
||||
},
|
||||
setTitle(new_title: string) {
|
||||
this.title = new_title;
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
30
src/stores/pages/collections.ts
Normal file
30
src/stores/pages/collections.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Album, Artist, Collection } from '@/interfaces'
|
||||
import { getCollection } from '@/requests/collections'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export default defineStore('collections', {
|
||||
state: () => ({
|
||||
collection: <Collection | null>null,
|
||||
}),
|
||||
actions: {
|
||||
async fetchCollection(collection_id: string) {
|
||||
this.collection = await getCollection(collection_id)
|
||||
},
|
||||
async removeLocalItem(item: Album | Artist, type: 'album' | 'artist') {
|
||||
if (!this.collection) return
|
||||
|
||||
if (type == 'album') {
|
||||
this.collection.items = this.collection.items.filter(i => {
|
||||
return (i as Album).albumhash != (item as Album).albumhash
|
||||
})
|
||||
} else {
|
||||
this.collection.items = this.collection.items.filter(i => {
|
||||
return (i as Artist).artisthash != (item as Artist).artisthash
|
||||
})
|
||||
}
|
||||
},
|
||||
clearStore() {
|
||||
this.collection = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import useTracklist from './queue/tracklist'
|
||||
import useSettings from './settings'
|
||||
import useTracker from './tracker'
|
||||
|
||||
import { paths } from '@/config'
|
||||
import { getBaseUrl, paths } from '@/config'
|
||||
import updateMediaNotif from '@/helpers/mediaNotification'
|
||||
import { crossFade } from '@/utils/audio/crossFade'
|
||||
|
||||
@@ -81,15 +81,12 @@ class AudioSource {
|
||||
this.playingSource.pause()
|
||||
}
|
||||
|
||||
async playPlayingSource(
|
||||
trackSilence?: { starting_file: number; ending_file: number }
|
||||
) {
|
||||
async playPlayingSource(trackSilence?: { starting_file: number; ending_file: number }) {
|
||||
const trackDuration = trackSilence
|
||||
? Math.floor(trackSilence.ending_file / 1000 - trackSilence.starting_file / 1000)
|
||||
: null
|
||||
|
||||
if(this.requiredAPBlockBypass)
|
||||
this.applyAPBlockBypass()
|
||||
if (this.requiredAPBlockBypass) this.applyAPBlockBypass()
|
||||
|
||||
await this.playingSource.play().catch(this.handlers.onPlaybackError)
|
||||
navigator.mediaSession.playbackState = 'playing'
|
||||
@@ -110,11 +107,14 @@ class AudioSource {
|
||||
*
|
||||
* this workaround plays the `standbySource` along with the `playingSource` to meet the first condition.
|
||||
*/
|
||||
private applyAPBlockBypass(){
|
||||
private applyAPBlockBypass() {
|
||||
this.standbySource.src = ''
|
||||
this.standbySource.play().then(() => {
|
||||
this.standbySource.pause()
|
||||
}).catch(() => {})
|
||||
this.standbySource
|
||||
.play()
|
||||
.then(() => {
|
||||
this.standbySource.pause()
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
this.requiredAPBlockBypass = false
|
||||
}
|
||||
@@ -127,9 +127,11 @@ export function getUrl(filepath: string, trackhash: string, use_legacy: boolean)
|
||||
use_legacy = true
|
||||
const { streaming_container, streaming_quality } = useSettings()
|
||||
|
||||
return `${paths.api.files}/${trackhash + (use_legacy ? '/legacy' : '')}?filepath=${encodeURIComponent(
|
||||
const url = `${paths.api.files}/${trackhash + (use_legacy ? '/legacy' : '')}?filepath=${encodeURIComponent(
|
||||
filepath
|
||||
)}&container=${streaming_container}&quality=${streaming_quality}`
|
||||
|
||||
return getBaseUrl() + url
|
||||
}
|
||||
|
||||
const audioSource = new AudioSource()
|
||||
@@ -228,9 +230,12 @@ export const usePlayer = defineStore('player', () => {
|
||||
|
||||
const handlePlayErrors = (e: Event | string) => {
|
||||
if (e instanceof DOMException) {
|
||||
if(e.name === 'NotAllowedError') {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
queue.playPause()
|
||||
return toast.showNotification('Tap anywhere in the page and try again (autoplay blocked)', NotifType.Error)
|
||||
return toast.showNotification(
|
||||
'Tap anywhere in the page and try again (autoplay blocked)',
|
||||
NotifType.Error
|
||||
)
|
||||
}
|
||||
|
||||
return toast.showNotification('Player Error: ' + e.message, NotifType.Error)
|
||||
@@ -260,9 +265,9 @@ export const usePlayer = defineStore('player', () => {
|
||||
return lyrics.getLyrics()
|
||||
}
|
||||
|
||||
if (!settings.use_lyrics_plugin) {
|
||||
lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash)
|
||||
}
|
||||
// if (!settings.use_lyrics_plugin) {
|
||||
// lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash)
|
||||
// }
|
||||
}
|
||||
|
||||
const onAudioCanPlay = () => {
|
||||
@@ -278,12 +283,14 @@ export const usePlayer = defineStore('player', () => {
|
||||
const { submitData } = tracker
|
||||
submitData()
|
||||
|
||||
console.log('audio ended')
|
||||
console.log(nextAudioData)
|
||||
if (settings.repeat == 'none') {
|
||||
queue.playPause()
|
||||
queue.moveForward()
|
||||
return
|
||||
}
|
||||
|
||||
// INFO: if next audio is not loaded, manually move forward
|
||||
if (nextAudioData.loaded === false) {
|
||||
console.log('next audio not loaded')
|
||||
clearNextAudioData()
|
||||
queue.playNext()
|
||||
}
|
||||
@@ -343,6 +350,10 @@ export const usePlayer = defineStore('player', () => {
|
||||
|
||||
const silence = e.data
|
||||
|
||||
if (!silence.ending_file){
|
||||
return
|
||||
}
|
||||
|
||||
nextAudioData.silence.starting_file = silence.starting_file
|
||||
currentAudioData.silence.ending_file = silence.ending_file
|
||||
nextAudioData.loaded = silence !== null
|
||||
@@ -378,7 +389,7 @@ export const usePlayer = defineStore('player', () => {
|
||||
currentAudioData.silence = nextAudioData.silence
|
||||
currentAudioData.filepath = nextAudioData.filepath
|
||||
maxSeekPercent.value = 0
|
||||
audioSource.playPlayingSource(nextAudioData.silence);
|
||||
audioSource.playPlayingSource(nextAudioData.silence)
|
||||
|
||||
clearNextAudioData()
|
||||
queue.moveForward()
|
||||
@@ -389,10 +400,10 @@ export const usePlayer = defineStore('player', () => {
|
||||
const initLoadingNextTrackAudio = () => {
|
||||
const { currentindex } = queue
|
||||
const { length } = tracklist
|
||||
const { repeat_all, repeat_one } = settings
|
||||
const { repeat } = settings
|
||||
|
||||
// if no repeat && is last track, return
|
||||
if (currentindex === length - 1 && !repeat_all && !repeat_one) {
|
||||
if (currentindex === length - 1 && repeat == 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -76,12 +76,12 @@ export default defineStore('Queue', {
|
||||
const { tracklist } = useTracklist()
|
||||
const is_last = this.currentindex === tracklist.length - 1
|
||||
|
||||
if (settings.repeat_one) {
|
||||
if (settings.repeat == 'one') {
|
||||
this.play(this.currentindex, false)
|
||||
return
|
||||
}
|
||||
|
||||
if (settings.repeat_all) {
|
||||
if (settings.repeat == 'all') {
|
||||
this.play(is_last ? 0 : this.currentindex + 1, false)
|
||||
return
|
||||
}
|
||||
@@ -189,9 +189,9 @@ export default defineStore('Queue', {
|
||||
},
|
||||
previndex(): number {
|
||||
const { tracklist } = useTracklist()
|
||||
const { repeat_one } = useSettings()
|
||||
const { repeat } = useSettings()
|
||||
|
||||
if (repeat_one) {
|
||||
if (repeat == 'one') {
|
||||
return this.currentindex
|
||||
}
|
||||
|
||||
@@ -199,9 +199,9 @@ export default defineStore('Queue', {
|
||||
},
|
||||
nextindex(): number {
|
||||
const { tracklist } = useTracklist()
|
||||
const { repeat_one } = useSettings()
|
||||
const { repeat } = useSettings()
|
||||
|
||||
if (repeat_one) {
|
||||
if (repeat == 'one') {
|
||||
return this.currentindex
|
||||
}
|
||||
|
||||
|
||||
@@ -7,25 +7,9 @@ import useQueue from '@/stores/queue'
|
||||
import useSettings from '@/stores/settings'
|
||||
|
||||
import { FromOptions } from '@/enums'
|
||||
import {
|
||||
fromAlbum,
|
||||
fromArtist,
|
||||
fromFav,
|
||||
fromFolder,
|
||||
fromMix,
|
||||
fromPlaylist,
|
||||
fromSearch,
|
||||
Track,
|
||||
} from '@/interfaces'
|
||||
import { fromAlbum, fromArtist, fromFav, fromFolder, fromMix, fromPlaylist, fromSearch, Track } from '@/interfaces'
|
||||
|
||||
export type From =
|
||||
| fromFolder
|
||||
| fromAlbum
|
||||
| fromPlaylist
|
||||
| fromSearch
|
||||
| fromArtist
|
||||
| fromFav
|
||||
| fromMix
|
||||
export type From = fromFolder | fromAlbum | fromPlaylist | fromSearch | fromArtist | fromFav | fromMix
|
||||
|
||||
function shuffle(tracks: Track[]) {
|
||||
const shuffled = tracks.slice()
|
||||
@@ -56,12 +40,6 @@ export default defineStore('tracklist', {
|
||||
this.tracklist.push(...tracklist)
|
||||
}
|
||||
|
||||
const settings = useSettings()
|
||||
|
||||
if (settings.repeat_one) {
|
||||
settings.toggleRepeatMode()
|
||||
}
|
||||
|
||||
const { focusCurrentInSidebar } = useInterface()
|
||||
focusCurrentInSidebar(1000)
|
||||
usePlayer().clearNextAudio()
|
||||
@@ -95,7 +73,13 @@ export default defineStore('tracklist', {
|
||||
|
||||
this.setNewList(tracks)
|
||||
},
|
||||
setFromMix(name: string, id: string, tracks: Track[], sourcehash: string, image: { type: 'mix' | 'track', image: string }) {
|
||||
setFromMix(
|
||||
name: string,
|
||||
id: string,
|
||||
tracks: Track[],
|
||||
sourcehash: string,
|
||||
image: { type: 'mix' | 'track'; image: string }
|
||||
) {
|
||||
this.from = <fromMix>{
|
||||
type: FromOptions.mix,
|
||||
name: name,
|
||||
@@ -137,10 +121,7 @@ export default defineStore('tracklist', {
|
||||
this.insertAt(tracks, this.tracklist.length)
|
||||
|
||||
const Toast = useToast()
|
||||
Toast.showNotification(
|
||||
`Added ${tracks.length} tracks to queue`,
|
||||
NotifType.Success
|
||||
)
|
||||
Toast.showNotification(`Added ${tracks.length} tracks to queue`, NotifType.Success)
|
||||
},
|
||||
insertAt(tracks: Track[], index: number) {
|
||||
this.tracklist.splice(index, 0, ...tracks)
|
||||
@@ -160,14 +141,7 @@ export default defineStore('tracklist', {
|
||||
this.tracklist = shuffle(this.tracklist)
|
||||
},
|
||||
removeByIndex(index: number) {
|
||||
const {
|
||||
currentindex,
|
||||
nextindex,
|
||||
playing,
|
||||
playNext,
|
||||
moveForward,
|
||||
setCurrentIndex,
|
||||
} = useQueue()
|
||||
const { currentindex, nextindex, playing, playNext, moveForward, setCurrentIndex } = useQueue()
|
||||
const player = usePlayer()
|
||||
|
||||
if (this.tracklist.length == 1) {
|
||||
@@ -207,10 +181,7 @@ export default defineStore('tracklist', {
|
||||
this.tracklist.splice(currentindex + 1, 0, ...tracks)
|
||||
|
||||
const Toast = useToast()
|
||||
Toast.showNotification(
|
||||
`Added ${tracks.length} tracks to queue`,
|
||||
NotifType.Success
|
||||
)
|
||||
Toast.showNotification(`Added ${tracks.length} tracks to queue`, NotifType.Success)
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
|
||||
@@ -24,10 +24,7 @@ export default defineStore('search', () => {
|
||||
const currentTab = ref('top')
|
||||
const top_results = reactive({
|
||||
query: '',
|
||||
top_result: {
|
||||
type: <null | string>null,
|
||||
item: <Track | Album | Artist>{},
|
||||
},
|
||||
top_result: <Track | Album | Artist>{},
|
||||
tracks: <Track[]>[],
|
||||
albums: <Album[]>[],
|
||||
artists: <Artist[]>[],
|
||||
|
||||
@@ -7,6 +7,10 @@ import { pluginSetActive, updatePluginSettings } from '@/requests/plugins'
|
||||
import { updateConfig } from '@/requests/settings'
|
||||
import { usePlayer } from '@/stores/player'
|
||||
import { content_width } from '../content-width'
|
||||
import { getLastFmApiSig } from '@/context_menus/hashing'
|
||||
import useAxios from '@/requests/useAxios'
|
||||
import { paths } from '@/config'
|
||||
import { router, Routes } from '@/router'
|
||||
|
||||
export default defineStore('settings', {
|
||||
state: () => ({
|
||||
@@ -14,8 +18,9 @@ export default defineStore('settings', {
|
||||
extend_width: false,
|
||||
contextChildrenShowMode: contextChildrenShowMode.hover,
|
||||
artist_top_tracks_count: 5,
|
||||
repeat_all: true,
|
||||
repeat_one: false,
|
||||
// repeat_all: true,
|
||||
// repeat_one: false,
|
||||
repeat: <'all' | 'one' | 'none'>'all',
|
||||
root_dir_set: false,
|
||||
root_dirs: <string[]>[],
|
||||
|
||||
@@ -34,6 +39,7 @@ export default defineStore('settings', {
|
||||
merge_albums: false,
|
||||
show_albums_as_singles: false,
|
||||
separators: <string[]>[],
|
||||
show_playlists_in_folders: false,
|
||||
|
||||
// client
|
||||
useCircularArtistImg: true,
|
||||
@@ -47,11 +53,16 @@ export default defineStore('settings', {
|
||||
auto_download: false,
|
||||
overide_unsynced: false,
|
||||
},
|
||||
lasftfm_token: '',
|
||||
lastfm_api_key: '',
|
||||
lastfm_api_secret: '',
|
||||
lastfm_session_key: '',
|
||||
lastfm_integration_started: false,
|
||||
|
||||
// audio
|
||||
use_silence_skip: true,
|
||||
use_crossfade: false,
|
||||
crossfade_duration: 2000, // milliseconds
|
||||
crossfade_duration: 1000, // milliseconds
|
||||
use_legacy_streaming_endpoint: false,
|
||||
|
||||
// layout
|
||||
@@ -63,6 +74,8 @@ export default defineStore('settings', {
|
||||
// stats
|
||||
statsgroup: 'artists',
|
||||
statsperiod: 'week',
|
||||
showInlineFavIcon: false,
|
||||
_highlightFavoriteTracks: false,
|
||||
}),
|
||||
actions: {
|
||||
mapDbSettings(settings: DBSettings) {
|
||||
@@ -75,11 +88,15 @@ export default defineStore('settings', {
|
||||
this.merge_albums = settings.mergeAlbums
|
||||
this.separators = settings.artistSeparators
|
||||
this.show_albums_as_singles = settings.showAlbumsAsSingles
|
||||
this.show_playlists_in_folders = settings.showPlaylistsInFolderView
|
||||
|
||||
this.enablePeriodicScans = settings.enablePeriodicScans
|
||||
this.periodicInterval = settings.scanInterval
|
||||
this.enableWatchDog = settings.enableWatchDog
|
||||
|
||||
this.lastfm_api_key = settings.lastfmApiKey
|
||||
this.lastfm_api_secret = settings.lastfmApiSecret
|
||||
this.lastfm_session_key = settings.lastfmSessionKey
|
||||
this.use_lyrics_plugin = settings.plugins.find(p => p.name === 'lyrics_finder')?.active
|
||||
|
||||
if (this.use_lyrics_plugin) {
|
||||
@@ -93,6 +110,12 @@ export default defineStore('settings', {
|
||||
toggleUseNPImg() {
|
||||
this.use_np_img = !this.use_np_img
|
||||
},
|
||||
toggleShowInlineFavIcon() {
|
||||
this.showInlineFavIcon = !this.showInlineFavIcon
|
||||
},
|
||||
toggleHighlightFavoriteTracks() {
|
||||
this._highlightFavoriteTracks = !this._highlightFavoriteTracks
|
||||
},
|
||||
// sidebar 👇
|
||||
toggleDisableSidebar() {
|
||||
if (this.is_alt_layout) {
|
||||
@@ -117,20 +140,18 @@ export default defineStore('settings', {
|
||||
},
|
||||
// repeat 👇
|
||||
toggleRepeatMode() {
|
||||
if (this.repeat_all) {
|
||||
this.repeat_all = false
|
||||
this.repeat_one = true
|
||||
if (this.repeat == 'all') {
|
||||
this.repeat = 'one'
|
||||
return
|
||||
}
|
||||
|
||||
if (this.repeat_one) {
|
||||
this.repeat_one = false
|
||||
this.repeat_all = false
|
||||
if (this.repeat == 'one') {
|
||||
this.repeat = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.repeat_all && !this.repeat_one) {
|
||||
this.repeat_all = true
|
||||
if (this.repeat == 'none') {
|
||||
this.repeat = 'all'
|
||||
}
|
||||
},
|
||||
setRootDirs(dirs: string[]) {
|
||||
@@ -224,11 +245,11 @@ export default defineStore('settings', {
|
||||
},
|
||||
|
||||
async genericToggleSetting(key: string, value: any, prop: string) {
|
||||
// @ts-expect-error
|
||||
const oldValue = this[prop]
|
||||
// @ts-expect-error
|
||||
this[prop] = value
|
||||
|
||||
console.log(this[prop])
|
||||
|
||||
const res = await updateConfig(key, value)
|
||||
|
||||
if (res.status !== 200) {
|
||||
@@ -282,6 +303,68 @@ export default defineStore('settings', {
|
||||
'show_albums_as_singles'
|
||||
)
|
||||
},
|
||||
async toggleShowPlaylistsInFolders() {
|
||||
return await this.genericToggleSetting('showPlaylistsInFolderView', !this.show_playlists_in_folders, 'show_playlists_in_folders'
|
||||
)
|
||||
},
|
||||
async setLastfmApiKey(key: string) {
|
||||
return await this.genericToggleSetting('lastfmApiKey', key, 'lastfm_api_key')
|
||||
},
|
||||
async setLastfmApiSecret(key: string) {
|
||||
return await this.genericToggleSetting('lastfmApiSecret', key, 'lastfm_api_secret')
|
||||
},
|
||||
async authorizeLastfmApiKey() {
|
||||
const getTokenUrl =
|
||||
'http://ws.audioscrobbler.com/2.0/?format=json&method=auth.getToken&api_key=' +
|
||||
this.lastfm_api_key +
|
||||
'&api_sig=' +
|
||||
getLastFmApiSig({ api_key: this.lastfm_api_key }, this.lastfm_api_secret)
|
||||
|
||||
const data = await useAxios(
|
||||
{
|
||||
url: getTokenUrl,
|
||||
method: 'POST',
|
||||
},
|
||||
false
|
||||
)
|
||||
|
||||
if (data.status !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
this.lasftfm_token = data.data.token
|
||||
const url = 'https://www.last.fm/api/auth/?api_key=' + this.lastfm_api_key + '&token=' + this.lasftfm_token
|
||||
window.open(url, '_blank')
|
||||
this.lastfm_integration_started = true
|
||||
},
|
||||
async finishLastfmAuth() {
|
||||
const res = await useAxios({
|
||||
url: paths.api.plugins + '/lastfm/session/create',
|
||||
method: 'POST',
|
||||
props: {
|
||||
token: this.lasftfm_token,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
this.lastfm_session_key = res.data.session_key
|
||||
this.lastfm_integration_started = false
|
||||
},
|
||||
async disconnectLastfm() {
|
||||
const res = await useAxios({
|
||||
url: paths.api.plugins + '/lastfm/session/delete',
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.status !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
this.lastfm_session_key = ''
|
||||
},
|
||||
setStreamingQuality(quality: string) {
|
||||
this.streaming_quality = quality
|
||||
},
|
||||
@@ -296,9 +379,6 @@ export default defineStore('settings', {
|
||||
can_extend_width(): boolean {
|
||||
return this.is_default_layout && xxl.value
|
||||
},
|
||||
no_repeat(): boolean {
|
||||
return !this.repeat_all && !this.repeat_one
|
||||
},
|
||||
crossfade_duration_seconds(): number {
|
||||
return this.crossfade_duration / 1000
|
||||
},
|
||||
@@ -307,6 +387,12 @@ export default defineStore('settings', {
|
||||
},
|
||||
is_default_layout: state => state.layout === '',
|
||||
is_alt_layout: state => state.layout === 'alternate' && content_width.value > 900,
|
||||
highlightFavoriteTracks(): boolean {
|
||||
return (
|
||||
!router.currentRoute.value.name?.toString().toLowerCase().startsWith('favorite') &&
|
||||
this._highlightFavoriteTracks
|
||||
)
|
||||
},
|
||||
},
|
||||
persist: {
|
||||
afterRestore: context => {
|
||||
|
||||
@@ -156,7 +156,6 @@ function getArtistAlbumComponents(): ScrollerItem[] {
|
||||
function getAlbumVersionsComponent(): ScrollerItem | null {
|
||||
if (album.otherVersions.length == 0) return null
|
||||
|
||||
console.log(album.otherVersions)
|
||||
return {
|
||||
id: 'otherVersions',
|
||||
component: CardScroller,
|
||||
|
||||
69
src/views/Collections/Collection.vue
Normal file
69
src/views/Collections/Collection.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<CardGridPage :items="collection.collection?.items || []">
|
||||
<template #header>
|
||||
<GenericHeader v-if="collection.collection?.id">
|
||||
<template #name>
|
||||
<span @click="updatePage">
|
||||
{{ collection.collection?.name }} <span><PencilSvg height="0.8rem" width="0.8rem" /></span
|
||||
></span>
|
||||
</template>
|
||||
<template #description v-if="collection.collection?.extra.description">
|
||||
<span @click="updatePage"> {{ collection.collection?.extra.description }} </span>
|
||||
</template>
|
||||
<template #right>
|
||||
<button @click="deletePage"><DeleteSvg height="1.2rem" width="1.2rem" /> Delete</button>
|
||||
</template>
|
||||
</GenericHeader>
|
||||
</template>
|
||||
</CardGridPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import DeleteSvg from '@/assets/icons/delete.svg'
|
||||
import PencilSvg from '@/assets/icons/pencil.svg'
|
||||
import GenericHeader from '@/components/shared/GenericHeader.vue'
|
||||
import CardGridPage from '@/views/SearchView/CardGridPage.vue'
|
||||
|
||||
import useModal from '@/stores/modal'
|
||||
import useCollection from '@/stores/pages/collections'
|
||||
|
||||
const modal = useModal()
|
||||
const collection = useCollection()
|
||||
|
||||
onMounted(async () => {
|
||||
const route = useRoute()
|
||||
const collection_id = route.params.collection as string
|
||||
collection.fetchCollection(collection_id)
|
||||
})
|
||||
|
||||
function updatePage() {
|
||||
modal.showCollectionModal({
|
||||
collection: collection.collection,
|
||||
})
|
||||
}
|
||||
|
||||
function deletePage() {
|
||||
modal.showCollectionModal({
|
||||
collection: collection.collection,
|
||||
delete: true,
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
collection.clearStore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
span {
|
||||
cursor: text;
|
||||
margin-right: $smaller;
|
||||
}
|
||||
|
||||
.generichead {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,128 +1,128 @@
|
||||
<template>
|
||||
<div class="content-page favorites-page">
|
||||
<GenericHeader>
|
||||
<template #name>Favorites</template>
|
||||
<template #description
|
||||
>{{ count.tracks }} Tracks • {{ count.albums }} Albums • {{ count.artists }} Artists</template
|
||||
>
|
||||
</GenericHeader>
|
||||
<CardScroller
|
||||
v-if="recentFavs.length"
|
||||
class="recent-favs"
|
||||
:items="recentFavs"
|
||||
:title="'Recent'"
|
||||
:play-source="playSources.favorite"
|
||||
/>
|
||||
<div v-if="favTracks.length" class="fav-tracks">
|
||||
<TopTracks
|
||||
:tracks="favTracks"
|
||||
:route="'/favorites/tracks'"
|
||||
:title="'Tracks'"
|
||||
:play-handler="handlePlay"
|
||||
:source="dropSources.favorite"
|
||||
:total="count.tracks"
|
||||
/>
|
||||
<div class="content-page favorites-page">
|
||||
<GenericHeader>
|
||||
<template #name>Favorites</template>
|
||||
<template #description
|
||||
>{{ count.tracks }} Tracks • {{ count.albums }} Albums • {{ count.artists }} Artists</template
|
||||
>
|
||||
</GenericHeader>
|
||||
<CardScroller
|
||||
v-if="recentFavs.length"
|
||||
class="recent-favs"
|
||||
:items="recentFavs"
|
||||
:title="'Recent'"
|
||||
:play-source="playSources.favorite"
|
||||
/>
|
||||
<div v-if="favTracks.length" class="fav-tracks">
|
||||
<TopTracks
|
||||
:tracks="favTracks"
|
||||
:route="'/favorites/tracks'"
|
||||
:title="'Tracks'"
|
||||
:play-handler="handlePlay"
|
||||
:source="dropSources.favorite"
|
||||
:total="count.tracks"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<CardScroller
|
||||
v-if="favAlbums.length"
|
||||
:items="favAlbums.map(i => ({ type: 'album', item: i }))"
|
||||
:title="'Albums'"
|
||||
:route="'/favorites/albums'"
|
||||
/>
|
||||
|
||||
<CardScroller
|
||||
v-if="favArtists.length"
|
||||
:items="favArtists.map(i => ({ type: 'artist', item: i }))"
|
||||
:title="'Artists'"
|
||||
:route="'/favorites/artists'"
|
||||
/>
|
||||
|
||||
<NoItems :flag="noFavs" :icon="HeartSvg" :title="'No favorites found'" :description="description" />
|
||||
</div>
|
||||
<br />
|
||||
<CardScroller
|
||||
v-if="favAlbums.length"
|
||||
:items="favAlbums.map((i) => ({ type: 'album', item: i }))"
|
||||
:title="'Albums'"
|
||||
:route="'/favorites/albums'"
|
||||
/>
|
||||
|
||||
<CardScroller
|
||||
v-if="favArtists.length"
|
||||
:items="favArtists.map((i) => ({ type: 'artist', item: i }))"
|
||||
:title="'Artists'"
|
||||
:route="'/favorites/artists'"
|
||||
/>
|
||||
|
||||
<NoItems :flag="noFavs" :icon="HeartSvg" :title="'No favorites found'" :description="description" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, Ref, ref } from "vue";
|
||||
import { nextTick, onMounted, Ref, ref } from 'vue'
|
||||
|
||||
import { maxAbumCards, updateCardWidth } from "@/stores/content-width";
|
||||
import { maxAbumCards, updateCardWidth } from '@/stores/content-width'
|
||||
|
||||
import { dropSources, playSources } from "@/enums";
|
||||
import { playFromFavorites } from "@/helpers/usePlayFrom";
|
||||
import { Album, Artist, RecentFavResult, Track } from "@/interfaces";
|
||||
import { getAllFavs } from "@/requests/favorite";
|
||||
import updatePageTitle from "@/utils/updatePageTitle";
|
||||
import { dropSources, playSources } from '@/enums'
|
||||
import { playFromFavorites } from '@/helpers/usePlayFrom'
|
||||
import { Album, Artist, RecentFavResult, Track } from '@/interfaces'
|
||||
import { getAllFavs } from '@/requests/favorite'
|
||||
import updatePageTitle from '@/utils/updatePageTitle'
|
||||
|
||||
import HeartSvg from "@/assets/icons/heart-no-color.svg";
|
||||
import TopTracks from "@/components/ArtistView/TopTracks.vue";
|
||||
import CardScroller from "@/components/shared/CardScroller.vue";
|
||||
import GenericHeader from "@/components/shared/GenericHeader.vue";
|
||||
import NoItems from "@/components/shared/NoItems.vue";
|
||||
import HeartSvg from '@/assets/icons/heart-no-color.svg'
|
||||
import TopTracks from '@/components/ArtistView/TopTracks.vue'
|
||||
import CardScroller from '@/components/shared/CardScroller.vue'
|
||||
import GenericHeader from '@/components/shared/GenericHeader.vue'
|
||||
import NoItems from '@/components/shared/NoItems.vue'
|
||||
|
||||
const description = `You can add tracks, albums and artists to your favorites by clicking the heart icon`;
|
||||
const description = `You can add tracks, albums and artists to your favorites by clicking the heart icon`
|
||||
|
||||
const recentFavs: Ref<RecentFavResult[]> = ref([]);
|
||||
const favAlbums: Ref<Album[]> = ref([]);
|
||||
const favTracks: Ref<Track[]> = ref([]);
|
||||
const favArtists: Ref<Artist[]> = ref([]);
|
||||
const recentFavs: Ref<RecentFavResult[]> = ref([])
|
||||
const favAlbums: Ref<Album[]> = ref([])
|
||||
const favTracks: Ref<Track[]> = ref([])
|
||||
const favArtists: Ref<Artist[]> = ref([])
|
||||
const count = ref({
|
||||
albums: 0,
|
||||
tracks: 0,
|
||||
artists: 0,
|
||||
});
|
||||
const noFavs = ref(false);
|
||||
albums: 0,
|
||||
tracks: 0,
|
||||
artists: 0,
|
||||
})
|
||||
const noFavs = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
updatePageTitle("Favorites");
|
||||
const max = maxAbumCards.value;
|
||||
updatePageTitle('Favorites')
|
||||
const max = maxAbumCards.value
|
||||
|
||||
getAllFavs(6, max, max)
|
||||
.then((favs) => {
|
||||
recentFavs.value = favs.recents;
|
||||
favAlbums.value = favs.albums;
|
||||
favTracks.value = favs.tracks;
|
||||
favArtists.value = favs.artists;
|
||||
count.value = favs.count;
|
||||
})
|
||||
.then(() => {
|
||||
noFavs.value = !favAlbums.value.length && !favTracks.value.length && !favArtists.value.length;
|
||||
})
|
||||
.then(async () => {
|
||||
await nextTick();
|
||||
updateCardWidth();
|
||||
});
|
||||
});
|
||||
getAllFavs(6, max, max)
|
||||
.then(favs => {
|
||||
recentFavs.value = favs.recents
|
||||
favAlbums.value = favs.albums
|
||||
favTracks.value = favs.tracks
|
||||
favArtists.value = favs.artists
|
||||
count.value = favs.count
|
||||
})
|
||||
.then(() => {
|
||||
noFavs.value = !favAlbums.value.length && !favTracks.value.length && !favArtists.value.length
|
||||
})
|
||||
.then(async () => {
|
||||
await nextTick()
|
||||
updateCardWidth()
|
||||
})
|
||||
})
|
||||
|
||||
function handlePlay(index: number) {
|
||||
const track = favTracks.value[index];
|
||||
if (!track) return;
|
||||
playFromFavorites(track);
|
||||
const track = favTracks.value[index]
|
||||
if (!track) return
|
||||
playFromFavorites(track)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.favorites-page {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
.recent-favs {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nothing h3 {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.fav-tracks {
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
.recent-favs {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.artist-top-tracks {
|
||||
h3 {
|
||||
padding-right: $small;
|
||||
}
|
||||
.nothing h3 {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.fav-tracks {
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.artist-top-tracks {
|
||||
h3 {
|
||||
padding-right: $small;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<PageItem
|
||||
v-for="item in home.homepageItems"
|
||||
:key="item.path"
|
||||
:title="item.title || ''"
|
||||
:description="item.description"
|
||||
:items="item.items"
|
||||
|
||||
@@ -1,100 +1,91 @@
|
||||
<template>
|
||||
<NoItems
|
||||
:title="`No ${page} results`"
|
||||
:description="desc"
|
||||
:icon="SearchSvg"
|
||||
:flag="!items.length"
|
||||
v-if="showNoItemsComponent"
|
||||
/>
|
||||
<div class="v-scroll-page" style="height: 100%">
|
||||
<DynamicScroller
|
||||
style="height: 100%"
|
||||
class="scroller"
|
||||
:min-item-size="64"
|
||||
:items="scrollerItems"
|
||||
>
|
||||
<template #before>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:size-dependencies="[item.props]"
|
||||
:data-index="index"
|
||||
>
|
||||
<component
|
||||
:is="item.component"
|
||||
:key="index"
|
||||
v-bind="item.props"
|
||||
></component>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</div>
|
||||
<NoItems
|
||||
:title="`No ${page} results`"
|
||||
:description="desc"
|
||||
:icon="SearchSvg"
|
||||
:flag="!items.length"
|
||||
v-if="showNoItemsComponent"
|
||||
/>
|
||||
<div class="v-scroll-page" style="height: 100%">
|
||||
<DynamicScroller style="height: 100%" class="scroller" :min-item-size="64" :items="scrollerItems">
|
||||
<template #before>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:size-dependencies="[item.props]"
|
||||
:data-index="index"
|
||||
>
|
||||
<component :is="item.component" :key="index" v-bind="item.props"></component>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed } from 'vue'
|
||||
|
||||
import useSearchStore from "@/stores/search";
|
||||
import { maxAbumCards } from "@/stores/content-width";
|
||||
import useSearchStore from '@/stores/search'
|
||||
import { maxAbumCards } from '@/stores/content-width'
|
||||
|
||||
import SearchSvg from "@/assets/icons/search.svg";
|
||||
import NoItems from "@/components/shared/NoItems.vue";
|
||||
import CardRow from "@/components/shared/CardRow.vue";
|
||||
import AlbumsFetcher from "@/components/ArtistView/AlbumsFetcher.vue";
|
||||
import SearchSvg from '@/assets/icons/search.svg'
|
||||
import NoItems from '@/components/shared/NoItems.vue'
|
||||
import CardRow from '@/components/shared/CardRow.vue'
|
||||
import AlbumsFetcher from '@/components/ArtistView/AlbumsFetcher.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
page: "album" | "artist" | "mix";
|
||||
fetch_callback?: () => Promise<void>;
|
||||
items: any[];
|
||||
outside_route?: boolean;
|
||||
showNoItemsComponent?: boolean;
|
||||
}>();
|
||||
page?: 'album' | 'artist' | 'mix'
|
||||
fetch_callback?: () => Promise<void>
|
||||
items: any[]
|
||||
outside_route?: boolean
|
||||
showNoItemsComponent?: boolean
|
||||
}>()
|
||||
|
||||
const search = useSearchStore();
|
||||
const search = useSearchStore()
|
||||
|
||||
const desc = computed(() =>
|
||||
search.query === ""
|
||||
? `Start typing to search for ${props.page}s`
|
||||
: `Results for '${search.query}' should appear here`
|
||||
);
|
||||
search.query === ''
|
||||
? `Start typing to search for ${props.page}s`
|
||||
: `Results for '${search.query}' should appear here`
|
||||
)
|
||||
|
||||
const scrollerItems = computed(() => {
|
||||
let maxCards = maxAbumCards.value;
|
||||
let maxCards = maxAbumCards.value
|
||||
|
||||
if (props.outside_route) {
|
||||
maxCards = 6;
|
||||
}
|
||||
if (props.outside_route) {
|
||||
maxCards = 6
|
||||
}
|
||||
|
||||
const groups = Math.ceil(props.items.length / maxCards);
|
||||
const items = [];
|
||||
const groups = Math.ceil(props.items.length / maxCards)
|
||||
const items = []
|
||||
|
||||
for (let i = 0; i < groups; i++) {
|
||||
items.push({
|
||||
id: i,
|
||||
component: CardRow,
|
||||
props: {
|
||||
type: props.page,
|
||||
items: props.items.slice(i * maxCards, (i + 1) * maxCards),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < groups; i++) {
|
||||
items.push({
|
||||
id: i,
|
||||
component: CardRow,
|
||||
props: {
|
||||
items: props.items.slice(i * maxCards, (i + 1) * maxCards),
|
||||
},
|
||||
key: i,
|
||||
})
|
||||
}
|
||||
|
||||
const moreItems = props.page === 'album' ? search.albums.more : search.artists.more
|
||||
const moreItems = props.page === 'album' ? search.albums.more : search.artists.more
|
||||
|
||||
if (props.fetch_callback && moreItems) {
|
||||
items.push({
|
||||
id: Math.random(),
|
||||
component: AlbumsFetcher,
|
||||
props: {
|
||||
fetch_callback: props.fetch_callback,
|
||||
outside_route: props.outside_route,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (props.fetch_callback && moreItems) {
|
||||
items.push({
|
||||
id: Math.random(),
|
||||
component: AlbumsFetcher,
|
||||
props: {
|
||||
fetch_callback: props.fetch_callback,
|
||||
outside_route: props.outside_route,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -62,7 +62,6 @@ const scrollerItems = computed(() => {
|
||||
}))
|
||||
|
||||
if (search.tracks.more) {
|
||||
console.log('more tracks')
|
||||
items.push({
|
||||
// set to random to force re-render
|
||||
id: Math.random(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import vue from "@vitejs/plugin-vue";
|
||||
import svgLoader from "vite-svg-loader";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import viteCompression from "vite-plugin-compression";
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
const path = require("path");
|
||||
|
||||
@@ -90,6 +91,9 @@ export default defineConfig({
|
||||
viteCompression({
|
||||
threshold: 150,
|
||||
}),
|
||||
nodePolyfills({
|
||||
include: ['crypto'],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user