mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
chore: vue3-ify
This commit is contained in:
parent
1ab5837c76
commit
7c88e96206
122 changed files with 3589 additions and 5055 deletions
|
@ -23,7 +23,6 @@
|
|||
"ismobilejs": "^0.4.0",
|
||||
"local-storage": "^2.0.0",
|
||||
"lodash": "^4.17.19",
|
||||
"mitt": "^3.0.0",
|
||||
"nouislider": "^14.0.2",
|
||||
"nprogress": "^0.2.0",
|
||||
"plyr": "1.5.x",
|
||||
|
@ -32,7 +31,7 @@
|
|||
"sketch-js": "^1.1.3",
|
||||
"slugify": "^1.0.2",
|
||||
"vue": "^3.2.32",
|
||||
"vue-global-events": "^1.0.2",
|
||||
"vue-global-events": "^2.1.1",
|
||||
"vue-virtual-scroller": "^2.0.0-alpha.1",
|
||||
"vuequery": "~2.1.1",
|
||||
"youtube-player": "^3.0.4"
|
||||
|
@ -99,13 +98,12 @@
|
|||
"watch.bak": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
|
||||
"watch-poll.bak": "yarn watch -- --watch-poll",
|
||||
"hot.bak": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
|
||||
"dev.bak": "start-test 'php artisan serve --port=8000 --quiet' :8000 hot",
|
||||
"test:e2e": "kill-port 8080 && start-test dev :8080 'cypress open'",
|
||||
"test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' :8080 'cypress run'",
|
||||
"build": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
|
||||
"build-demo": "cross-env NODE_ENV=demo node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js -p",
|
||||
"production.bak": "yarn build",
|
||||
"dev": "npm run development",
|
||||
"dev": "start-test 'php artisan serve --port=8000 --quiet' :8000 hot",
|
||||
"development": "mix",
|
||||
"watch": "mix watch",
|
||||
"watch-poll": "mix watch -- --watch-options-poll=1000",
|
||||
|
|
|
@ -16,16 +16,20 @@
|
|||
"@typescript-eslint"
|
||||
],
|
||||
"globals": {
|
||||
"KOEL_ENV": true,
|
||||
"NODE_ENV": true,
|
||||
"HTMLElement": true,
|
||||
"FileReader": true
|
||||
"KOEL_ENV": "readonly",
|
||||
"NODE_ENV": "readonly",
|
||||
"FileReader": "readonly",
|
||||
"defineProps": "readonly",
|
||||
"defineEmits": "readonly",
|
||||
"defineExpose": "readonly",
|
||||
"withDefaults": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
"camelcase": 0,
|
||||
"no-multi-str": 0,
|
||||
"no-empty": 0,
|
||||
"quotes": 0,
|
||||
"no-use-before-define": 0,
|
||||
"@typescript-eslint/no-var-requires": 0,
|
||||
"@typescript-eslint/camelcase": 0,
|
||||
"@typescript-eslint/member-delimiter-style": 0,
|
||||
|
@ -33,8 +37,10 @@
|
|||
"@typescript-eslint/no-inferrable-types": 0,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/no-non-null-assertion": 0,
|
||||
"@typescript-eslint/ban-ts-ignore": 0,
|
||||
"@typescript-eslint/ban-ts-comment": 0,
|
||||
"vue/no-side-effects-in-computed-properties": 0,
|
||||
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||
"standard/no-callback-literal": 0,
|
||||
"vue/valid-v-on": 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,12 +14,12 @@ describe('components/layout/modal-wrapper', () => {
|
|||
it.each<[string, string, User | Song | undefined]>([
|
||||
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
|
||||
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')],
|
||||
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', factory('song')],
|
||||
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory('song')]],
|
||||
['create-smart-playlist-form', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', undefined]
|
||||
])('shows %s modal', async (modalName, eventName, eventParams?) => {
|
||||
if (modalName === 'edit-song-form') {
|
||||
// mocking the songInfo.fetch() request made during edit-form modal opening
|
||||
mock(http, 'request').mockReturnValue(Promise.resolve({ data: {}}))
|
||||
mock(http, 'request').mockReturnValue(Promise.resolve({ data: {} }))
|
||||
}
|
||||
|
||||
const wrapper = shallow(Component, {
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
/// <reference path="./types.d.ts"/>
|
||||
import Vue, { createApp } from 'vue'
|
||||
import './static-loader'
|
||||
import { createApp } from 'vue'
|
||||
import App from './app.vue'
|
||||
import { http } from '@/services'
|
||||
import { clickaway, droppable, focus } from '@/directives'
|
||||
import router from '@/router'
|
||||
|
||||
http.init()
|
||||
router.init()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
|
|
@ -34,7 +34,6 @@ import Overlay from '@/components/ui/overlay.vue'
|
|||
import { $, eventBus, hideOverlay, showOverlay } from '@/utils'
|
||||
import { favoriteStore, preferenceStore as preferences, queueStore, sharedStore } from '@/stores'
|
||||
import { auth, playback, socket } from '@/services'
|
||||
import { BaseContextMenu } from 'koel/types/ui'
|
||||
|
||||
const SongContextMenu = defineAsyncComponent(() => import('@/components/song/context-menu.vue'))
|
||||
const AlbumContextMenu = defineAsyncComponent(() => import('@/components/album/context-menu.vue'))
|
||||
|
@ -43,12 +42,13 @@ const SupportKoel = defineAsyncComponent(() => import('@/components/meta/support
|
|||
|
||||
const authenticated = ref(false)
|
||||
const contextMenuSongs = ref<Song[]>([])
|
||||
const contextMenuAlbum = ref<Album | null>(null)
|
||||
const contextMenuArtist = ref<Artist | null>(null)
|
||||
const contextMenuAlbum = ref<Album>()
|
||||
const contextMenuArtist = ref<Artist>()
|
||||
|
||||
const songContextMenu = ref<BaseContextMenu | null>(null)
|
||||
const albumContextMenu = ref<BaseContextMenu | null>(null)
|
||||
const artistContextMenu = ref<BaseContextMenu | null>(null)
|
||||
const overlay = ref<HTMLElement>()
|
||||
const songContextMenu = ref<InstanceType<typeof SongContextMenu>>()
|
||||
const albumContextMenu = ref<InstanceType<typeof AlbumContextMenu>>()
|
||||
const artistContextMenu = ref<InstanceType<typeof ArtistContextMenu>>()
|
||||
|
||||
/**
|
||||
* Request for notification permission if it's not provided and the user is OK with notifications.
|
||||
|
@ -138,7 +138,7 @@ const init = async () => {
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "~#/app.scss";
|
||||
@import "#/app.scss";
|
||||
|
||||
#dragGhost {
|
||||
display: inline-block;
|
||||
|
@ -179,13 +179,7 @@ const init = async () => {
|
|||
|
||||
.login-wrapper {
|
||||
@include vertical-center();
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
|
||||
input, button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<template>
|
||||
{{ message }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const message = ref('It works!')
|
||||
</script>
|
|
@ -8,17 +8,17 @@
|
|||
tabindex="0"
|
||||
data-test="album-card"
|
||||
v-if="album.songs.length"
|
||||
@contextmenu.prevent="requestContextMenu"
|
||||
@contextmenu.prevent.stop="requestContextMenu"
|
||||
@dblclick="shuffle"
|
||||
>
|
||||
<span class="thumbnail-wrapper">
|
||||
<album-thumbnail :entity="album" />
|
||||
<AlbumThumbnail :entity="album"/>
|
||||
</span>
|
||||
|
||||
<footer>
|
||||
<div class="info">
|
||||
<a class="name" :href="`#!/album/${album.id}`">{{ album.name }}</a>
|
||||
<span class="sep text-secondary">by</span>
|
||||
<span class="sep text-secondary"> by </span>
|
||||
<a
|
||||
class="artist"
|
||||
v-if="isNormalArtist"
|
||||
|
@ -28,11 +28,11 @@
|
|||
</div>
|
||||
<p class="meta">
|
||||
<span class="left">
|
||||
{{ album.songs.length | pluralize('song') }}
|
||||
{{ pluralize(album.songs.length, 'song') }}
|
||||
•
|
||||
{{ fmtLength }}
|
||||
•
|
||||
{{ album.playCount | pluralize('play') }}
|
||||
{{ pluralize(album.playCount, 'play') }}
|
||||
</span>
|
||||
<span class="right">
|
||||
<a
|
||||
|
@ -60,57 +60,30 @@
|
|||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive, toRefs } from 'vue'
|
||||
import { useAlbumAttributes } from '@/composables'
|
||||
import { eventBus, pluralize, startDragging } from '@/utils'
|
||||
import { artistStore, sharedStore } from '@/stores'
|
||||
import { playback, download } from '@/services'
|
||||
import albumAttributes from '@/mixins/album-attributes.ts'
|
||||
import { PropOptions } from 'vue'
|
||||
import { download as downloadService, playback } from '@/services'
|
||||
|
||||
export default mixins(albumAttributes).extend({
|
||||
props: {
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'full'
|
||||
} as PropOptions<ArtistAlbumCardLayout>
|
||||
},
|
||||
const AlbumThumbnail = defineAsyncComponent(() => import('@/components/ui/album-artist-thumbnail.vue'))
|
||||
|
||||
components: {
|
||||
AlbumThumbnail: () => import('@/components/ui/album-artist-thumbnail.vue')
|
||||
},
|
||||
const props = withDefaults(defineProps<{ album: Album, layout: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { album, layout } = toRefs(props)
|
||||
|
||||
filters: { pluralize },
|
||||
const { length, fmtLength } = useAlbumAttributes(album.value)
|
||||
|
||||
data: () => ({
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
computed: {
|
||||
isNormalArtist (): boolean {
|
||||
return !artistStore.isVariousArtists(this.album.artist) &&
|
||||
!artistStore.isUnknownArtist(this.album.artist)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
shuffle (): void {
|
||||
playback.playAllInAlbum(this.album, true /* shuffled */)
|
||||
},
|
||||
|
||||
download (): void {
|
||||
download.fromAlbum(this.album)
|
||||
},
|
||||
|
||||
dragStart (event: DragEvent): void {
|
||||
startDragging(event, this.album, 'Album')
|
||||
},
|
||||
|
||||
requestContextMenu (e: MouseEvent): void {
|
||||
eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', e, this.album)
|
||||
}
|
||||
}
|
||||
const isNormalArtist = computed(() => {
|
||||
return !artistStore.isVariousArtists(album.value.artist) && !artistStore.isUnknownArtist(album.value.artist)
|
||||
})
|
||||
|
||||
const shuffle = () => playback.playAllInAlbum(album.value, true /* shuffled */)
|
||||
const download = () => downloadService.fromAlbum(album.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, album.value, 'Album')
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', event, album.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<base-context-menu extra-class="album-menu" ref="base" data-testid="album-context-menu">
|
||||
<BaseContextMenu extra-class="album-menu" data-testid="album-context-menu" ref="base">
|
||||
<template v-if="album">
|
||||
<li data-test="play" @click="play">Play All</li>
|
||||
<li data-test="shuffle" @click="shuffle">Shuffle All</li>
|
||||
|
@ -8,75 +8,49 @@
|
|||
<li data-test="view-artist" @click="viewArtistDetails" v-if="isStandardArtist">Go to Artist</li>
|
||||
<template v-if="isStandardAlbum && sharedState.allowDownload">
|
||||
<li class="separator"></li>
|
||||
<li data-test="download" @click="download" >Download</li>
|
||||
<li data-test="download" @click="download">Download</li>
|
||||
</template>
|
||||
</template>
|
||||
</base-context-menu>
|
||||
</BaseContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
import { BaseContextMenu } from 'koel/types/ui'
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, toRefs } from 'vue'
|
||||
import { albumStore, artistStore, sharedStore } from '@/stores'
|
||||
import { download, playback } from '@/services'
|
||||
import { download as downloadService, playback } from '@/services'
|
||||
import { useContextMenu } from '@/composables'
|
||||
import router from '@/router'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
BaseContextMenu: () => import('@/components/ui/context-menu.vue')
|
||||
},
|
||||
const { base, BaseContextMenu, open, close } = useContextMenu()
|
||||
|
||||
props: {
|
||||
album: {
|
||||
type: Object
|
||||
} as PropOptions<Album>
|
||||
},
|
||||
const props = defineProps<{ album: Album }>()
|
||||
const { album } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
computed: {
|
||||
isStandardAlbum (): boolean {
|
||||
return !albumStore.isUnknownAlbum(this.album)
|
||||
},
|
||||
const isStandardAlbum = computed(() => !albumStore.isUnknownAlbum(album.value))
|
||||
|
||||
isStandardArtist (): boolean {
|
||||
return !artistStore.isUnknownArtist(this.album.artist) && !artistStore.isVariousArtists(this.album.artist)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
open (top: number, left: number): void {
|
||||
(this.$refs.base as BaseContextMenu).open(top, left)
|
||||
},
|
||||
|
||||
play (): void {
|
||||
playback.playAllInAlbum(this.album)
|
||||
},
|
||||
|
||||
shuffle (): void {
|
||||
playback.playAllInAlbum(this.album, true /* shuffled */)
|
||||
},
|
||||
|
||||
viewAlbumDetails (): void {
|
||||
router.go(`album/${this.album.id}`)
|
||||
this.close()
|
||||
},
|
||||
|
||||
viewArtistDetails (): void {
|
||||
router.go(`artist/${this.album.artist.id}`)
|
||||
this.close()
|
||||
},
|
||||
|
||||
download (): void {
|
||||
download.fromAlbum(this.album)
|
||||
this.close()
|
||||
},
|
||||
|
||||
close (): void {
|
||||
(this.$refs.base as BaseContextMenu).close()
|
||||
}
|
||||
}
|
||||
const isStandardArtist = computed(() => {
|
||||
return !artistStore.isUnknownArtist(album.value.artist) && !artistStore.isVariousArtists(album.value.artist)
|
||||
})
|
||||
|
||||
const play = () => playback.playAllInAlbum(album.value)
|
||||
const shuffle = () => playback.playAllInAlbum(album.value, true /* shuffled */)
|
||||
|
||||
const viewAlbumDetails = () => {
|
||||
router.go(`album/${album.value.id}`)
|
||||
close()
|
||||
}
|
||||
|
||||
const viewArtistDetails = () => {
|
||||
router.go(`artist/${album.value.artist.id}`)
|
||||
close()
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
downloadService.fromAlbum(album.value)
|
||||
close()
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
</script>
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
</h1>
|
||||
|
||||
<main>
|
||||
<album-thumbnail :entity="album"/>
|
||||
<AlbumThumbnail :entity="album"/>
|
||||
|
||||
<template v-if="album.info">
|
||||
<div class="wiki" v-if="album.info.wiki && album.info.wiki.summary">
|
||||
<div class="summary" v-if="showSummary" v-html="album.info.wiki.summary"></div>
|
||||
<div class="wiki" v-if="album.info.wiki?.summary">
|
||||
<div class="summary" v-if="showSummary" v-html="album.info.wiki?.summary"></div>
|
||||
<div class="full" v-if="showFull" v-html="album.info.wiki.full"></div>
|
||||
|
||||
<button class="more" v-if="showSummary" @click.prevent="showingFullWiki = true" data-test="more-btn">
|
||||
|
@ -20,7 +20,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<track-list :album="album" v-if="album.info.tracks && album.info.tracks.length" data-test="album-info-tracks"/>
|
||||
<TrackList :album="album" v-if="album.info.tracks?.length" data-test="album-info-tracks"/>
|
||||
|
||||
<footer>Data © <a target="_blank" rel="noopener" :href="album.info.url">Last.fm</a></footer>
|
||||
</template>
|
||||
|
@ -29,60 +29,29 @@
|
|||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { sharedStore } from '@/stores'
|
||||
import { playback, auth } from '@/services'
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { playback } from '@/services'
|
||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
album: Object as PropOptions<Album>,
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'sidebar',
|
||||
validator: value => ['sidebar', 'full'].includes(value)
|
||||
}
|
||||
},
|
||||
const TrackList = defineAsyncComponent(() => import('./track-list.vue'))
|
||||
const AlbumThumbnail = defineAsyncComponent(() => import('@/components/ui/album-artist-thumbnail.vue'))
|
||||
|
||||
components: {
|
||||
TrackList: () => import('./track-list.vue'),
|
||||
AlbumThumbnail: () => import('@/components/ui/album-artist-thumbnail.vue')
|
||||
},
|
||||
type DisplayMode = 'sidebar' | 'full'
|
||||
|
||||
data: () => ({
|
||||
showingFullWiki: false,
|
||||
useiTunes: sharedStore.state.useiTunes
|
||||
}),
|
||||
const props = withDefaults(defineProps<{ album: Album, mode: DisplayMode }>(), { mode: 'sidebar' })
|
||||
const { album, mode } = toRefs(props)
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Whenever a new album is loaded into this component, we reset the "full wiki" state.
|
||||
*/
|
||||
album (): void {
|
||||
this.showingFullWiki = false
|
||||
}
|
||||
},
|
||||
const showingFullWiki = ref(false)
|
||||
|
||||
computed: {
|
||||
showSummary (): boolean {
|
||||
return this.mode !== 'full' && !this.showingFullWiki
|
||||
},
|
||||
/**
|
||||
* Whenever a new album is loaded into this component, we reset the "full wiki" state.
|
||||
*/
|
||||
watch(album, () => (showingFullWiki.value = false))
|
||||
|
||||
showFull (): boolean {
|
||||
return this.mode === 'full' || this.showingFullWiki
|
||||
},
|
||||
const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.value)
|
||||
const showFull = computed(() => !showSummary.value)
|
||||
|
||||
iTunesUrl (): string {
|
||||
return `${window.BASE_URL}itunes/album/${this.album.id}&api_token=${auth.getToken()}`
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
shuffleAll (): void {
|
||||
playback.playAllInAlbum(this.album)
|
||||
}
|
||||
}
|
||||
})
|
||||
const shuffleAll = () => playback.playAllInAlbum(album.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -15,56 +15,29 @@
|
|||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
import { songStore, queueStore, sharedStore } from '@/stores'
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
import { queueStore, sharedStore, songStore } from '@/stores'
|
||||
import { auth, playback } from '@/services'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
album: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Album>,
|
||||
const props = defineProps<{ album: Album, track: AlbumTrack, index: number }>()
|
||||
const { album, track, index } = toRefs(props)
|
||||
|
||||
track: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<AlbumTrack>,
|
||||
const useiTunes = ref(sharedStore.state.useiTunes)
|
||||
|
||||
index: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
const song = computed(() => songStore.guess(track.value.title, album.value))
|
||||
const tooltip = computed(() => song.value ? 'Click to play' : '')
|
||||
|
||||
data: () => ({
|
||||
useiTunes: sharedStore.state.useiTunes
|
||||
}),
|
||||
|
||||
computed: {
|
||||
song (): Song | null {
|
||||
return songStore.guess(this.track.title, this.album)
|
||||
},
|
||||
|
||||
tooltip (): string {
|
||||
return this.song ? 'Click to play' : ''
|
||||
},
|
||||
|
||||
iTunesUrl (): string {
|
||||
return `${window.BASE_URL}itunes/song/${this.album.id}?q=${encodeURIComponent(this.track.title)}&api_token=${auth.getToken()}`
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
play (): void {
|
||||
if (this.song) {
|
||||
queueStore.contains(this.song) || queueStore.queueAfterCurrent(this.song)
|
||||
playback.play(this.song)
|
||||
}
|
||||
}
|
||||
}
|
||||
const iTunesUrl = computed(() => {
|
||||
return `${window.BASE_URL}itunes/song/${album.value.id}?q=${encodeURIComponent(track.value.title)}&api_token=${auth.getToken()}`
|
||||
})
|
||||
|
||||
const play = () => {
|
||||
if (song.value) {
|
||||
queueStore.contains(song.value) || queueStore.queueAfterCurrent(song.value)
|
||||
playback.play(song.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -87,7 +60,7 @@ export default Vue.extend({
|
|||
margin-left: 4px;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: linear-gradient(27deg, #fe5c52 0%,#c74bd5 50%,#2daaff 100%);
|
||||
background: linear-gradient(27deg, #fe5c52 0%, #c74bd5 50%, #2daaff 100%);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
|
|
|
@ -8,26 +8,18 @@
|
|||
:key="idx"
|
||||
:index="idx"
|
||||
:track="track"
|
||||
is="track-list-item"
|
||||
is="TrackListItem"
|
||||
v-for="(track, idx) in album.info.tracks"
|
||||
/>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, toRefs } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
album: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Album>
|
||||
},
|
||||
const TrackListItem = defineAsyncComponent(() => import('./track-list-item.vue'))
|
||||
|
||||
components: {
|
||||
TrackListItem: () => import('./track-list-item.vue')
|
||||
}
|
||||
})
|
||||
const props = defineProps<{ album: Album }>()
|
||||
const { album } = toRefs(props)
|
||||
</script>
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
tabindex="0"
|
||||
data-test="artist-card"
|
||||
v-if="showing"
|
||||
@contextmenu.prevent="requestContextMenu"
|
||||
@contextmenu.prevent.stop="requestContextMenu"
|
||||
@dblclick="shuffle"
|
||||
>
|
||||
<span class="thumbnail-wrapper">
|
||||
<artist-thumbnail :entity="artist" />
|
||||
<ArtistThumbnail :entity="artist"/>
|
||||
</span>
|
||||
|
||||
<footer>
|
||||
|
@ -23,11 +23,11 @@
|
|||
</div>
|
||||
<p class="meta">
|
||||
<span class="left">
|
||||
{{ artist.albums.length | pluralize('album') }}
|
||||
{{ pluralize(artist.albums.length, 'album') }}
|
||||
•
|
||||
{{ artist.songs.length | pluralize('song') }}
|
||||
{{ pluralize(artist.songs.length, 'song') }}
|
||||
•
|
||||
{{ artist.playCount | pluralize('play') }}
|
||||
{{ pluralize(artist.playCount, 'play') }}
|
||||
</span>
|
||||
<span class="right">
|
||||
<a
|
||||
|
@ -55,56 +55,28 @@
|
|||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import { startDragging, pluralize, eventBus } from '@/utils'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, startDragging } from '@/utils'
|
||||
import { artistStore, sharedStore } from '@/stores'
|
||||
import { playback, download } from '@/services'
|
||||
import artistAttributes from '@/mixins/artist-attributes.ts'
|
||||
import { PropOptions } from 'vue'
|
||||
import { download as downloadService, playback } from '@/services'
|
||||
import { useArtistAttributes } from '@/composables'
|
||||
|
||||
export default mixins(artistAttributes).extend({
|
||||
props: {
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'full'
|
||||
} as PropOptions<ArtistAlbumCardLayout>
|
||||
},
|
||||
const ArtistThumbnail = defineAsyncComponent(() => import('@/components/ui/album-artist-thumbnail.vue'))
|
||||
|
||||
components: {
|
||||
ArtistThumbnail: () => import('@/components/ui/album-artist-thumbnail.vue')
|
||||
},
|
||||
const props = withDefaults(defineProps<{ artist: Artist, layout: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { artist, layout } = toRefs(props)
|
||||
|
||||
filters: { pluralize },
|
||||
const { length, fmtLength, image } = useArtistAttributes(artist.value)
|
||||
|
||||
data: () => ({
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
computed: {
|
||||
showing (): boolean {
|
||||
return Boolean(this.artist.songs.length && !artistStore.isVariousArtists(this.artist))
|
||||
}
|
||||
},
|
||||
const showing = computed(() => artist.value.songs.length && !artistStore.isVariousArtists(artist.value))
|
||||
|
||||
methods: {
|
||||
shuffle (): void {
|
||||
playback.playAllByArtist(this.artist, true /* shuffled */)
|
||||
},
|
||||
|
||||
download (): void {
|
||||
download.fromArtist(this.artist)
|
||||
},
|
||||
|
||||
dragStart (event: DragEvent): void {
|
||||
startDragging(event, this.artist, 'Artist')
|
||||
},
|
||||
|
||||
requestContextMenu (e: MouseEvent): void {
|
||||
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', e, this.artist)
|
||||
}
|
||||
}
|
||||
})
|
||||
const shuffle = () => playback.playAllByArtist(artist.value, true /* shuffled */)
|
||||
const download = () => downloadService.fromArtist(artist.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, artist.value, 'Artist')
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', event, artist.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<base-context-menu extra-class="artist-menu" ref="base" data-testid="artist-context-menu">
|
||||
<BaseContextMenu extra-class="artist-menu" ref="base" data-testid="artist-context-menu">
|
||||
<template v-if="artist">
|
||||
<li data-test="play" @click="play">Play All</li>
|
||||
<li data-test="shuffle" @click="shuffle">Shuffle All</li>
|
||||
|
@ -12,63 +12,40 @@
|
|||
<li data-test="download" @click="download">Download</li>
|
||||
</template>
|
||||
</template>
|
||||
</base-context-menu>
|
||||
</BaseContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
import { BaseContextMenu } from 'koel/types/ui'
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, toRefs } from 'vue'
|
||||
import { artistStore, sharedStore } from '@/stores'
|
||||
import { download, playback } from '@/services'
|
||||
import { download as downloadService, playback } from '@/services'
|
||||
import { useContextMenu } from '@/composables'
|
||||
import router from '@/router'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
BaseContextMenu: () => import('@/components/ui/context-menu.vue')
|
||||
},
|
||||
const { base, BaseContextMenu, open, close } = useContextMenu()
|
||||
|
||||
props: {
|
||||
artist: {
|
||||
type: Object
|
||||
} as PropOptions<Artist>
|
||||
},
|
||||
const props = defineProps<{ artist: Artist }>()
|
||||
const { artist } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
computed: {
|
||||
isStandardArtist (): boolean {
|
||||
return !artistStore.isUnknownArtist(this.artist) && !artistStore.isVariousArtists(this.artist)
|
||||
}
|
||||
},
|
||||
const isStandardArtist = computed(() =>
|
||||
!artistStore.isUnknownArtist(artist.value)
|
||||
&& !artistStore.isVariousArtists(artist.value)
|
||||
)
|
||||
|
||||
methods: {
|
||||
open (top: number, left: number): void {
|
||||
(this.$refs.base as BaseContextMenu).open(top, left)
|
||||
},
|
||||
const play = () => playback.playAllByArtist(artist.value)
|
||||
const shuffle = () => playback.playAllByArtist(artist.value, true /* shuffled */)
|
||||
|
||||
play (): void {
|
||||
playback.playAllByArtist(this.artist)
|
||||
},
|
||||
const viewArtistDetails = () => {
|
||||
router.go(`artist/${artist.value.id}`)
|
||||
close()
|
||||
}
|
||||
|
||||
shuffle (): void {
|
||||
playback.playAllByArtist(this.artist, true /* shuffled */)
|
||||
},
|
||||
const download = () => {
|
||||
downloadService.fromArtist(artist.value)
|
||||
close()
|
||||
}
|
||||
|
||||
viewArtistDetails (): void {
|
||||
router.go(`artist/${this.artist.id}`)
|
||||
this.close()
|
||||
},
|
||||
|
||||
download (): void {
|
||||
download.fromArtist(this.artist)
|
||||
this.close()
|
||||
},
|
||||
|
||||
close (): void {
|
||||
(this.$refs.base as BaseContextMenu).close()
|
||||
}
|
||||
}
|
||||
})
|
||||
defineExpose({ open, close })
|
||||
</script>
|
||||
|
|
|
@ -8,10 +8,10 @@
|
|||
</h1>
|
||||
|
||||
<main v-if="artist.info">
|
||||
<artist-thumbnail :entity="artist"/>
|
||||
<ArtistThumbnail :entity="artist"/>
|
||||
|
||||
<template v-if="artist.info">
|
||||
<div class="bio" v-if="artist.info.bio && artist.info.bio.summary">
|
||||
<div class="bio" v-if="artist.info.bio?.summary">
|
||||
<div class="summary" v-if="showSummary" v-html="artist.info.bio.summary"></div>
|
||||
<div class="full" v-if="showFull" v-html="artist.info.bio.full"></div>
|
||||
|
||||
|
@ -27,54 +27,25 @@
|
|||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import { playback } from '@/services'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
artist: Object as PropOptions<Artist>,
|
||||
type DisplayMode = 'sidebar' | 'full'
|
||||
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'sidebar',
|
||||
validator: value => ['sidebar', 'full'].includes(value)
|
||||
}
|
||||
},
|
||||
const ArtistThumbnail = defineAsyncComponent(() => import('@/components/ui/album-artist-thumbnail.vue'))
|
||||
|
||||
components: {
|
||||
ArtistThumbnail: () => import('@/components/ui/album-artist-thumbnail.vue')
|
||||
},
|
||||
const props = withDefaults(defineProps<{ artist: Artist, mode: DisplayMode }>(), { mode: 'sidebar' })
|
||||
const { artist, mode } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
showingFullBio: false
|
||||
}),
|
||||
const showingFullBio = ref(false)
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Whenever a new artist is loaded into this component, we reset the "full bio" state.
|
||||
*/
|
||||
artist (): void {
|
||||
this.showingFullBio = false
|
||||
}
|
||||
},
|
||||
watch(artist, () => (showingFullBio.value = false))
|
||||
|
||||
computed: {
|
||||
showSummary (): boolean {
|
||||
return this.mode !== 'full' && !this.showingFullBio
|
||||
},
|
||||
const showSummary = computed(() => mode.value !== 'full' && !showingFullBio)
|
||||
const showFull = computed(() => !showSummary.value)
|
||||
|
||||
showFull (): boolean {
|
||||
return this.mode === 'full' || this.showingFullBio
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
shuffleAll (): void {
|
||||
playback.playAllByArtist(this.artist, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
const shuffleAll = () => playback.playAllByArtist(artist.value, false)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -3,80 +3,44 @@
|
|||
<div class="logo">
|
||||
<img src="@/../img/logo.svg" width="156" height="auto" alt="Koel's logo">
|
||||
</div>
|
||||
<input v-if="isDesktopApp" v-model="url" type="text" placeholder="Koel's Host" autofocus required>
|
||||
<input v-model="email" type="email" placeholder="Email Address" autofocus required>
|
||||
<input v-model="password" type="password" placeholder="Password" required>
|
||||
<btn type="submit">Log In</btn>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import axios from 'axios'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
import { ls } from '@/services'
|
||||
|
||||
const DEMO_ACCOUNT = {
|
||||
email: 'demo@koel.dev',
|
||||
password: 'demo'
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
url: '',
|
||||
email: NODE_ENV === 'demo' ? DEMO_ACCOUNT.email : '',
|
||||
password: NODE_ENV === 'demo' ? DEMO_ACCOUNT.password : '',
|
||||
failed: false,
|
||||
isDesktopApp: KOEL_ENV === 'app'
|
||||
}),
|
||||
const url = ref('')
|
||||
const email = ref(NODE_ENV === 'demo' ? DEMO_ACCOUNT.email : '')
|
||||
const password = ref(NODE_ENV === 'demo' ? DEMO_ACCOUNT.password : '')
|
||||
const failed = ref(false)
|
||||
|
||||
methods: {
|
||||
async login (): Promise<void> {
|
||||
if (KOEL_ENV === 'app') {
|
||||
if (this.url.indexOf('http://') !== 0 && this.url.indexOf('https://') !== 0) {
|
||||
this.url = `https://${this.url}`
|
||||
}
|
||||
const emit = defineEmits(['loggedin'])
|
||||
|
||||
if (!this.url.endsWith('/')) {
|
||||
this.url = `${this.url}/`
|
||||
}
|
||||
const login = async () => {
|
||||
try {
|
||||
await userStore.login(email.value, password.value)
|
||||
failed.value = false
|
||||
|
||||
axios.defaults.baseURL = `${this.url}api`
|
||||
}
|
||||
// Reset the password so that the next login will have this field empty.
|
||||
password.value = ''
|
||||
|
||||
try {
|
||||
await userStore.login(this.email, this.password)
|
||||
this.failed = false
|
||||
|
||||
// Reset the password so that the next login will have this field empty.
|
||||
this.password = ''
|
||||
|
||||
if (KOEL_ENV === 'app') {
|
||||
ls.set('koelHost', this.url)
|
||||
ls.set('lastLoginEmail', this.email)
|
||||
}
|
||||
|
||||
this.$emit('loggedin')
|
||||
} catch (err) {
|
||||
this.failed = true
|
||||
window.setTimeout((): void => {
|
||||
this.failed = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted (): void {
|
||||
if (KOEL_ENV === 'app') {
|
||||
this.url = window.BASE_URL = String(ls.get<string>('koelHost'))
|
||||
this.email = String(ls.get('lastLoginEmail'))
|
||||
}
|
||||
emit('loggedin')
|
||||
} catch (err) {
|
||||
failed.value = true
|
||||
window.setTimeout(() => (failed.value = false), 2000)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -125,7 +89,7 @@ form {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 414px) {
|
||||
@media only screen and (max-width: 414px) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
@ -1,59 +1,40 @@
|
|||
<template>
|
||||
<footer id="mainFooter" @contextmenu.prevent="requestContextMenu">
|
||||
<player-controls :song="song"/>
|
||||
<PlayerControls :song="song"/>
|
||||
|
||||
<div class="media-info-wrap">
|
||||
<middle-pane :song="song"/>
|
||||
<other-controls :song="song"/>
|
||||
<MiddlePane :song="song"/>
|
||||
<OtherControls :song="song"/>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
|
||||
import OtherControls from '@/components/layout/app-footer/other-controls.vue'
|
||||
import MiddlePane from '@/components/layout/app-footer/middle-pane.vue'
|
||||
import PlayerControls from '@/components/layout/app-footer/player-controls.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
song: null as unknown as Song,
|
||||
viewingQueue: false
|
||||
}),
|
||||
const song = ref<Song | null>(null)
|
||||
const viewingQueue = ref(false)
|
||||
|
||||
components: {
|
||||
MiddlePane,
|
||||
PlayerControls,
|
||||
OtherControls
|
||||
},
|
||||
const requestContextMenu = (event: MouseEvent) => {
|
||||
song.value?.id && eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
|
||||
}
|
||||
|
||||
methods: {
|
||||
requestContextMenu (e: MouseEvent): void {
|
||||
if (this.song?.id) {
|
||||
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', e, this.song)
|
||||
}
|
||||
}
|
||||
},
|
||||
eventBus.on({
|
||||
/**
|
||||
* Listen to song:played event to set the current playing song.
|
||||
*/
|
||||
'SONG_STARTED': (newSong: Song) => (song.value = newSong),
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
/**
|
||||
* Listen to song:played event to set the current playing song.
|
||||
*/
|
||||
'SONG_STARTED': (song: Song): void => {
|
||||
this.song = song
|
||||
},
|
||||
|
||||
/**
|
||||
* Listen to main-content-view:load event and highlight the Queue icon if
|
||||
* the Queue screen is being loaded.
|
||||
*/
|
||||
'LOAD_MAIN_CONTENT': (view: MainViewName): void => {
|
||||
this.viewingQueue = view === 'Queue'
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Listen to main-content-view:load event and highlight the Queue icon if
|
||||
* the Queue screen is being loaded.
|
||||
*/
|
||||
'LOAD_MAIN_CONTENT': (view: MainViewName) => (viewingQueue.value = view === 'Queue')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -16,16 +16,11 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
song: {
|
||||
type: Object
|
||||
} as PropOptions<Song>
|
||||
}
|
||||
})
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { song } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<style lang="scss">/* no scoping here because we're overriding some plyr classes */
|
||||
|
|
|
@ -45,69 +45,40 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, ref, toRefs } from 'vue'
|
||||
import { socket } from '@/services'
|
||||
import { eventBus, isAudioContextSupported } from '@/utils'
|
||||
import { eventBus, isAudioContextSupported as useEqualizer } from '@/utils'
|
||||
import { favoriteStore, preferenceStore, sharedStore, songStore } from '@/stores'
|
||||
import isMobile from 'ismobilejs'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
song: {
|
||||
type: Object
|
||||
} as PropOptions<Song>
|
||||
},
|
||||
const Equalizer = defineAsyncComponent(() => import('@/components/ui/equalizer.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
const Volume = defineAsyncComponent(() => import('@/components/ui/volume.vue'))
|
||||
const LikeButton = defineAsyncComponent(() => import('@/components/song/like-button.vue'))
|
||||
const RepeatModeSwitch = defineAsyncComponent(() => import('@/components/ui/repeat-mode-switch.vue'))
|
||||
|
||||
components: {
|
||||
Equalizer: () => import('@/components/ui/equalizer.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue'),
|
||||
Volume: () => import('@/components/ui/volume.vue'),
|
||||
LikeButton: () => import('@/components/song/like-button.vue'),
|
||||
RepeatModeSwitch: () => import('@/components/ui/repeat-mode-switch.vue')
|
||||
},
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
preferences: preferenceStore.state,
|
||||
showEqualizer: false,
|
||||
sharedState: sharedStore.state,
|
||||
useEqualizer: isAudioContextSupported,
|
||||
viewingQueue: false
|
||||
}),
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
const preferences = reactive(preferenceStore.state)
|
||||
const showEqualizer = ref(false)
|
||||
const viewingQueue = ref(false)
|
||||
|
||||
methods: {
|
||||
like (): void {
|
||||
if (this.song.id) {
|
||||
favoriteStore.toggleOne(this.song)
|
||||
socket.broadcast('SOCKET_SONG', songStore.generateDataToBroadcast(this.song))
|
||||
}
|
||||
},
|
||||
|
||||
toggleExtraPanel (): void {
|
||||
preferenceStore.showExtraPanel = !this.preferences.showExtraPanel
|
||||
},
|
||||
|
||||
toggleEqualizer (): void {
|
||||
this.showEqualizer = !this.showEqualizer
|
||||
},
|
||||
|
||||
closeEqualizer (): void {
|
||||
this.showEqualizer = false
|
||||
},
|
||||
|
||||
toggleVisualizer: (): void => {
|
||||
if (!isMobile.any) {
|
||||
eventBus.emit('TOGGLE_VISUALIZER')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName): void => {
|
||||
this.viewingQueue = view === 'Queue'
|
||||
})
|
||||
const like = () => {
|
||||
if (song.value.id) {
|
||||
favoriteStore.toggleOne(song.value)
|
||||
socket.broadcast('SOCKET_SONG', songStore.generateDataToBroadcast(song.value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleExtraPanel = () => (preferenceStore.showExtraPanel = !preferences.showExtraPanel)
|
||||
const toggleEqualizer = () => (showEqualizer.value = !showEqualizer.value)
|
||||
const closeEqualizer = () => showEqualizer.value = false
|
||||
const toggleVisualizer = () => isMobile.any || eventBus.emit('TOGGLE_VISUALIZER')
|
||||
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName) => (viewingQueue.value = view === 'Queue'))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
tabindex="0"
|
||||
title="Play or resume"
|
||||
data-testid="play-btn"
|
||||
v-if="shouldDisplayPlayButton"
|
||||
v-if="shouldShowPlayButton"
|
||||
>
|
||||
<i class="fa fa-play"></i>
|
||||
</span>
|
||||
|
@ -46,34 +46,20 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { playback } from '@/services'
|
||||
import { getDefaultCover } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
song: {
|
||||
type: Object
|
||||
} as PropOptions<Song>
|
||||
},
|
||||
const props = defineProps<{ song: Song | null }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
computed: {
|
||||
cover (): string {
|
||||
return this.song && this.song.album.cover ? this.song.album.cover : getDefaultCover()
|
||||
},
|
||||
const cover = computed(() => song.value?.album.cover ? song.value.album.cover : getDefaultCover())
|
||||
const shouldShowPlayButton = computed(() => !song || song.value?.playbackState !== 'Playing')
|
||||
|
||||
shouldDisplayPlayButton (): boolean {
|
||||
return !this.song || (this.song && this.song.playbackState !== 'Playing')
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
playPrev: async () => await playback.playPrev(),
|
||||
playNext: async () => await playback.playNext(),
|
||||
toggle: async () => await playback.toggle()
|
||||
}
|
||||
})
|
||||
const playPrev = async () => await playback.playPrev()
|
||||
const playNext = async () => await playback.playNext()
|
||||
const toggle = async () => await playback.toggle()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -154,7 +140,7 @@ export default Vue.extend({
|
|||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: linear-gradient(135deg, rgba(235,241,246,0) 0%,rgba(255,255,255,.3) 41%,rgba(255,255,255,0) 41%);
|
||||
background: linear-gradient(135deg, rgba(235, 241, 246, 0) 0%, rgba(255, 255, 255, .3) 41%, rgba(255, 255, 255, 0) 41%);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<header id="mainHeader" @dblclick="triggerMaximize">
|
||||
<h1 class="brand" v-once>{{ appName }}</h1>
|
||||
<header id="mainHeader">
|
||||
<h1 class="brand" v-once>{{ appConfig.name }}</h1>
|
||||
<span class="hamburger" @click="toggleSidebar" role="button" title="Show or hide the sidebar">
|
||||
<i class="fa fa-bars"></i>
|
||||
</span>
|
||||
|
@ -10,7 +10,13 @@
|
|||
<search-form/>
|
||||
<div class="header-right">
|
||||
<user-badge/>
|
||||
<button @click.prevent="showAboutDialog" class="about control" title="About Koel" data-testid="about-btn">
|
||||
<button
|
||||
type="button"
|
||||
@click.prevent="showAboutDialog"
|
||||
class="about control"
|
||||
title="About Koel"
|
||||
data-testid="about-btn"
|
||||
>
|
||||
<span v-if="shouldDisplayVersionUpdate && hasNewVersion" class="new-version" data-test="new-version-available">
|
||||
{{ sharedState.latestVersion }} available!
|
||||
</span>
|
||||
|
@ -21,53 +27,28 @@
|
|||
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive } from 'vue'
|
||||
import compareVersions from 'compare-versions'
|
||||
import { eventBus, app } from '@/utils'
|
||||
import { app as appConfig, events } from '@/config'
|
||||
import { eventBus } from '@/utils'
|
||||
import { app as appConfig } from '@/config'
|
||||
import { sharedStore, userStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
SearchForm: () => import('@/components/ui/search-form.vue'),
|
||||
UserBadge: () => import('@/components/user/badge.vue')
|
||||
},
|
||||
const SearchForm = defineAsyncComponent(() => import('@/components/ui/search-form.vue'))
|
||||
const UserBadge = defineAsyncComponent(() => import('@/components/user/badge.vue'))
|
||||
|
||||
data: () => ({
|
||||
appName: appConfig.name,
|
||||
userState: userStore.state,
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const userState = reactive(userStore.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
computed: {
|
||||
shouldDisplayVersionUpdate (): boolean {
|
||||
return this.userState.current.is_admin
|
||||
},
|
||||
const shouldDisplayVersionUpdate = computed(() => userState.current.is_admin)
|
||||
|
||||
hasNewVersion (): boolean {
|
||||
return compareVersions.compare(this.sharedState.latestVersion, this.sharedState.currentVersion, '>')
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleSidebar: (): void => {
|
||||
eventBus.emit('TOGGLE_SIDEBAR')
|
||||
},
|
||||
|
||||
toggleSearchForm: (): void => {
|
||||
eventBus.emit('TOGGLE_SEARCH_FORM')
|
||||
},
|
||||
|
||||
triggerMaximize: (): void => {
|
||||
app.triggerMaximize()
|
||||
},
|
||||
|
||||
showAboutDialog: (): void => {
|
||||
eventBus.emit('MODAL_SHOW_ABOUT_DIALOG')
|
||||
}
|
||||
}
|
||||
const hasNewVersion = computed(() => {
|
||||
return compareVersions.compare(sharedState.latestVersion, sharedState.currentVersion, '>')
|
||||
})
|
||||
|
||||
const toggleSidebar = () => eventBus.emit('TOGGLE_SIDEBAR')
|
||||
const toggleSearchForm = () => eventBus.emit('TOGGLE_SEARCH_FORM')
|
||||
const showAboutDialog = () => eventBus.emit('MODAL_SHOW_ABOUT_DIALOG')
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
tabindex="0"
|
||||
v-show="currentTab === 'Lyrics'"
|
||||
>
|
||||
<lyrics-pane :song="song" />
|
||||
<lyrics-pane :song="song"/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -60,7 +60,7 @@
|
|||
tabindex="0"
|
||||
v-show="currentTab === 'Artist'"
|
||||
>
|
||||
<artist-info v-if="artist" :artist="artist" mode="sidebar"/>
|
||||
<ArtistInfo v-if="artist" :artist="artist" mode="sidebar"/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -70,7 +70,7 @@
|
|||
tabindex="0"
|
||||
v-show="currentTab === 'Album'"
|
||||
>
|
||||
<album-info v-if="album" :album="album" mode="sidebar"/>
|
||||
<AlbumInfo v-if="album" :album="album" mode="sidebar"/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -80,94 +80,66 @@
|
|||
tabindex="0"
|
||||
v-show="currentTab === 'YouTube'"
|
||||
>
|
||||
<you-tube-video-list v-if="sharedState.useYouTube && song" :song="song"/>
|
||||
<YouTubeVideoList v-if="sharedState.useYouTube && song" :song="song"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import Vue from 'vue'
|
||||
import { eventBus, $ } from '@/utils'
|
||||
import { sharedStore, songStore, preferenceStore as preferences } from '@/stores'
|
||||
import { computed, defineAsyncComponent, reactive, ref, watch } from 'vue'
|
||||
import { $, eventBus } from '@/utils'
|
||||
import { preferenceStore as preferences, sharedStore, songStore } from '@/stores'
|
||||
import { songInfo } from '@/services'
|
||||
|
||||
type Tab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
|
||||
const defaultTab: Tab = 'Lyrics'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
LyricsPane: () => import('@/components/ui/lyrics-pane.vue'),
|
||||
ArtistInfo: () => import('@/components/artist/info.vue'),
|
||||
AlbumInfo: () => import('@/components/album/info.vue'),
|
||||
YouTubeVideoList: () => import('@/components/ui/youtube-video-list.vue')
|
||||
},
|
||||
const LyricsPane = defineAsyncComponent(() => import('@/components/ui/lyrics-pane.vue'))
|
||||
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/info.vue'))
|
||||
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/info.vue'))
|
||||
const YouTubeVideoList = defineAsyncComponent(() => import('@/components/ui/youtube-video-list.vue'))
|
||||
|
||||
data: () => ({
|
||||
song: null as Song | null,
|
||||
state: preferences.state,
|
||||
sharedState: sharedStore.state,
|
||||
currentTab: defaultTab
|
||||
}),
|
||||
const song = ref<Song | null>(null)
|
||||
const state = reactive(preferences.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
const currentTab = ref(defaultTab)
|
||||
|
||||
computed: {
|
||||
artist (): Artist | null {
|
||||
return this.song ? this.song.artist : null
|
||||
},
|
||||
const artist = computed(() => song.value?.artist)
|
||||
const album = computed(() => song.value?.album)
|
||||
|
||||
album (): Album | null {
|
||||
return this.song ? this.song.album : null
|
||||
}
|
||||
},
|
||||
watch(() => state.showExtraPanel, (showingExtraPanel) => {
|
||||
if (showingExtraPanel && !isMobile.any) {
|
||||
$.addClass(document.documentElement, 'with-extra-panel')
|
||||
} else {
|
||||
$.removeClass(document.documentElement, 'with-extra-panel')
|
||||
}
|
||||
})
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Watch the "showExtraPanel" property to add/remove the corresponding class
|
||||
* to/from the html tag.
|
||||
* Some element's CSS can then be controlled based on this class.
|
||||
*/
|
||||
'state.showExtraPanel': (showingExtraPanel: boolean): void => {
|
||||
if (showingExtraPanel && !isMobile.any) {
|
||||
$.addClass(document.documentElement, 'with-extra-panel')
|
||||
} else {
|
||||
$.removeClass(document.documentElement, 'with-extra-panel')
|
||||
}
|
||||
}
|
||||
},
|
||||
const resetState = () => {
|
||||
currentTab.value = defaultTab
|
||||
song.value = songStore.stub
|
||||
}
|
||||
|
||||
methods: {
|
||||
resetState (): void {
|
||||
this.currentTab = defaultTab
|
||||
this.song = songStore.stub
|
||||
},
|
||||
const fetchSongInfo = async (_song: Song) =>{
|
||||
try {
|
||||
song.value = await songInfo.fetch(_song)
|
||||
} catch (err) {
|
||||
song.value = _song
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async fetchSongInfo (song: Song): Promise<void> {
|
||||
try {
|
||||
this.song = await songInfo.fetch(song)
|
||||
} catch (err) {
|
||||
this.song = song
|
||||
throw err
|
||||
}
|
||||
}
|
||||
},
|
||||
eventBus.on({
|
||||
'SONG_STARTED': async (song: Song): Promise<void> => await fetchSongInfo(song),
|
||||
'LOAD_MAIN_CONTENT': (): void => {
|
||||
// On ready, add 'with-extra-panel' class.
|
||||
isMobile.any || $.addClass(document.documentElement, 'with-extra-panel')
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'SONG_STARTED': async (song: Song): Promise<void> => await this.fetchSongInfo(song),
|
||||
'LOAD_MAIN_CONTENT': (): void => {
|
||||
// On ready, add 'with-extra-panel' class.
|
||||
if (!isMobile.any) {
|
||||
$.addClass(document.documentElement, 'with-extra-panel')
|
||||
}
|
||||
|
||||
// Hide the extra panel if on mobile
|
||||
if (isMobile.phone) {
|
||||
this.state.showExtraPanel = false
|
||||
}
|
||||
}
|
||||
})
|
||||
// Hide the extra panel if on mobile
|
||||
isMobile.phone && (state.showExtraPanel = false)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@ -198,7 +170,7 @@ export default Vue.extend({
|
|||
line-height: 2.8rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 1024px) {
|
||||
@media only screen and (max-width: 1024px) {
|
||||
position: fixed;
|
||||
height: calc(100vh - var(--header-height));
|
||||
width: var(--extra-panel-width);
|
||||
|
@ -212,7 +184,7 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 667px) {
|
||||
@media only screen and (max-width: 667px) {
|
||||
@include themed-background();
|
||||
|
||||
width: 100%;
|
||||
|
|
|
@ -1,23 +1,19 @@
|
|||
<template>
|
||||
<div id="mainWrapper">
|
||||
<sidebar/>
|
||||
<main-content/>
|
||||
<extra-panel/>
|
||||
<modal-wrapper/>
|
||||
<Sidebar/>
|
||||
<MainContent/>
|
||||
<ExtraPanel/>
|
||||
<ModalWrapper/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Sidebar: () => import('@/components/layout/main-wrapper/sidebar.vue'),
|
||||
MainContent: () => import('@/components/layout/main-wrapper/main-content.vue'),
|
||||
ExtraPanel: () => import('@/components/layout/main-wrapper/extra-panel.vue'),
|
||||
ModalWrapper: () => import('@/components/layout/modal-wrapper.vue')
|
||||
}
|
||||
})
|
||||
const Sidebar = defineAsyncComponent(() => import('@/components/layout/main-wrapper/sidebar.vue'))
|
||||
const MainContent = defineAsyncComponent(() => import('@/components/layout/main-wrapper/main-content.vue'))
|
||||
const ExtraPanel = defineAsyncComponent(() => import('@/components/layout/main-wrapper/extra-panel.vue'))
|
||||
const ModalWrapper = defineAsyncComponent(() => import('@/components/layout/modal-wrapper.vue'))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -3,34 +3,34 @@
|
|||
<!--
|
||||
Most of the views are render-expensive and have their own UI states (viewport/scroll position), e.g. the song
|
||||
lists), so we use v-show.
|
||||
For those that don't need to maintain their own UI state, we use v-if and enjoy some codesplitting juice.
|
||||
For those that don't need to maintain their own UI state, we use v-if and enjoy some code-splitting juice.
|
||||
-->
|
||||
<visualizer v-if="showingVisualizer"/>
|
||||
<album-art-overlay :song="currentSong" v-if="preferences.showAlbumArtOverlay"/>
|
||||
<Visualizer v-if="showingVisualizer"/>
|
||||
<AlbumArtOverlay :song="currentSong" v-if="preferences.showAlbumArtOverlay"/>
|
||||
|
||||
<home-screen v-show="view === 'Home'"/>
|
||||
<queue-screen v-show="view === 'Queue'"/>
|
||||
<all-songs-screen v-show="view === 'Songs'"/>
|
||||
<album-list-screen v-show="view === 'Albums'"/>
|
||||
<artist-list-screen v-show="view === 'Artists'"/>
|
||||
<playlist-screen v-show="view === 'Playlist'"/>
|
||||
<favorites-screen v-show="view === 'Favorites'"/>
|
||||
<recently-played-screen v-show="view === 'RecentlyPlayed'"/>
|
||||
<upload-screen v-show="view === 'Upload'"/>
|
||||
<search-excerpts-screen v-show="view === 'Search.Excerpt'"/>
|
||||
<HomeScreen v-show="view === 'Home'"/>
|
||||
<QueueScreen v-show="view === 'Queue'"/>
|
||||
<AllSongsScreen v-show="view === 'Songs'"/>
|
||||
<AlbumListScreen v-show="view === 'Albums'"/>
|
||||
<ArtistListScreen v-show="view === 'Artists'"/>
|
||||
<PlaylistScreen v-show="view === 'Playlist'"/>
|
||||
<FavoritesScreen v-show="view === 'Favorites'"/>
|
||||
<RecentlyPlayedScreen v-show="view === 'RecentlyPlayed'"/>
|
||||
<UploadScreen v-show="view === 'Upload'"/>
|
||||
<SearchExcerptsScreen v-show="view === 'Search.Excerpt'"/>
|
||||
|
||||
<search-song-results-screen v-if="view === 'Search.Songs'" :q="screenProps" />
|
||||
<album-screen v-if="view === 'Album'" :album="screenProps"/>
|
||||
<artist-screen v-if="view === 'Artist'" :artist="screenProps"/>
|
||||
<settings-screen v-if="view === 'Settings'"/>
|
||||
<profile-screen v-if="view === 'Profile'"/>
|
||||
<user-list-screen v-if="view === 'Users'"/>
|
||||
<youtube-screen v-if="sharedState.useYouTube" v-show="view === 'YouTube'"/>
|
||||
<SearchSongResultsScreen v-if="view === 'Search.Songs'" :q="screenProps"/>
|
||||
<AlbumScreen v-if="view === 'Album'" :album="screenProps"/>
|
||||
<ArtistScreen v-if="view === 'Artist'" :artist="screenProps"/>
|
||||
<SettingsScreen v-if="view === 'Settings'"/>
|
||||
<ProfileScreen v-if="view === 'Profile'"/>
|
||||
<UserListScreen v-if="view === 'Users'"/>
|
||||
<YoutubeScreen v-if="sharedState.useYouTube" v-show="view === 'YouTube'"/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { preferenceStore, sharedStore } from '@/stores'
|
||||
import HomeScreen from '@/components/screens/home.vue'
|
||||
|
@ -41,54 +41,34 @@ import AllSongsScreen from '@/components/screens/all-songs.vue'
|
|||
import PlaylistScreen from '@/components/screens/playlist.vue'
|
||||
import FavoritesScreen from '@/components/screens/favorites.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
HomeScreen,
|
||||
QueueScreen,
|
||||
AllSongsScreen,
|
||||
AlbumListScreen,
|
||||
ArtistListScreen,
|
||||
PlaylistScreen,
|
||||
FavoritesScreen,
|
||||
RecentlyPlayedScreen: () => import('@/components/screens/recently-played.vue'),
|
||||
UserListScreen: () => import('@/components/screens/user-list.vue'),
|
||||
AlbumArtOverlay: () => import('@/components/ui/album-art-overlay.vue'),
|
||||
AlbumScreen: () => import('@/components/screens/album.vue'),
|
||||
ArtistScreen: () => import('@/components/screens/artist.vue'),
|
||||
SettingsScreen: () => import('@/components/screens/settings.vue'),
|
||||
ProfileScreen: () => import('@/components/screens/profile.vue'),
|
||||
YoutubeScreen: () => import('@/components/screens/youtube.vue'),
|
||||
UploadScreen: () => import('@/components/screens/upload.vue'),
|
||||
SearchExcerptsScreen: () => import('@/components/screens/search/excerpts.vue'),
|
||||
SearchSongResultsScreen: () => import('@/components/screens/search/song-results.vue'),
|
||||
Visualizer: () => import('@/components/ui/visualizer.vue')
|
||||
const RecentlyPlayedScreen = defineAsyncComponent(() => import('@/components/screens/recently-played.vue'))
|
||||
const UserListScreen = defineAsyncComponent(() => import('@/components/screens/user-list.vue'))
|
||||
const AlbumArtOverlay = defineAsyncComponent(() => import('@/components/ui/album-art-overlay.vue'))
|
||||
const AlbumScreen = defineAsyncComponent(() => import('@/components/screens/album.vue'))
|
||||
const ArtistScreen = defineAsyncComponent(() => import('@/components/screens/artist.vue'))
|
||||
const SettingsScreen = defineAsyncComponent(() => import('@/components/screens/settings.vue'))
|
||||
const ProfileScreen = defineAsyncComponent(() => import('@/components/screens/profile.vue'))
|
||||
const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/youtube.vue'))
|
||||
const UploadScreen = defineAsyncComponent(() => import('@/components/screens/upload.vue'))
|
||||
const SearchExcerptsScreen = defineAsyncComponent(() => import('@/components/screens/search/excerpts.vue'))
|
||||
const SearchSongResultsScreen = defineAsyncComponent(() => import('@/components/screens/search/song-results.vue'))
|
||||
const Visualizer = defineAsyncComponent(() => import('@/components/ui/visualizer.vue'))
|
||||
|
||||
const preferences = reactive(preferenceStore.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
const showingVisualizer = ref(false)
|
||||
const screenProps = ref<any>(null)
|
||||
const view = ref<MainViewName>('Home')
|
||||
const currentSong = ref<Song | null>(null)
|
||||
|
||||
eventBus.on({
|
||||
LOAD_MAIN_CONTENT (_view: MainViewName, data: any) {
|
||||
screenProps.value = data
|
||||
view.value = _view
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
preferences: preferenceStore.state,
|
||||
sharedState: sharedStore.state,
|
||||
showingVisualizer: false,
|
||||
screenProps: null,
|
||||
view: 'Home' as MainViewName,
|
||||
currentSong: null as Song | null
|
||||
}),
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'LOAD_MAIN_CONTENT': (view: MainViewName, data: any): void => {
|
||||
this.screenProps = data
|
||||
this.view = view
|
||||
},
|
||||
|
||||
'TOGGLE_VISUALIZER': (): void => {
|
||||
this.showingVisualizer = !this.showingVisualizer
|
||||
},
|
||||
|
||||
'SONG_STARTED': (song: Song): void => {
|
||||
this.currentSong = song
|
||||
}
|
||||
})
|
||||
}
|
||||
'TOGGLE_VISUALIZER': () => (showingVisualizer.value = !showingVisualizer.value),
|
||||
'SONG_STARTED': (song: Song) => (currentSong.value = song)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
<playlist-list :current-view="currentView"/>
|
||||
<PlaylistList :current-view="currentView"/>
|
||||
|
||||
<section v-if="userState.current.is_admin" class="manage">
|
||||
<h1>Manage</h1>
|
||||
|
@ -53,69 +53,44 @@
|
|||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue'
|
||||
import isMobile from 'ismobilejs'
|
||||
|
||||
import { eventBus } from '@/utils'
|
||||
import { sharedStore, userStore, songStore, queueStore } from '@/stores'
|
||||
import { queueStore, sharedStore, songStore, userStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
PlaylistList: () => import('@/components/playlist/sidebar-list.vue')
|
||||
},
|
||||
const PlaylistList = defineAsyncComponent(() => import('@/components/playlist/sidebar-list.vue'))
|
||||
|
||||
data: () => ({
|
||||
currentView: 'Home',
|
||||
userState: userStore.state,
|
||||
showing: !isMobile.phone,
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const currentView = ref<MainViewName>('Home')
|
||||
const userState = reactive(userStore.state)
|
||||
const showing = ref(!isMobile.phone)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Handle songs dropped to our Queue menu item.
|
||||
*/
|
||||
handleDrop: (e: DragEvent): boolean => {
|
||||
if (!e.dataTransfer) {
|
||||
return false
|
||||
}
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.getData('application/x-koel.text+plain')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!e.dataTransfer.getData('application/x-koel.text+plain')) {
|
||||
return false
|
||||
}
|
||||
const songs = songStore.byIds(event.dataTransfer.getData('application/x-koel.text+plain').split(','))
|
||||
songs.length && queueStore.queue(songs)
|
||||
|
||||
const songs = songStore.byIds(e.dataTransfer.getData('application/x-koel.text+plain').split(','))
|
||||
return false
|
||||
}
|
||||
|
||||
if (!songs.length) {
|
||||
return false
|
||||
}
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName): void => {
|
||||
currentView.value = view
|
||||
|
||||
queueStore.queue(songs)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName): void => {
|
||||
this.currentView = view
|
||||
|
||||
// Hide the sidebar if on mobile
|
||||
if (isMobile.phone) {
|
||||
this.showing = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Listen to sidebar:toggle event to show or hide the sidebar.
|
||||
* This should only be triggered on a mobile device.
|
||||
*/
|
||||
eventBus.on('TOGGLE_SIDEBAR', (): void => {
|
||||
this.showing = !this.showing
|
||||
})
|
||||
// Hide the sidebar if on mobile
|
||||
if (isMobile.phone) {
|
||||
showing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Listen to sidebar:toggle event to show or hide the sidebar.
|
||||
* This should only be triggered on a mobile device.
|
||||
*/
|
||||
eventBus.on('TOGGLE_SIDEBAR', () => (showing.value = !showing.value))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -226,7 +201,7 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 667px) {
|
||||
@media only screen and (max-width: 667px) {
|
||||
@include themed-background();
|
||||
|
||||
position: fixed;
|
||||
|
|
|
@ -1,26 +1,26 @@
|
|||
<template>
|
||||
<div class="modal-wrapper" :class="{ overlay: showingModalName }">
|
||||
<create-smart-playlist-form v-if="showingModalName === 'create-smart-playlist-form'" @close="close"/>
|
||||
<edit-smart-playlist-form
|
||||
<CreateSmartPlaylistForm v-if="showingModalName === 'create-smart-playlist-form'" @close="close"/>
|
||||
<EditSmartPlaylistForm
|
||||
v-if="showingModalName === 'edit-smart-playlist-form'"
|
||||
@close="close"
|
||||
:playlist="boundData.playlist"
|
||||
/>
|
||||
<add-user-form v-if="showingModalName === 'add-user-form'" @close="close"/>
|
||||
<edit-user-form v-if="showingModalName === 'edit-user-form'" :user="boundData.user" @close="close"/>
|
||||
<edit-song-form
|
||||
<AddUserForm v-if="showingModalName === 'add-user-form'" @close="close"/>
|
||||
<EditUserForm v-if="showingModalName === 'edit-user-form'" :user="boundData.user" @close="close"/>
|
||||
<EditSongForm
|
||||
:songs="boundData.songs"
|
||||
:initialTab="boundData.initialTab"
|
||||
@close="close"
|
||||
v-if="showingModalName === 'edit-song-form'"
|
||||
/>
|
||||
<about-dialog v-if="showingModalName === 'about-dialog'" @close="close"/>
|
||||
<AboutDialog v-if="showingModalName === 'about-dialog'" @close="close"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { eventBus } from '@/utils'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
interface ModalWrapperBoundData {
|
||||
playlist?: Playlist
|
||||
|
@ -37,58 +37,40 @@ declare type ModalName =
|
|||
| 'edit-song-form'
|
||||
| 'about-dialog'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
CreateSmartPlaylistForm: () => import('@/components/playlist/smart-playlist/create-form.vue'),
|
||||
EditSmartPlaylistForm: () => import('@/components/playlist/smart-playlist/edit-form.vue'),
|
||||
AddUserForm: () => import('@/components/user/add-form.vue'),
|
||||
EditUserForm: () => import('@/components/user/edit-form.vue'),
|
||||
EditSongForm: () => import('@/components/song/edit-form.vue'),
|
||||
AboutDialog: () => import('@/components/meta/about-dialog.vue')
|
||||
const CreateSmartPlaylistForm = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/create-form.vue'))
|
||||
const EditSmartPlaylistForm = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/edit-form.vue'))
|
||||
const AddUserForm = defineAsyncComponent(() => import('@/components/user/add-form.vue'))
|
||||
const EditUserForm = defineAsyncComponent(() => import('@/components/user/edit-form.vue'))
|
||||
const EditSongForm = defineAsyncComponent(() => import('@/components/song/edit-form.vue'))
|
||||
const AboutDialog = defineAsyncComponent(() => import('@/components/meta/about-dialog.vue'))
|
||||
|
||||
const showingModalName = ref<ModalName | null>(null)
|
||||
const boundData = ref<ModalWrapperBoundData>({})
|
||||
|
||||
const close = () => {
|
||||
showingModalName.value = null
|
||||
boundData.value = {}
|
||||
}
|
||||
|
||||
eventBus.on({
|
||||
'MODAL_SHOW_ABOUT_DIALOG': () => (showingModalName.value = 'about-dialog'),
|
||||
'MODAL_SHOW_ADD_USER_FORM': () => (showingModalName.value = 'add-user-form'),
|
||||
'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM': () => (showingModalName.value = 'create-smart-playlist-form'),
|
||||
|
||||
'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM': (playlist: Playlist) => {
|
||||
boundData.value.playlist = playlist
|
||||
showingModalName.value = 'edit-smart-playlist-form'
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
showingModalName: null as ModalName | null,
|
||||
boundData: {} as ModalWrapperBoundData
|
||||
}),
|
||||
|
||||
methods: {
|
||||
close (): void {
|
||||
this.showingModalName = null
|
||||
this.boundData = {}
|
||||
}
|
||||
'MODAL_SHOW_EDIT_USER_FORM': (user: User) => {
|
||||
boundData.value.user = user
|
||||
showingModalName.value = 'edit-user-form'
|
||||
},
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM': (): void => {
|
||||
this.showingModalName = 'create-smart-playlist-form'
|
||||
},
|
||||
|
||||
'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM': (playlist: Playlist): void => {
|
||||
this.boundData.playlist = playlist
|
||||
this.showingModalName = 'edit-smart-playlist-form'
|
||||
},
|
||||
|
||||
'MODAL_SHOW_ADD_USER_FORM': (): void => {
|
||||
this.showingModalName = 'add-user-form'
|
||||
},
|
||||
|
||||
'MODAL_SHOW_EDIT_USER_FORM': (user: User): void => {
|
||||
this.boundData.user = user
|
||||
this.showingModalName = 'edit-user-form'
|
||||
},
|
||||
|
||||
'MODAL_SHOW_EDIT_SONG_FORM': (songs: Song[], initialTab: string = 'details'): void => {
|
||||
this.boundData.songs = songs
|
||||
this.boundData.initialTab = initialTab
|
||||
this.showingModalName = 'edit-song-form'
|
||||
},
|
||||
|
||||
'MODAL_SHOW_ABOUT_DIALOG': (): void => {
|
||||
this.showingModalName = 'about-dialog'
|
||||
}
|
||||
})
|
||||
'MODAL_SHOW_EDIT_SONG_FORM': (songs: Song[], initialTab: string = 'details'): void => {
|
||||
boundData.value.songs = songs
|
||||
boundData.value.initialTab = initialTab
|
||||
showingModalName.value = 'edit-song-form'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
<p v-if="shouldDisplayVersionUpdate && hasNewVersion" class="new-version">
|
||||
<a :href="latestVersionUrl" target="_blank">
|
||||
A new Koel version is available ({{ sharedState.latestVersion }}).
|
||||
A new Koel version is available ({{ sharedState.latestVersion }}).
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
@ -39,47 +39,31 @@
|
|||
</main>
|
||||
|
||||
<footer>
|
||||
<btn @click.prevent="close" red rounded data-test="close-modal-btn">Close</btn>
|
||||
<Btn @click.prevent="close" red rounded data-test="close-modal-btn">Close</Btn>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive } from 'vue'
|
||||
import compareVersions from 'compare-versions'
|
||||
import { sharedStore, userStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
userState: userStore.state,
|
||||
sharedState: sharedStore.state,
|
||||
demo: NODE_ENV === 'demo'
|
||||
}),
|
||||
const userState = reactive(userStore.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
const demo = NODE_ENV === 'demo'
|
||||
|
||||
computed: {
|
||||
latestVersionUrl (): string {
|
||||
return `https://github.com/phanan/koel/releases/tag/${this.sharedState.latestVersion}`
|
||||
},
|
||||
const latestVersionUrl = computed(() => `https://github.com/phanan/koel/releases/tag/${sharedState.latestVersion}`)
|
||||
const shouldDisplayVersionUpdate = computed(() => userState.current.is_admin)
|
||||
|
||||
shouldDisplayVersionUpdate (): boolean {
|
||||
return this.userState.current.is_admin
|
||||
},
|
||||
|
||||
hasNewVersion (): boolean {
|
||||
return compareVersions.compare(this.sharedState.latestVersion, this.sharedState.currentVersion, '>')
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
close (): void {
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
const hasNewVersion = computed(() => {
|
||||
return compareVersions.compare(sharedState.latestVersion, sharedState.currentVersion, '>')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const close = () => emit('close')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -14,52 +14,34 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import Vue from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { preferenceStore as preferences } from '@/stores'
|
||||
|
||||
const DELAY_UNTIL_SHOWN = 30 * 60 * 1000
|
||||
let SUPPORT_BAR_TIMEOUT_HANDLE = 0
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
shown: false
|
||||
}),
|
||||
const shown = ref(false)
|
||||
|
||||
computed: {
|
||||
canNag (): boolean {
|
||||
return !isMobile.any && !preferences.supportBarNoBugging
|
||||
}
|
||||
},
|
||||
const canNag = computed(() => !isMobile.any && !preferences.supportBarNoBugging)
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'KOEL_READY': (): void => {
|
||||
if (this.canNag) {
|
||||
this.setUpShowBarTimeout()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
const setUpShowBarTimeout = () => {
|
||||
SUPPORT_BAR_TIMEOUT_HANDLE = window.setTimeout(() => (shown.value = true), DELAY_UNTIL_SHOWN)
|
||||
}
|
||||
|
||||
methods: {
|
||||
setUpShowBarTimeout (): void {
|
||||
SUPPORT_BAR_TIMEOUT_HANDLE = window.setTimeout(() => (this.shown = true), DELAY_UNTIL_SHOWN)
|
||||
},
|
||||
const close = () => {
|
||||
shown.value = false
|
||||
window.clearTimeout(SUPPORT_BAR_TIMEOUT_HANDLE)
|
||||
}
|
||||
|
||||
close (): void {
|
||||
this.shown = false
|
||||
window.clearTimeout(SUPPORT_BAR_TIMEOUT_HANDLE)
|
||||
},
|
||||
const stopBugging = () => {
|
||||
preferences.supportBarNoBugging = true
|
||||
close()
|
||||
}
|
||||
|
||||
stopBugging (): void {
|
||||
preferences.supportBarNoBugging = true
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
eventBus.on('KOEL_READY', () => canNag.value && setUpShowBarTimeout())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,38 +1,25 @@
|
|||
<template>
|
||||
<base-context-menu extra-class="playlist-menu" ref="base">
|
||||
<BaseContextMenu extra-class="playlist-menu" ref="base">
|
||||
<li @click="createPlaylist" data-testid="playlist-context-menu-create-simple">New Playlist</li>
|
||||
<li @click="createSmartPlaylist" data-testid="playlist-context-menu-create-smart">New Smart Playlist</li>
|
||||
</base-context-menu>
|
||||
</BaseContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { BasePlaylistMenu } from 'koel/types/ui'
|
||||
<script lang="ts" setup>
|
||||
import { eventBus } from '@/utils'
|
||||
import { useContextMenu } from '@/composables'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
BaseContextMenu: () => import('@/components/ui/context-menu.vue')
|
||||
},
|
||||
const { base, BaseContextMenu, open, close } = useContextMenu()
|
||||
|
||||
methods: {
|
||||
open (top: number, left: number): void {
|
||||
(this.$refs.base as BasePlaylistMenu).open(top, left)
|
||||
},
|
||||
const emit = defineEmits(['createPlaylist'])
|
||||
|
||||
close (): void {
|
||||
(this.$refs.base as BasePlaylistMenu).close()
|
||||
},
|
||||
const createPlaylist = () => {
|
||||
emit('createPlaylist')
|
||||
close()
|
||||
}
|
||||
|
||||
createPlaylist (): void {
|
||||
this.$emit('createPlaylist')
|
||||
this.close()
|
||||
},
|
||||
|
||||
createSmartPlaylist (): void {
|
||||
eventBus.emit('MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM')
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
const createSmartPlaylist = () => {
|
||||
eventBus.emit('MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM')
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,50 +1,29 @@
|
|||
<template>
|
||||
<base-context-menu extra-class="playlist-item-menu" ref="base">
|
||||
<BaseContextMenu extra-class="playlist-item-menu" ref="base">
|
||||
<li @click="editPlaylist" :data-testid="`playlist-context-menu-edit-${playlist.id}`">Edit</li>
|
||||
<li @click="deletePlaylist" :data-testid="`playlist-context-menu-delete-${playlist.id}`">Delete</li>
|
||||
</base-context-menu>
|
||||
</BaseContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
import { BasePlaylistMenu } from 'koel/types/ui'
|
||||
<script lang="ts" setup>
|
||||
import { toRefs } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { useContextMenu } from '@/composables'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
BaseContextMenu: () => import('@/components/ui/context-menu.vue')
|
||||
},
|
||||
const { base, BaseContextMenu, open, close } = useContextMenu()
|
||||
|
||||
props: {
|
||||
playlist: {
|
||||
required: true,
|
||||
type: Object
|
||||
} as PropOptions<Playlist>
|
||||
},
|
||||
const props = defineProps<{ playlist: Playlist }>()
|
||||
const { playlist } = toRefs(props)
|
||||
|
||||
methods: {
|
||||
open (top: number, left: number): void {
|
||||
(this.$refs.base as BasePlaylistMenu).open(top, left)
|
||||
},
|
||||
const emit = defineEmits(['edit'])
|
||||
|
||||
close (): void {
|
||||
(this.$refs.base as BasePlaylistMenu).close()
|
||||
},
|
||||
const editPlaylist = () => {
|
||||
playlist.value.is_smart ? eventBus.emit('MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', playlist.value) : emit('edit')
|
||||
close()
|
||||
}
|
||||
|
||||
editPlaylist (): void {
|
||||
if (this.playlist.is_smart) {
|
||||
eventBus.emit('MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', this.playlist)
|
||||
} else {
|
||||
this.$emit('edit')
|
||||
}
|
||||
|
||||
this.close()
|
||||
},
|
||||
|
||||
deletePlaylist (): void {
|
||||
eventBus.emit('PLAYLIST_DELETE', this.playlist)
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
const deletePlaylist = () => {
|
||||
eventBus.emit('PLAYLIST_DELETE', playlist.value)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -12,55 +12,42 @@
|
|||
>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, toRefs } from 'vue'
|
||||
import { playlistStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
playlist: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Playlist>
|
||||
},
|
||||
const props = defineProps<{ playlist: Playlist }>()
|
||||
const { playlist } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
mutatedPlaylist: null as unknown as Playlist,
|
||||
updating: false
|
||||
}),
|
||||
const updating = ref(false)
|
||||
|
||||
methods: {
|
||||
async update (): Promise<void> {
|
||||
this.mutatedPlaylist.name = this.mutatedPlaylist.name.trim()
|
||||
const mutatedPlaylist = reactive<Playlist>(Object.assign({}, playlist.value))
|
||||
|
||||
if (!this.mutatedPlaylist.name) {
|
||||
this.cancel()
|
||||
return
|
||||
}
|
||||
const emit = defineEmits(['updated', 'cancelled'])
|
||||
|
||||
if (this.mutatedPlaylist.name === this.playlist.name) {
|
||||
this.cancel()
|
||||
return
|
||||
}
|
||||
const update = async () => {
|
||||
mutatedPlaylist.name = mutatedPlaylist.name.trim()
|
||||
|
||||
// prevent duplicate updating from Enter and Blur
|
||||
if (this.updating) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updating = true
|
||||
|
||||
await playlistStore.update(this.mutatedPlaylist)
|
||||
this.$emit('updated', this.mutatedPlaylist)
|
||||
},
|
||||
|
||||
cancel (): void {
|
||||
this.$emit('cancelled')
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
this.mutatedPlaylist = Object.assign({}, this.playlist)
|
||||
if (!mutatedPlaylist.name) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if (mutatedPlaylist.name === playlist.value.name) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
// prevent duplicate updating from Enter and Blur
|
||||
if (updating.value) {
|
||||
return
|
||||
}
|
||||
|
||||
updating.value = true
|
||||
|
||||
await playlistStore.update(mutatedPlaylist)
|
||||
emit('updated', mutatedPlaylist)
|
||||
}
|
||||
|
||||
const cancel = () => emit('cancelled')
|
||||
</script>
|
||||
|
|
|
@ -9,14 +9,14 @@
|
|||
v-koel-droppable="handleDrop"
|
||||
>{{ playlist.name }}</a>
|
||||
|
||||
<name-editor
|
||||
<NameEditor
|
||||
:playlist="playlist"
|
||||
@cancelled="cancelEditing"
|
||||
@updated="onPlaylistNameUpdated"
|
||||
v-if="nameEditable && editing"
|
||||
/>
|
||||
|
||||
<context-menu
|
||||
<ContextMenu
|
||||
v-if="hasContextMenu"
|
||||
v-show="showingContextMenu"
|
||||
:playlist="playlist"
|
||||
|
@ -26,149 +26,122 @@
|
|||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, nextTick, ref, toRefs } from 'vue'
|
||||
import { BaseContextMenu } from 'koel/types/ui'
|
||||
import { eventBus } from '@/utils'
|
||||
import router from '@/router'
|
||||
import { songStore, playlistStore, favoriteStore } from '@/stores'
|
||||
import { favoriteStore, playlistStore, songStore } from '@/stores'
|
||||
|
||||
const VALID_PLAYLIST_TYPES = ['playlist', 'favorites', 'recently-played']
|
||||
type PlaylistType = 'playlist' | 'favorites' | 'recently-played'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ContextMenu: () => import('@/components/playlist/item-context-menu.vue'),
|
||||
NameEditor: () => import('@/components/playlist/name-editor.vue')
|
||||
},
|
||||
const ContextMenu = defineAsyncComponent(() => import('@/components/playlist/item-context-menu.vue'))
|
||||
const NameEditor = defineAsyncComponent(() => import('@/components/playlist/name-editor.vue'))
|
||||
|
||||
props: {
|
||||
playlist: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Playlist>,
|
||||
const contextMenu = ref<BaseContextMenu | null>(null)
|
||||
|
||||
type: {
|
||||
type: String,
|
||||
default: 'playlist',
|
||||
validator: value => VALID_PLAYLIST_TYPES.includes(value)
|
||||
}
|
||||
},
|
||||
const props = withDefaults(defineProps<{ playlist: Playlist, type: PlaylistType }>(), { type: 'playlist' })
|
||||
const { playlist, type } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
editing: false,
|
||||
active: false,
|
||||
showingContextMenu: false
|
||||
}),
|
||||
const editing = ref(false)
|
||||
const active = ref(false)
|
||||
const showingContextMenu = ref(false)
|
||||
|
||||
computed: {
|
||||
url (): string {
|
||||
switch (this.type) {
|
||||
case 'playlist':
|
||||
return `#!/playlist/${this.playlist.id}`
|
||||
const url = computed(() => {
|
||||
switch (type.value) {
|
||||
case 'playlist':
|
||||
return `#!/playlist/${playlist.value.id}`
|
||||
|
||||
case 'favorites':
|
||||
return '#!/favorites'
|
||||
case 'favorites':
|
||||
return '#!/favorites'
|
||||
|
||||
case 'recently-played':
|
||||
return '#!/recently-played'
|
||||
case 'recently-played':
|
||||
return '#!/recently-played'
|
||||
|
||||
default:
|
||||
throw new Error('Invalid playlist type')
|
||||
}
|
||||
},
|
||||
default:
|
||||
throw new Error('Invalid playlist type')
|
||||
}
|
||||
})
|
||||
|
||||
nameEditable (): boolean {
|
||||
return this.type === 'playlist'
|
||||
},
|
||||
const nameEditable = computed(() => type.value === 'playlist')
|
||||
const hasContextMenu = computed(() => type.value === 'playlist')
|
||||
|
||||
contentEditable (): boolean {
|
||||
if (this.playlist.is_smart) {
|
||||
return false
|
||||
}
|
||||
const contentEditable = computed(() => {
|
||||
if (playlist.value.is_smart) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.type === 'playlist' || this.type === 'favorites'
|
||||
},
|
||||
return type.value === 'playlist' || type.value === 'favorites'
|
||||
})
|
||||
|
||||
hasContextMenu (): boolean {
|
||||
return this.type === 'playlist'
|
||||
}
|
||||
},
|
||||
const makeEditable = () => {
|
||||
if (!nameEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
methods: {
|
||||
makeEditable (): void {
|
||||
if (!this.nameEditable) {
|
||||
return
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
this.editing = true
|
||||
},
|
||||
/**
|
||||
* Handle songs dropped to our favorite or playlist menu item.
|
||||
*/
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (!contentEditable.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle songs dropped to our favorite or playlist menu item.
|
||||
*/
|
||||
handleDrop (e: DragEvent): boolean {
|
||||
if (!this.contentEditable) {
|
||||
return false
|
||||
}
|
||||
if (!event.dataTransfer?.getData('application/x-koel.text+plain')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!e.dataTransfer?.getData('application/x-koel.text+plain')) {
|
||||
return false
|
||||
}
|
||||
const songs = songStore.byIds(event.dataTransfer.getData('application/x-koel.text+plain').split(','))
|
||||
|
||||
const songs = songStore.byIds(e.dataTransfer.getData('application/x-koel.text+plain').split(','))
|
||||
if (!songs.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!songs.length) {
|
||||
return false
|
||||
}
|
||||
if (type.value === 'favorites') {
|
||||
favoriteStore.like(songs)
|
||||
} else if (type.value === 'playlist') {
|
||||
playlistStore.addSongs(playlist.value, songs)
|
||||
}
|
||||
|
||||
if (this.type === 'favorites') {
|
||||
favoriteStore.like(songs)
|
||||
} else if (this.type === 'playlist') {
|
||||
playlistStore.addSongs(this.playlist, songs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
const openContextMenu = async (event: MouseEvent) => {
|
||||
if (hasContextMenu.value) {
|
||||
showingContextMenu.value = true
|
||||
await nextTick()
|
||||
router.go(`/playlist/${playlist.value.id}`)
|
||||
contextMenu.value?.open(event.pageY, event.pageX)
|
||||
}
|
||||
}
|
||||
|
||||
async openContextMenu (event: MouseEvent) {
|
||||
if (this.hasContextMenu) {
|
||||
this.showingContextMenu = true
|
||||
await this.$nextTick()
|
||||
router.go(`/playlist/${this.playlist.id}`)
|
||||
;(this.$refs.contextMenu as BaseContextMenu).open(event.pageY, event.pageX)
|
||||
}
|
||||
},
|
||||
const cancelEditing = () => (editing.value = false)
|
||||
|
||||
cancelEditing (): void {
|
||||
this.editing = false
|
||||
},
|
||||
const onPlaylistNameUpdated = (mutatedPlaylist: Playlist) => {
|
||||
playlist.value.name = mutatedPlaylist.name
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
onPlaylistNameUpdated (mutatedPlaylist: Playlist): void {
|
||||
this.playlist.name = mutatedPlaylist.name
|
||||
this.editing = false
|
||||
}
|
||||
},
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void => {
|
||||
switch (view) {
|
||||
case 'Favorites':
|
||||
active.value = type.value === 'favorites'
|
||||
break
|
||||
|
||||
created (): void {
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, playlist: Playlist): void => {
|
||||
switch (view) {
|
||||
case 'Favorites':
|
||||
this.active = this.type === 'favorites'
|
||||
break
|
||||
case 'RecentlyPlayed':
|
||||
active.value = type.value === 'recently-played'
|
||||
|
||||
case 'RecentlyPlayed':
|
||||
this.active = this.type === 'recently-played'
|
||||
break
|
||||
case 'Playlist':
|
||||
active.value = playlist.value === _playlist
|
||||
break
|
||||
|
||||
break
|
||||
case 'Playlist':
|
||||
this.active = this.playlist === playlist
|
||||
break
|
||||
|
||||
default:
|
||||
this.active = false
|
||||
break
|
||||
}
|
||||
})
|
||||
default:
|
||||
active.value = false
|
||||
break
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -24,9 +24,9 @@
|
|||
</form>
|
||||
|
||||
<ul>
|
||||
<playlist-item type="favorites" :playlist="{ name: 'Favorites', songs: favoriteState.songs }"/>
|
||||
<playlist-item type="recently-played" :playlist="{ name: 'Recently Played', songs: [] }"/>
|
||||
<playlist-item
|
||||
<PlaylistItem type="favorites" :playlist="{ name: 'Favorites', songs: favoriteState.songs }"/>
|
||||
<PlaylistItem type="recently-played" :playlist="{ name: 'Recently Played', songs: [] }"/>
|
||||
<PlaylistItem
|
||||
:playlist="playlist"
|
||||
:key="playlist.id"
|
||||
type="playlist"
|
||||
|
@ -34,51 +34,44 @@
|
|||
/>
|
||||
</ul>
|
||||
|
||||
<context-menu ref="contextMenu" @createPlaylist="creating = true"/>
|
||||
<ContextMenu ref="contextMenu" @createPlaylist="creating = true"/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, nextTick, reactive, ref } from 'vue'
|
||||
import { BaseContextMenu } from 'koel/types/ui'
|
||||
import { playlistStore, favoriteStore, recentlyPlayedStore } from '@/stores'
|
||||
import { favoriteStore, playlistStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
PlaylistItem: () => import('@/components/playlist/sidebar-item.vue'),
|
||||
ContextMenu: () => import('@/components/playlist/create-new-context-menu.vue')
|
||||
},
|
||||
const PlaylistItem = defineAsyncComponent(() => import('@/components/playlist/sidebar-item.vue'))
|
||||
const ContextMenu = defineAsyncComponent(() => import('@/components/playlist/create-new-context-menu.vue'))
|
||||
|
||||
data: () => ({
|
||||
playlistState: playlistStore.state,
|
||||
favoriteState: favoriteStore.state,
|
||||
recentlyPlayedState: recentlyPlayedStore.state,
|
||||
creating: false,
|
||||
newName: ''
|
||||
}),
|
||||
const contextMenu = ref<BaseContextMenu | null>(null)
|
||||
|
||||
methods: {
|
||||
async createPlaylist (): Promise<void> {
|
||||
this.creating = false
|
||||
const playlistState = reactive(playlistStore.state)
|
||||
const favoriteState = reactive(favoriteStore.state)
|
||||
const creating = ref(false)
|
||||
const newName = ref('')
|
||||
|
||||
const playlist = await playlistStore.store(this.newName)
|
||||
this.newName = ''
|
||||
// Activate the new playlist right away
|
||||
this.$nextTick(() => router.go(`playlist/${playlist.id}`))
|
||||
},
|
||||
const createPlaylist = async () => {
|
||||
creating.value = false
|
||||
|
||||
toggleContextMenu (event: MouseEvent): void {
|
||||
this.$nextTick((): void => {
|
||||
if (this.creating) {
|
||||
this.creating = false
|
||||
} else {
|
||||
(this.$refs.contextMenu as BaseContextMenu).open(event.pageY, event.pageX)
|
||||
}
|
||||
})
|
||||
}
|
||||
const playlist = await playlistStore.store(newName.value)
|
||||
newName.value = ''
|
||||
// Activate the new playlist right away
|
||||
await nextTick()
|
||||
router.go(`playlist/${playlist.id}`)
|
||||
}
|
||||
|
||||
const toggleContextMenu = async (event: MouseEvent) => {
|
||||
await nextTick()
|
||||
if (creating) {
|
||||
creating.value = false
|
||||
} else {
|
||||
contextMenu.value?.open(event.pageY, event.pageX)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<form-base>
|
||||
<FormBase>
|
||||
<template slot="default">
|
||||
<div @keydown.esc="maybeClose">
|
||||
<sound-bar v-if="meta.loading"/>
|
||||
<SoundBar v-if="loading"/>
|
||||
<form @submit.prevent="submit" v-else data-testid="create-smart-playlist-form">
|
||||
<header>
|
||||
<h1>New Smart Playlist</h1>
|
||||
|
@ -15,87 +15,65 @@
|
|||
</div>
|
||||
|
||||
<div class="form-row rules">
|
||||
<rule-group
|
||||
<RuleGroup
|
||||
:group="group"
|
||||
:isFirstGroup="index === 0"
|
||||
:key="group.id"
|
||||
@input="onGroupChanged"
|
||||
v-for="(group, index) in ruleGroups"
|
||||
/>
|
||||
<btn @click.prevent="addGroup" class="btn-add-group" green small uppercase>
|
||||
<Btn @click.prevent="addGroup" class="btn-add-group" green small uppercase>
|
||||
<i class="fa fa-plus"></i> Group
|
||||
</btn>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<btn type="submit">Save</btn>
|
||||
<btn class="btn-cancel" @click.prevent="maybeClose" white>Cancel</btn>
|
||||
<Btn type="submit">Save</Btn>
|
||||
<Btn class="btn-cancel" @click.prevent="maybeClose" white>Cancel</Btn>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</form-base>
|
||||
</FormBase>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { playlistStore } from '@/stores'
|
||||
import { alerts } from '@/utils'
|
||||
import router from '@/router'
|
||||
import { useSmartPlaylistForms } from '@/components/playlist/smart-playlist/useSmartPlaylistForms'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
FormBase: () => import('@/components/playlist/smart-playlist/form-base.vue'),
|
||||
RuleGroup: () => import('@/components/playlist/smart-playlist/rule-group.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue')
|
||||
},
|
||||
const {
|
||||
Btn,
|
||||
FormBase,
|
||||
RuleGroup,
|
||||
SoundBar,
|
||||
ruleGroups,
|
||||
loading,
|
||||
addGroup,
|
||||
onGroupChanged,
|
||||
close
|
||||
} = useSmartPlaylistForms([playlistStore.createEmptySmartPlaylistRuleGroup()])
|
||||
|
||||
data: () => ({
|
||||
name: '',
|
||||
ruleGroups: [playlistStore.createEmptySmartPlaylistRuleGroup()] as SmartPlaylistRuleGroup[],
|
||||
meta: {
|
||||
loading: false
|
||||
}
|
||||
}),
|
||||
const name = ref('')
|
||||
|
||||
methods: {
|
||||
addGroup (): void {
|
||||
this.ruleGroups.push(this.createGroup())
|
||||
},
|
||||
|
||||
onGroupChanged (data: SmartPlaylistRuleGroup): void {
|
||||
const changedGroup = Object.assign(this.ruleGroups.find(g => g.id === data.id), data)
|
||||
|
||||
// Remove empty group
|
||||
if (changedGroup.rules.length === 0) {
|
||||
this.ruleGroups = this.ruleGroups.filter(group => group.id !== changedGroup.id)
|
||||
}
|
||||
},
|
||||
|
||||
close (): void {
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
maybeClose (): void {
|
||||
if (!this.name && !this.ruleGroups.length) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', () => this.close())
|
||||
},
|
||||
|
||||
async submit (): Promise<void> {
|
||||
this.meta.loading = true
|
||||
const playlist = await playlistStore.store(this.name, [], this.ruleGroups)
|
||||
this.meta.loading = false
|
||||
this.close()
|
||||
this.$nextTick(() => router.go(`playlist/${playlist.id}`))
|
||||
},
|
||||
|
||||
createGroup: (): SmartPlaylistRuleGroup => playlistStore.createEmptySmartPlaylistRuleGroup()
|
||||
const maybeClose = () => {
|
||||
if (!name.value && !ruleGroups.value.length) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
alerts.confirm('Discard all changes?', close)
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
loading.value = true
|
||||
const playlist = await playlistStore.store(name.value, [], ruleGroups.value)
|
||||
loading.value = false
|
||||
close()
|
||||
await nextTick()
|
||||
router.go(`playlist/${playlist.id}`)
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<form-base>
|
||||
<FormBase>
|
||||
<div @keydown.esc="maybeClose">
|
||||
<sound-bar v-if="meta.loading"/>
|
||||
<SoundBar v-if="loading"/>
|
||||
<form @submit.prevent="submit" v-else data-testid="edit-smart-playlist-form">
|
||||
<header>
|
||||
<h1>Edit Smart Playlist</h1>
|
||||
|
@ -14,98 +14,68 @@
|
|||
</div>
|
||||
|
||||
<div class="form-row rules">
|
||||
<rule-group
|
||||
<RuleGroup
|
||||
v-for="(group, index) in mutatedPlaylist.rules"
|
||||
:isFirstGroup="index === 0"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
@input="onGroupChanged"
|
||||
/>
|
||||
<btn @click.prevent="addGroup" class="btn-add-group" green small uppercase>
|
||||
<Btn @click.prevent="addGroup" class="btn-add-group" green small uppercase>
|
||||
<i class="fa fa-plus"></i> Group
|
||||
</btn>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<btn type="submit">Save</btn>
|
||||
<btn white class="btn-cancel" @click.prevent="maybeClose">Cancel</btn>
|
||||
<Btn type="submit">Save</Btn>
|
||||
<Btn white class="btn-cancel" @click.prevent="maybeClose">Cancel</Btn>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</form-base>
|
||||
</FormBase>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { reactive, toRefs } from 'vue'
|
||||
import { cloneDeep, isEqual } from 'lodash'
|
||||
import { playlistStore } from '@/stores'
|
||||
import { alerts } from '@/utils'
|
||||
import { useSmartPlaylistForms } from '@/components/playlist/smart-playlist/useSmartPlaylistForms'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
FormBase: () => import('@/components/playlist/smart-playlist/form-base.vue'),
|
||||
RuleGroup: () => import('@/components/playlist/smart-playlist/rule-group.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue'),
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const props = defineProps<{ playlist: Playlist }>()
|
||||
const { playlist } = toRefs(props)
|
||||
|
||||
props: {
|
||||
playlist: {
|
||||
required: true,
|
||||
type: Object
|
||||
} as PropOptions<Playlist>
|
||||
},
|
||||
const mutatedPlaylist = reactive<Playlist>(cloneDeep(playlist.value))
|
||||
|
||||
data: () => ({
|
||||
meta: {
|
||||
loading: false
|
||||
},
|
||||
mutatedPlaylist: null as unknown as Playlist
|
||||
}),
|
||||
const {
|
||||
Btn,
|
||||
FormBase,
|
||||
RuleGroup,
|
||||
SoundBar,
|
||||
ruleGroups,
|
||||
loading,
|
||||
addGroup,
|
||||
onGroupChanged,
|
||||
close
|
||||
} = useSmartPlaylistForms(mutatedPlaylist.rules)
|
||||
|
||||
methods: {
|
||||
addGroup (): void {
|
||||
this.mutatedPlaylist.rules.push(this.createGroup())
|
||||
},
|
||||
|
||||
onGroupChanged (data: SmartPlaylistRuleGroup): void {
|
||||
const changedGroup = Object.assign(this.mutatedPlaylist.rules.find(g => g.id === data.id), data)
|
||||
|
||||
// Remove empty group
|
||||
if (changedGroup.rules.length === 0) {
|
||||
this.mutatedPlaylist.rules = this.mutatedPlaylist.rules.filter(group => group.id !== changedGroup.id)
|
||||
}
|
||||
},
|
||||
|
||||
close (): void {
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
maybeClose (): void {
|
||||
if (isEqual(this.playlist, this.mutatedPlaylist)) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', () => this.close())
|
||||
},
|
||||
|
||||
async submit (): Promise<void> {
|
||||
this.meta.loading = true
|
||||
await playlistStore.update(this.mutatedPlaylist)
|
||||
Object.assign(this.playlist, this.mutatedPlaylist)
|
||||
this.meta.loading = false
|
||||
this.close()
|
||||
await playlistStore.fetchSongs(this.playlist)
|
||||
},
|
||||
|
||||
createGroup: (): SmartPlaylistRuleGroup => playlistStore.createEmptySmartPlaylistRuleGroup()
|
||||
},
|
||||
|
||||
created (): void {
|
||||
// use cloneDeep instead of Object.assign because we don't want references to playlist's rules
|
||||
this.mutatedPlaylist = cloneDeep(this.playlist)
|
||||
const maybeClose = () => {
|
||||
if (isEqual(playlist, mutatedPlaylist)) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
alerts.confirm('Discard all changes?', close)
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
loading.value = true
|
||||
mutatedPlaylist.rules = ruleGroups.value
|
||||
await playlistStore.update(mutatedPlaylist)
|
||||
Object.assign(playlist, mutatedPlaylist)
|
||||
loading.value = false
|
||||
close()
|
||||
await playlistStore.fetchSongs(playlist.value)
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,10 +4,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
|
||||
export default Vue.extend({})
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -28,48 +28,33 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, toRefs } from 'vue'
|
||||
import { playlistStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['group', 'isFirstGroup'],
|
||||
const props = defineProps<{ group: SmartPlaylistRuleGroup, isFirstGroup: boolean }>()
|
||||
const { group, isFirstGroup } = toRefs(props)
|
||||
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
Rule: () => import('@/components/playlist/smart-playlist/rule.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const Rule = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/rule.vue'))
|
||||
|
||||
data: () => ({
|
||||
mutatedGroup: null as unknown as SmartPlaylistRuleGroup
|
||||
}),
|
||||
const mutatedGroup = reactive<SmartPlaylistRuleGroup>(JSON.parse(JSON.stringify(group)))
|
||||
|
||||
created (): void {
|
||||
this.mutatedGroup = JSON.parse(JSON.stringify(this.group))
|
||||
},
|
||||
const emit = defineEmits(['input'])
|
||||
|
||||
methods: {
|
||||
onRuleChanged (data: SmartPlaylistRule): void {
|
||||
Object.assign(this.mutatedGroup.rules.find(r => r.id === data.id), data)
|
||||
this.notifyParentForUpdate()
|
||||
},
|
||||
const notifyParentForUpdate = () => emit('input', mutatedGroup)
|
||||
|
||||
addRule (): void {
|
||||
this.mutatedGroup.rules.push(this.createRule())
|
||||
},
|
||||
const addRule = () => mutatedGroup.rules.push(playlistStore.createEmptySmartPlaylistRule())
|
||||
|
||||
removeRule (rule: SmartPlaylistRule): void {
|
||||
this.mutatedGroup.rules = this.mutatedGroup.rules.filter(r => r.id !== rule.id)
|
||||
this.notifyParentForUpdate()
|
||||
},
|
||||
const onRuleChanged = (data: SmartPlaylistRule) => {
|
||||
Object.assign(mutatedGroup.rules.find(r => r.id === data.id), data)
|
||||
notifyParentForUpdate()
|
||||
}
|
||||
|
||||
notifyParentForUpdate (): void {
|
||||
this.$emit('input', this.mutatedGroup)
|
||||
},
|
||||
|
||||
createRule: (): SmartPlaylistRule => playlistStore.createEmptySmartPlaylistRule()
|
||||
}
|
||||
})
|
||||
const removeRule = (rule: SmartPlaylistRule) => {
|
||||
mutatedGroup.rules = mutatedGroup.rules.filter(r => r.id !== rule.id)
|
||||
notifyParentForUpdate()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -2,37 +2,18 @@
|
|||
<input :type="type" v-model="mutableValue" name="value[]" required>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, watch } from 'vue'
|
||||
import types from '@/config/smart-playlist/types'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
validator: value => Object.keys(types).includes(value)
|
||||
},
|
||||
const props = withDefaults(defineProps<{ type: keyof typeof types, value: string }>(), { value: '' })
|
||||
const { type, value } = toRefs(props)
|
||||
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
const mutableValue = value.value
|
||||
|
||||
data: () => ({
|
||||
mutableValue: ''
|
||||
}),
|
||||
const emit = defineEmits(['input'])
|
||||
|
||||
watch: {
|
||||
mutableValue (): void {
|
||||
this.$emit('input', this.mutableValue)
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
this.mutableValue = this.value
|
||||
}
|
||||
})
|
||||
watch(() => mutableValue, () => emit('input', mutableValue))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="row" data-test="smart-playlist-rule-row">
|
||||
<btn @click.prevent="removeRule" class="remove-rule" red><i class="fa fa-times"></i></btn>
|
||||
<Btn @click.prevent="removeRule" class="remove-rule" red><i class="fa fa-times"></i></Btn>
|
||||
|
||||
<select v-model="selectedModel" name="model[]">
|
||||
<option v-for="model in models" :key="model.name" :value="model">{{ model.label }}</option>
|
||||
|
@ -11,10 +11,10 @@
|
|||
</select>
|
||||
|
||||
<span class="value-wrapper">
|
||||
<rule-input
|
||||
<RuleInput
|
||||
v-for="input in availableInputs"
|
||||
:key="input.id"
|
||||
:type="selectedOperator.type || selectedModel.type"
|
||||
:type="selectedOperator.type || selectedModel?.type"
|
||||
v-model="input.value"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
@ -24,111 +24,85 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import models from '@/config/smart-playlist/models'
|
||||
import types from '@/config/smart-playlist/types'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SmartPlaylistRule',
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const RuleInput = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/rule-input.vue'))
|
||||
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
RuleInput: () => import('@/components/playlist/smart-playlist/rule-input.vue')
|
||||
},
|
||||
const props = defineProps<{ rule: SmartPlaylistRule }>()
|
||||
const { rule } = toRefs(props)
|
||||
|
||||
props: {
|
||||
rule: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<SmartPlaylistRule>
|
||||
},
|
||||
const mutatedRule = Object.assign({}, rule.value)
|
||||
|
||||
data: () => ({
|
||||
models,
|
||||
selectedModel: null as unknown as SmartPlaylistModel,
|
||||
selectedOperator: null as unknown as SmartPlaylistOperator,
|
||||
inputValues: [],
|
||||
mutatedRule: null as unknown as SmartPlaylistRule
|
||||
}),
|
||||
const selectedModel = ref<SmartPlaylistModel | null>(null)
|
||||
const selectedOperator = ref(null as unknown as SmartPlaylistOperator)
|
||||
const inputValues = ref([])
|
||||
|
||||
watch: {
|
||||
options (): void {
|
||||
if (this.selectedModel.name === this.mutatedRule.model.name) {
|
||||
this.selectedOperator = this.options.find(o => o.operator === this.mutatedRule.operator)!
|
||||
} else {
|
||||
this.selectedOperator = this.options[0]
|
||||
}
|
||||
}
|
||||
},
|
||||
const model = models.find(m => m.name === mutatedRule.model.name)
|
||||
|
||||
computed: {
|
||||
options (): SmartPlaylistOperator[] {
|
||||
return this.selectedModel ? types[this.selectedModel.type] : []
|
||||
},
|
||||
if (!model) {
|
||||
throw new Error(`Invalid smart playlist model: ${mutatedRule.model.name}`)
|
||||
}
|
||||
|
||||
availableInputs (): { id: string, value: any }[] {
|
||||
if (!this.selectedOperator) {
|
||||
return []
|
||||
}
|
||||
mutatedRule.model = selectedModel.value = model
|
||||
|
||||
const inputs: Array<{ id: string, value: string }> = []
|
||||
const options = computed<SmartPlaylistOperator[]>(() => selectedModel.value ? types[selectedModel.value.type] : [])
|
||||
|
||||
for (let i = 0, inputCount = this.selectedOperator.inputs || 1; i < inputCount; ++i) {
|
||||
inputs.push({
|
||||
id: `${this.mutatedRule.model}_${this.selectedOperator.operator}_${i}`,
|
||||
value: this.isOriginalOperatorSelected ? this.mutatedRule.value[i] : ''
|
||||
})
|
||||
}
|
||||
const operator = options.value.find(o => o.operator === mutatedRule.operator)
|
||||
|
||||
return inputs
|
||||
},
|
||||
if (!operator) {
|
||||
throw new Error(`Invalid smart playlist operator: ${mutatedRule.operator}`)
|
||||
}
|
||||
|
||||
isOriginalOperatorSelected (): boolean {
|
||||
return this.selectedModel.name === this.mutatedRule.model.name &&
|
||||
this.selectedOperator.operator === this.mutatedRule.operator
|
||||
},
|
||||
selectedOperator.value = operator
|
||||
|
||||
valueSuffix (): string | undefined {
|
||||
return this.selectedOperator.unit || this.selectedModel.unit
|
||||
}
|
||||
},
|
||||
const isOriginalOperatorSelected = computed(() => {
|
||||
return selectedModel.value?.name === mutatedRule.model.name &&
|
||||
selectedOperator.value.operator === mutatedRule.operator
|
||||
})
|
||||
|
||||
created (): void {
|
||||
this.mutatedRule = Object.assign({}, this.rule)
|
||||
const availableInputs = computed<{ id: string, value: any }[]>(() => {
|
||||
if (!selectedOperator.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const model = this.models.find((m: SmartPlaylistModel) => m.name === this.mutatedRule.model.name)
|
||||
const inputs: Array<{ id: string, value: string }> = []
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`Invalid smart playlist model: ${this.mutatedRule.model.name}`)
|
||||
}
|
||||
for (let i = 0, inputCount = selectedOperator.value.inputs || 1; i < inputCount; ++i) {
|
||||
inputs.push({
|
||||
id: `${mutatedRule.model}_${selectedOperator.value.operator}_${i}`,
|
||||
value: isOriginalOperatorSelected.value ? mutatedRule.value[i] : ''
|
||||
})
|
||||
}
|
||||
|
||||
this.mutatedRule.model = this.selectedModel = model
|
||||
return inputs
|
||||
})
|
||||
|
||||
const operator = this.options.find(o => o.operator === this.mutatedRule.operator)
|
||||
|
||||
if (!operator) {
|
||||
throw new Error(`Invalid smart playlist operator: ${this.mutatedRule.operator}`)
|
||||
}
|
||||
|
||||
this.selectedOperator = operator
|
||||
},
|
||||
|
||||
methods: {
|
||||
onInput (): void {
|
||||
this.$emit('input', {
|
||||
id: this.mutatedRule.id,
|
||||
model: this.selectedModel,
|
||||
operator: this.selectedOperator.operator,
|
||||
value: this.availableInputs.map(input => input.value)
|
||||
} as SmartPlaylistRule)
|
||||
},
|
||||
|
||||
removeRule (): void {
|
||||
this.$emit('remove')
|
||||
}
|
||||
watch(options, () => {
|
||||
if (selectedModel.value?.name === mutatedRule.model.name) {
|
||||
selectedOperator.value = options.value.find(o => o.operator === mutatedRule.operator)!
|
||||
} else {
|
||||
selectedOperator.value = options.value[0]
|
||||
}
|
||||
})
|
||||
|
||||
const valueSuffix = computed(() => selectedOperator.value.unit || selectedModel.value?.unit)
|
||||
|
||||
const emit = defineEmits(['input', 'remove'])
|
||||
|
||||
const onInput = () => {
|
||||
emit('input', {
|
||||
id: mutatedRule.id,
|
||||
model: selectedModel.value,
|
||||
operator: selectedOperator.value.operator,
|
||||
value: availableInputs.value.map(input => input.value)
|
||||
} as SmartPlaylistRule)
|
||||
}
|
||||
|
||||
const removeRule = () => emit('remove')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { playlistStore } from '@/stores'
|
||||
|
||||
export const useSmartPlaylistForms = (initialRuleGroups: SmartPlaylistRuleGroup[]) => {
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const FormBase = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/form-base.vue'))
|
||||
const RuleGroup = defineAsyncComponent(() => import('@/components/playlist/smart-playlist/rule-group.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
|
||||
const ruleGroups = ref<SmartPlaylistRuleGroup[]>(initialRuleGroups)
|
||||
const loading = ref(false)
|
||||
|
||||
const createGroup = () => playlistStore.createEmptySmartPlaylistRuleGroup()
|
||||
const addGroup = () => ruleGroups.value.push(createGroup())
|
||||
|
||||
const onGroupChanged = (data: SmartPlaylistRuleGroup) => {
|
||||
const changedGroup = Object.assign(ruleGroups.value.find(g => g.id === data.id), data)
|
||||
|
||||
// Remove empty group
|
||||
if (changedGroup.rules.length === 0) {
|
||||
ruleGroups.value = ruleGroups.value.filter(group => group.id !== changedGroup.id)
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const close = () => emit('close')
|
||||
|
||||
return {
|
||||
Btn,
|
||||
FormBase,
|
||||
RuleGroup,
|
||||
SoundBar,
|
||||
ruleGroups,
|
||||
loading,
|
||||
addGroup,
|
||||
onGroupChanged,
|
||||
close
|
||||
}
|
||||
}
|
|
@ -22,10 +22,10 @@
|
|||
</a>.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<btn @click.prevent="connect" class="connect">
|
||||
<Btn @click.prevent="connect" class="connect">
|
||||
<i class="fa fa-lastfm"></i>
|
||||
{{ currentUserConnected ? 'Reconnect' : 'Connect' }}
|
||||
</btn>
|
||||
</Btn>
|
||||
|
||||
<btn v-if="currentUserConnected" @click.prevent="disconnect" class="disconnect" gray>Disconnect</btn>
|
||||
</div>
|
||||
|
@ -47,50 +47,31 @@
|
|||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive } from 'vue'
|
||||
import { sharedStore, userStore } from '@/stores'
|
||||
import { auth, http } from '@/services'
|
||||
import { forceReloadWindow } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
userState: userStore.state,
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const userState = reactive(userStore.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
computed: {
|
||||
currentUserConnected (): boolean {
|
||||
return this.userState?.current.preferences.lastfm_session_key
|
||||
}
|
||||
},
|
||||
const currentUserConnected = computed(() => Boolean(userState.current.preferences.lastfm_session_key))
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Connect the current user to Last.fm.
|
||||
* This method opens a new window.
|
||||
* Koel will reload once the connection is successful.
|
||||
*/
|
||||
connect: (): void => {
|
||||
window.open(
|
||||
`${window.BASE_URL}lastfm/connect?api_token=${auth.getToken()}`,
|
||||
'_blank',
|
||||
'toolbar=no,titlebar=no,location=no,width=1024,height=640'
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Connect the current user to Last.fm.
|
||||
* This method opens a new window.
|
||||
* Koel will reload once the connection is successful.
|
||||
*/
|
||||
const connect = () => window.open(
|
||||
`${window.BASE_URL}lastfm/connect?api_token=${auth.getToken()}`,
|
||||
'_blank',
|
||||
'toolbar=no,titlebar=no,location=no,width=1024,height=640'
|
||||
)
|
||||
|
||||
/**
|
||||
* Disconnect the current user from Last.fm.
|
||||
*/
|
||||
disconnect: (): void => {
|
||||
http.delete('lastfm/disconnect').then(forceReloadWindow)
|
||||
}
|
||||
}
|
||||
})
|
||||
const disconnect = () => http.delete('lastfm/disconnect').then(forceReloadWindow)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -27,17 +27,11 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { preferenceStore as preferences } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
preferences,
|
||||
isPhone: isMobile.phone
|
||||
})
|
||||
})
|
||||
const isPhone = isMobile.phone
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<btn type="submit" class="btn-submit">Save</btn>
|
||||
<Btn type="submit" class="btn-submit">Save</Btn>
|
||||
<span v-if="demo" style="font-size:.95rem; opacity:.7; margin-left:5px">
|
||||
Changes will not be saved in the demo version.
|
||||
</span>
|
||||
|
@ -48,45 +48,40 @@
|
|||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { preferenceStore as preferences, sharedStore, UpdateCurrentProfileData, userStore } from '@/stores'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||||
import { sharedStore, UpdateCurrentProfileData, userStore } from '@/stores'
|
||||
import { alerts, parseValidationError } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
preferences,
|
||||
demo: NODE_ENV === 'demo',
|
||||
state: userStore.state,
|
||||
sharedState: sharedStore.state,
|
||||
profile: {} as UpdateCurrentProfileData
|
||||
}),
|
||||
const demo = NODE_ENV === 'demo'
|
||||
const state = reactive(userStore.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
const profile = ref<UpdateCurrentProfileData>({} as unknown as UpdateCurrentProfileData)
|
||||
|
||||
methods: {
|
||||
async update (): Promise<void> {
|
||||
try {
|
||||
await userStore.updateProfile(this.profile)
|
||||
this.profile.current_password = null
|
||||
delete this.profile.new_password
|
||||
} catch (err) {
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.profile = {
|
||||
name: userStore.current.name,
|
||||
email: userStore.current.email,
|
||||
current_password: null
|
||||
}
|
||||
onMounted(() => {
|
||||
profile.value = {
|
||||
name: userStore.current.name,
|
||||
email: userStore.current.email,
|
||||
current_password: null
|
||||
}
|
||||
})
|
||||
|
||||
const update = async () => {
|
||||
if (!profile.value) {
|
||||
throw Error()
|
||||
}
|
||||
|
||||
try {
|
||||
await userStore.updateProfile(profile.value)
|
||||
profile.value.current_password = null
|
||||
delete profile.value.new_password
|
||||
} catch (err: any) {
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -104,7 +99,7 @@ input {
|
|||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 667px) {
|
||||
@media only screen and (max-width: 667px) {
|
||||
input {
|
||||
&[type="text"], &[type="email"], &[type="password"] {
|
||||
width: 100%;
|
||||
|
|
|
@ -10,35 +10,25 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { slugToTitle } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
theme: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Theme>
|
||||
},
|
||||
const props = defineProps<{ theme: Theme }>()
|
||||
const { theme } = toRefs(props)
|
||||
|
||||
computed: {
|
||||
name (): string {
|
||||
return this.theme.name ? this.theme.name : slugToTitle(this.theme.id)
|
||||
},
|
||||
const name = computed(() => theme.value.name ? theme.value.name : slugToTitle(theme.value.id))
|
||||
|
||||
thumbnailStyles (): Record<string, string> {
|
||||
const styles = {
|
||||
'background-color': this.theme.thumbnailColor
|
||||
} as Record<string, string>
|
||||
|
||||
if (this.theme.thumbnailUrl) {
|
||||
styles['background-image'] = `url(${this.theme.thumbnailUrl})`
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
const thumbnailStyles = computed((): Record<string, string> => {
|
||||
const styles: Record<string, string> = {
|
||||
'background-color': theme.value.thumbnailColor
|
||||
}
|
||||
|
||||
if (theme.value.thumbnailUrl) {
|
||||
styles['background-image'] = `url(${theme.value.thumbnailUrl})`
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -3,29 +3,20 @@
|
|||
<h1>Theme</h1>
|
||||
<ul class="themes">
|
||||
<li v-for="theme in themes" :key="theme.id">
|
||||
<theme-card :theme="theme" :key="theme.id" @selected="setTheme"/>
|
||||
<ThemeCard :theme="theme" :key="theme.id" @selected="setTheme"/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive } from 'vue'
|
||||
import { themeStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ThemeCard: () => import('@/components/profile-preferences/theme-card.vue')
|
||||
},
|
||||
const ThemeCard = defineAsyncComponent(() => import('@/components/profile-preferences/theme-card.vue'))
|
||||
const themes = reactive(themeStore.state.themes)
|
||||
|
||||
data: () => ({
|
||||
themes: themeStore.state.themes
|
||||
}),
|
||||
|
||||
methods: {
|
||||
setTheme: (theme: Theme): void => themeStore.setTheme(theme)
|
||||
}
|
||||
})
|
||||
const setTheme = (theme: Theme) => themeStore.setTheme(theme)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,68 +1,56 @@
|
|||
<template>
|
||||
<section id="albumsWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
Albums
|
||||
<template v-slot:controls>
|
||||
<view-mode-switch v-model="viewMode"/>
|
||||
<ViewModeSwitch v-model="viewMode"/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<div ref="scroller" class="albums main-scroll-wrap" :class="`as-${viewMode}`" @scroll="scrolling">
|
||||
<album-card v-for="item in displayedItems" :album="item" :layout="itemLayout" :key="item.id" />
|
||||
<to-top-button/>
|
||||
<AlbumCard v-for="item in displayedItems" :album="item" :layout="itemLayout" :key="item.id"/>
|
||||
<ToTopButton/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import { limitBy, eventBus } from '@/utils'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from 'vue'
|
||||
import { eventBus, limitBy } from '@/utils'
|
||||
import { albumStore, preferenceStore as preferences } from '@/stores'
|
||||
import infiniteScroll from '@/mixins/infinite-scroll.ts'
|
||||
import { useInfiniteScroll } from '@/composables'
|
||||
|
||||
export default mixins(infiniteScroll).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
AlbumCard: () => import('@/components/album/card.vue'),
|
||||
ViewModeSwitch: () => import('@/components/ui/view-mode-switch.vue')
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const AlbumCard = defineAsyncComponent(() => import('@/components/album/card.vue'))
|
||||
const ViewModeSwitch = defineAsyncComponent(() => import('@/components/ui/view-mode-switch.vue'))
|
||||
|
||||
const viewMode = ref<ArtistAlbumViewMode>('thumbnails')
|
||||
const albums = ref<Album[]>([])
|
||||
|
||||
const {
|
||||
ToTopButton,
|
||||
displayedItemCount,
|
||||
scroller,
|
||||
scrolling,
|
||||
makeScrollable
|
||||
} = useInfiniteScroll(9)
|
||||
|
||||
const displayedItems = computed(() => limitBy(albums.value, displayedItemCount.value))
|
||||
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
|
||||
|
||||
watch(viewMode, () => preferences.albumsViewMode = viewMode.value)
|
||||
|
||||
eventBus.on({
|
||||
KOEL_READY () {
|
||||
albums.value = albumStore.all
|
||||
viewMode.value = preferences.albumsViewMode || 'thumbnails'
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
perPage: 9,
|
||||
displayedItemCount: 9,
|
||||
viewMode: null as ArtistAlbumViewMode | null,
|
||||
albums: [] as Album[]
|
||||
}),
|
||||
|
||||
computed: {
|
||||
displayedItems (): Album[] {
|
||||
return limitBy(this.albums, this.displayedItemCount)
|
||||
},
|
||||
|
||||
itemLayout (): ArtistAlbumCardLayout {
|
||||
return this.viewMode === 'thumbnails' ? 'full' : 'compact'
|
||||
async LOAD_MAIN_CONTENT (view: MainViewName) {
|
||||
if (view === 'Albums') {
|
||||
await nextTick()
|
||||
makeScrollable(albums.value.length)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
viewMode (): void {
|
||||
preferences.albumsViewMode = this.viewMode
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'KOEL_READY': (): void => {
|
||||
this.albums = albumStore.all
|
||||
this.viewMode = preferences.albumsViewMode || 'thumbnails'
|
||||
},
|
||||
|
||||
'LOAD_MAIN_CONTENT': (view: MainViewName): void => {
|
||||
if (view === 'Albums') {
|
||||
this.$nextTick((): void => this.makeScrollable(this.$refs.scroller as HTMLElement, this.albums.length))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<section id="albumWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
{{ album.name }}
|
||||
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:thumbnail>
|
||||
<album-thumbnail :entity="album"/>
|
||||
<AlbumThumbnail :entity="album"/>
|
||||
</template>
|
||||
|
||||
<template v-slot:meta>
|
||||
|
@ -14,7 +14,7 @@
|
|||
<a class="artist" v-if="isNormalArtist" :href="`#!/artist/${album.artist.id}`">{{ album.artist.name }}</a>
|
||||
<span class="nope" v-else>{{ album.artist.name }}</span>
|
||||
•
|
||||
{{ album.songs.length | pluralize('song') }}
|
||||
{{ pluralize(album.songs.length, 'song') }}
|
||||
•
|
||||
{{ fmtLength }}
|
||||
|
||||
|
@ -32,7 +32,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<song-list-controls
|
||||
<SongListControls
|
||||
v-if="album.songs.length && (!isPhone || showingControls)"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
|
@ -41,108 +41,96 @@
|
|||
:selectedSongs="selectedSongs"
|
||||
/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<song-list :items="album.songs" type="album" :config="listConfig" ref="songList"/>
|
||||
<SongList :items="album.songs" type="album" :config="listConfig" ref="songList"/>
|
||||
|
||||
<section class="info-wrapper" v-if="sharedState.useLastfm && meta.showing">
|
||||
<close-modal-btn @click="meta.showing = false"/>
|
||||
<section class="info-wrapper" v-if="sharedState.useLastfm && showing">
|
||||
<CloseModalBtn @click="showing = false"/>
|
||||
<div class="inner">
|
||||
<div class="loading" v-if="meta.loading">
|
||||
<sound-bar/>
|
||||
<div class="loading" v-if="loading">
|
||||
<SoundBar/>
|
||||
</div>
|
||||
<album-info :album="album" mode="full" v-else/>
|
||||
<AlbumInfo :album="album" mode="full" v-else/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive, ref, toRefs, watch } from 'vue'
|
||||
import { pluralize } from '@/utils'
|
||||
import { artistStore, sharedStore } from '@/stores'
|
||||
import { download, albumInfo as albumInfoService } from '@/services'
|
||||
import { albumInfo as albumInfoService, download as downloadService } from '@/services'
|
||||
import router from '@/router'
|
||||
import hasSongList from '@/mixins/has-song-list.ts'
|
||||
import albumAttributes from '@/mixins/album-attributes.ts'
|
||||
import { SongListConfig } from '@/components/song/list.vue'
|
||||
import { useAlbumAttributes, useSongList } from '@/composables'
|
||||
|
||||
export default mixins(hasSongList, albumAttributes).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
AlbumInfo: () => import('@/components/album/info.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue'),
|
||||
AlbumThumbnail: () => import('@/components/ui/album-artist-thumbnail.vue'),
|
||||
CloseModalBtn: () => import('@/components/ui/close-modal-btn.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/info.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
const AlbumThumbnail = defineAsyncComponent(() => import('@/components/ui/album-artist-thumbnail.vue'))
|
||||
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/close-modal-btn.vue'))
|
||||
|
||||
filters: { pluralize },
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playAll,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList()
|
||||
|
||||
data: () => ({
|
||||
sharedState: sharedStore.state,
|
||||
const props = defineProps<{ album: Album }>()
|
||||
const { album } = toRefs(props)
|
||||
|
||||
listConfig: {
|
||||
columns: ['track', 'title', 'length']
|
||||
} as Partial<SongListConfig>,
|
||||
const { length, fmtLength } = useAlbumAttributes(album.value)
|
||||
|
||||
meta: {
|
||||
showing: false,
|
||||
loading: true
|
||||
}
|
||||
}),
|
||||
const listConfig: Partial<SongListConfig> = { columns: ['track', 'title', 'length'] }
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
const showing = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
computed: {
|
||||
isNormalArtist (): boolean {
|
||||
return !artistStore.isVariousArtists(this.album.artist) &&
|
||||
!artistStore.isUnknownArtist(this.album.artist)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Watch the album's song count.
|
||||
* If this is changed to 0, the user has edit the songs in this album
|
||||
* and move all of them into another album.
|
||||
* We should then go back to the album list.
|
||||
*/
|
||||
'album.songs.length': (newSongCount: number): void => {
|
||||
if (!newSongCount) {
|
||||
router.go('albums')
|
||||
}
|
||||
},
|
||||
|
||||
album (): void {
|
||||
this.meta.showing = false
|
||||
// #530
|
||||
if (this.$refs.songList) {
|
||||
(this.$refs.songList as any).sort()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
download (): void {
|
||||
download.fromAlbum(this.album)
|
||||
},
|
||||
|
||||
async showInfo (): Promise<void> {
|
||||
this.meta.showing = true
|
||||
|
||||
if (!this.album.info) {
|
||||
try {
|
||||
await albumInfoService.fetch(this.album)
|
||||
} catch (e) {
|
||||
/* eslint no-console: 0 */
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.meta.loading = false
|
||||
}
|
||||
} else {
|
||||
this.meta.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
const isNormalArtist = computed(() => {
|
||||
return !artistStore.isVariousArtists(album.value.artist) && !artistStore.isUnknownArtist(album.value.artist)
|
||||
})
|
||||
|
||||
/**
|
||||
* Watch the album's song count.
|
||||
* If this is changed to 0, the user has edited the songs on this album
|
||||
* and moved all of them into another album.
|
||||
* We should then go back to the album list.
|
||||
*/
|
||||
watch(() => album.value.songs.length, newSongCount => newSongCount || router.go('albums'))
|
||||
|
||||
watch(album, () => {
|
||||
showing.value = false
|
||||
// @ts-ignore
|
||||
songList.value?.sort()
|
||||
})
|
||||
|
||||
const download = () => downloadService.fromAlbum(album.value)
|
||||
|
||||
const showInfo = async () => {
|
||||
showing.value = true
|
||||
|
||||
if (!album.value.info) {
|
||||
try {
|
||||
await albumInfoService.fetch(album.value)
|
||||
} catch (e) {
|
||||
/* eslint no-console: 0 */
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
<template>
|
||||
<section id="songsWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
All Songs
|
||||
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:meta>
|
||||
<span v-if="meta.songCount">{{ meta.songCount | pluralize('song') }} • {{ meta.totalLength }}</span>
|
||||
<span v-if="meta.songCount">{{ pluralize(meta.songCount, 'song') }} • {{ meta.totalLength }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<song-list-controls
|
||||
<SongListControls
|
||||
v-if="state.songs.length && (!isPhone || showingControls)"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
|
@ -18,27 +18,33 @@
|
|||
:selectedSongs="selectedSongs"
|
||||
/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<song-list :items="state.songs" type="all-songs" ref="songList"/>
|
||||
<SongList :items="state.songs" type="all-songs" ref="songList"/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
<script lang="ts" setup>
|
||||
import { pluralize } from '@/utils'
|
||||
import { songStore } from '@/stores'
|
||||
import hasSongList from '@/mixins/has-song-list.ts'
|
||||
import { useSongList } from '@/composables'
|
||||
import { defineAsyncComponent, reactive } from 'vue'
|
||||
|
||||
export default mixins(hasSongList).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue')
|
||||
},
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
meta,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playAll,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList()
|
||||
|
||||
filters: { pluralize },
|
||||
|
||||
data: () => ({
|
||||
state: songStore.state
|
||||
})
|
||||
})
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const state = reactive(songStore.state)
|
||||
</script>
|
||||
|
|
|
@ -1,68 +1,56 @@
|
|||
<template>
|
||||
<section id="artistsWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
Artists
|
||||
<template v-slot:controls>
|
||||
<view-mode-switch v-model="viewMode"/>
|
||||
<ViewModeSwitch v-model="viewMode"/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<div ref="scroller" class="artists main-scroll-wrap" :class="`as-${viewMode}`" @scroll="scrolling">
|
||||
<artist-card v-for="item in displayedItems" :artist="item" :layout="itemLayout" :key="item.id"/>
|
||||
<to-top-button/>
|
||||
<ArtistCard v-for="item in displayedItems" :artist="item" :layout="itemLayout" :key="item.id"/>
|
||||
<ToTopButton/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import { limitBy, eventBus } from '@/utils'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from 'vue'
|
||||
import { eventBus, limitBy } from '@/utils'
|
||||
import { artistStore, preferenceStore as preferences } from '@/stores'
|
||||
import infiniteScroll from '@/mixins/infinite-scroll.ts'
|
||||
import { useInfiniteScroll } from '@/composables'
|
||||
|
||||
export default mixins(infiniteScroll).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ArtistCard: () => import('@/components/artist/card.vue'),
|
||||
ViewModeSwitch: () => import('@/components/ui/view-mode-switch.vue')
|
||||
const {
|
||||
ToTopButton,
|
||||
displayedItemCount,
|
||||
scroller,
|
||||
scrolling,
|
||||
makeScrollable
|
||||
} = useInfiniteScroll(9)
|
||||
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ArtistCard = defineAsyncComponent(() => import('@/components/artist/card.vue'))
|
||||
const ViewModeSwitch = defineAsyncComponent(() => import('@/components/ui/view-mode-switch.vue'))
|
||||
|
||||
const viewMode = ref<ArtistAlbumViewMode>('thumbnails')
|
||||
const artists = ref<Artist[]>([])
|
||||
|
||||
const displayedItems = computed(() => limitBy(artists.value, displayedItemCount.value))
|
||||
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
|
||||
|
||||
watch(viewMode, () => preferences.artistsViewMode = viewMode.value)
|
||||
|
||||
eventBus.on({
|
||||
KOEL_READY () {
|
||||
artists.value = artistStore.all
|
||||
viewMode.value = preferences.artistsViewMode || 'thumbnails'
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
perPage: 9,
|
||||
displayedItemCount: 9,
|
||||
viewMode: null as ArtistAlbumViewMode | null,
|
||||
artists: [] as Artist[]
|
||||
}),
|
||||
|
||||
computed: {
|
||||
displayedItems (): Artist[] {
|
||||
return limitBy(this.artists, this.displayedItemCount)
|
||||
},
|
||||
|
||||
itemLayout (): ArtistAlbumCardLayout {
|
||||
return this.viewMode === 'thumbnails' ? 'full' : 'compact'
|
||||
async LOAD_MAIN_CONTENT (view: MainViewName) {
|
||||
if (view === 'Artists') {
|
||||
await nextTick()
|
||||
makeScrollable(artists.value.length)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
viewMode (): void {
|
||||
preferences.artistsViewMode = this.viewMode
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'KOEL_READY': (): void => {
|
||||
this.artists = artistStore.all
|
||||
this.viewMode = preferences.artistsViewMode || 'thumbnails'
|
||||
},
|
||||
|
||||
'LOAD_MAIN_CONTENT': (view: MainViewName): void => {
|
||||
if (view === 'Artists') {
|
||||
this.$nextTick((): void => this.makeScrollable(this.$refs.scroller as HTMLElement, this.artists.length))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
<template>
|
||||
<section id="artistWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
{{ artist.name }}
|
||||
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:thumbnail>
|
||||
<artist-thumbnail :entity="artist"/>
|
||||
<ArtistThumbnail :entity="artist"/>
|
||||
</template>
|
||||
|
||||
<template v-slot:meta>
|
||||
<span v-if="artist.songs.length">
|
||||
{{ artist.albums.length | pluralize('album') }}
|
||||
{{ pluralize(artist.albums.length, 'album') }}
|
||||
•
|
||||
{{ artist.songs.length | pluralize('song') }}
|
||||
{{ pluralize(artist.songs.length, 'song') }}
|
||||
•
|
||||
{{ fmtLength }}
|
||||
|
||||
|
@ -37,7 +37,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<song-list-controls
|
||||
<SongListControls
|
||||
v-if="artist.songs.length && (!isPhone || showingControls)"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
|
@ -46,101 +46,94 @@
|
|||
:selectedSongs="selectedSongs"
|
||||
/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<song-list :items="artist.songs" type="artist" :config="listConfig" ref="songList"/>
|
||||
<SongList :items="artist.songs" type="artist" :config="listConfig" ref="songList"/>
|
||||
|
||||
<section class="info-wrapper" v-if="sharedState.useLastfm && meta.showing">
|
||||
<close-modal-btn @click="meta.showing = false"/>
|
||||
<section class="info-wrapper" v-if="sharedState.useLastfm && showing">
|
||||
<CloseModalBtn @click="showing = false"/>
|
||||
<div class="inner">
|
||||
<div class="loading" v-if="meta.loading">
|
||||
<sound-bar/>
|
||||
<div class="loading" v-if="loading">
|
||||
<SoundBar/>
|
||||
</div>
|
||||
<artist-info :artist="artist" mode="full" v-else/>
|
||||
<ArtistInfo :artist="artist" mode="full" v-else/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, ref, toRefs, watch } from 'vue'
|
||||
import { pluralize } from '@/utils'
|
||||
import { sharedStore } from '@/stores'
|
||||
import { download, artistInfo as artistInfoService } from '@/services'
|
||||
import { artistInfo as artistInfoService, download as downloadService } from '@/services'
|
||||
import router from '@/router'
|
||||
import hasSongList from '@/mixins/has-song-list.ts'
|
||||
import artistAttributes from '@/mixins/artist-attributes.ts'
|
||||
import { SongListConfig } from '@/components/song/list.vue'
|
||||
import { useArtistAttributes, useSongList } from '@/composables'
|
||||
|
||||
export default mixins(hasSongList, artistAttributes).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ArtistInfo: () => import('@/components/artist/info.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue'),
|
||||
ArtistThumbnail: () => import('@/components/ui/album-artist-thumbnail.vue'),
|
||||
CloseModalBtn: () => import('@/components/ui/close-modal-btn.vue')
|
||||
},
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
state,
|
||||
meta,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playAll,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList()
|
||||
|
||||
filters: { pluralize },
|
||||
const props = defineProps<{ artist: Artist }>()
|
||||
const { artist } = toRefs(props)
|
||||
const { length, fmtLength, image } = useArtistAttributes(artist.value)
|
||||
|
||||
data: () => ({
|
||||
listConfig: {
|
||||
columns: ['track', 'title', 'album', 'length']
|
||||
} as Partial<SongListConfig>,
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/info.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
const ArtistThumbnail = defineAsyncComponent(() => import('@/components/ui/album-artist-thumbnail.vue'))
|
||||
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/close-modal-btn.vue'))
|
||||
|
||||
sharedState: sharedStore.state,
|
||||
const listConfig: Partial<SongListConfig> = { columns: ['track', 'title', 'album', 'length'] }
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
meta: {
|
||||
showing: false,
|
||||
loading: true
|
||||
}
|
||||
}),
|
||||
const showing = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Watch the artist's album count.
|
||||
* If this is changed to 0, the user has edit the songs by this artist
|
||||
* and move all of them to another artist (thus delete this artist entirely).
|
||||
* We should then go back to the artist list.
|
||||
*/
|
||||
'artist.albums.length': (newAlbumCount: number): void => {
|
||||
if (!newAlbumCount) {
|
||||
router.go('artists')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Watch the artist's album count.
|
||||
* If this is changed to 0, the user has edited the songs by this artist
|
||||
* and moved all of them to another artist (thus deleted this artist entirely).
|
||||
* We should then go back to the artist list.
|
||||
*/
|
||||
watch(() => artist.value.albums.length, newAlbumCount => newAlbumCount || router.go('artists'))
|
||||
|
||||
artist (): void {
|
||||
this.meta.showing = false
|
||||
// #530
|
||||
if (this.$refs.songList) {
|
||||
(this.$refs.songList as any).sort()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
download (): void {
|
||||
download.fromArtist(this.artist)
|
||||
},
|
||||
|
||||
async showInfo (): Promise<void> {
|
||||
this.meta.showing = true
|
||||
|
||||
if (!this.artist.info) {
|
||||
try {
|
||||
await artistInfoService.fetch(this.artist)
|
||||
} catch (e) {
|
||||
/* eslint no-console: 0 */
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.meta.loading = false
|
||||
}
|
||||
} else {
|
||||
this.meta.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(artist, () => {
|
||||
showing.value = false
|
||||
// @ts-ignore
|
||||
songList.value?.sort()
|
||||
})
|
||||
|
||||
const download = () => downloadService.fromArtist(artist.value)
|
||||
|
||||
const showInfo = async () => {
|
||||
showing.value = true
|
||||
|
||||
if (!artist.value.info) {
|
||||
try {
|
||||
await artistInfoService.fetch(artist.value)
|
||||
} catch (e) {
|
||||
/* eslint no-console: 0 */
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<section id="favoritesWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
Songs You Love
|
||||
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:meta>
|
||||
<span v-if="meta.songCount">
|
||||
{{ meta.songCount | pluralize('song') }}
|
||||
{{ pluralize(meta.songCount, 'song') }}
|
||||
•
|
||||
{{ meta.totalLength }}
|
||||
<template v-if="sharedState.allowDownload && state.songs.length">
|
||||
|
@ -19,7 +19,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<song-list-controls
|
||||
<SongListControls
|
||||
v-if="state.songs.length && (!isPhone || showingControls)"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
|
@ -28,11 +28,11 @@
|
|||
:selectedSongs="selectedSongs"
|
||||
/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<song-list v-if="state.songs.length" :items="state.songs" type="favorites" ref="songList"/>
|
||||
<SongList v-if="state.songs.length" :items="state.songs" type="favorites" ref="songList"/>
|
||||
|
||||
<screen-placeholder v-else>
|
||||
<ScreenPlaceholder v-else>
|
||||
<template v-slot:icon>
|
||||
<i class="fa fa-frown-o"></i>
|
||||
</template>
|
||||
|
@ -42,32 +42,37 @@
|
|||
<i class="fa fa-heart-o"></i>
|
||||
icon to mark a song as favorite.
|
||||
</span>
|
||||
</screen-placeholder>
|
||||
</ScreenPlaceholder>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
<script lang="ts" setup>
|
||||
import { pluralize } from '@/utils'
|
||||
import { favoriteStore, sharedStore } from '@/stores'
|
||||
import { download } from '@/services'
|
||||
import hasSongList from '@/mixins/has-song-list.ts'
|
||||
import { download as downloadService } from '@/services'
|
||||
import { useSongList } from '@/composables'
|
||||
import { defineAsyncComponent, reactive } from 'vue'
|
||||
|
||||
export default mixins(hasSongList).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ScreenPlaceholder: () => import('@/components/ui/screen-placeholder.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ScreenPlaceholder = defineAsyncComponent(() => import('@/components/ui/screen-placeholder.vue'))
|
||||
|
||||
filters: { pluralize },
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
meta,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playAll,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList()
|
||||
|
||||
data: () => ({
|
||||
state: favoriteStore.state,
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const state = reactive(favoriteStore.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
methods: {
|
||||
download: (): void => download.fromFavorites()
|
||||
}
|
||||
})
|
||||
const download = () => downloadService.fromFavorites()
|
||||
</script>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
<template>
|
||||
<section id="homeWrapper">
|
||||
<screen-header>{{ greeting }}</screen-header>
|
||||
<ScreenHeader>{{ greeting }}</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap" @scroll="scrolling" ref="wrapper">
|
||||
<div class="main-scroll-wrap" @scroll="scrolling">
|
||||
<div class="two-cols">
|
||||
<section v-if="top.songs.length">
|
||||
<h1>Most Played</h1>
|
||||
|
||||
<ol class="top-song-list">
|
||||
<li v-for="song in top.songs" :key="song.id">
|
||||
<song-card :song="song" :top-play-count="top.songs.length ? top.songs[0].playCount : 0"/>
|
||||
<SongCard :song="song" :top-play-count="top.songs.length ? top.songs[0].playCount : 0"/>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
@ -17,7 +17,7 @@
|
|||
<section class="recent">
|
||||
<h1>
|
||||
Recently Played
|
||||
<btn
|
||||
<Btn
|
||||
data-testid="home-view-all-recently-played-btn"
|
||||
@click.prevent="goToRecentlyPlayedScreen"
|
||||
rounded
|
||||
|
@ -25,12 +25,12 @@
|
|||
orange
|
||||
>
|
||||
View All
|
||||
</btn>
|
||||
</Btn>
|
||||
</h1>
|
||||
|
||||
<ol class="recent-song-list" v-if="recentSongs.length">
|
||||
<li v-for="song in recentSongs" :key="song.id">
|
||||
<song-card :song="song" :top-play-count="top.songs.length ? top.songs[0].playCount : 0"/>
|
||||
<SongCard :song="song" :top-play-count="top.songs.length ? top.songs[0].playCount : 0"/>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
|
@ -46,12 +46,12 @@
|
|||
<div class="two-cols">
|
||||
<ol class="recently-added-album-list">
|
||||
<li v-for="album in recentlyAdded.albums" :key="album.id">
|
||||
<album-card :album="album" layout="compact"/>
|
||||
<AlbumCard :album="album" layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
<ol class="recently-added-song-list" v-show="recentlyAdded.songs.length">
|
||||
<li v-for="song in recentlyAdded.songs" :key="song.id">
|
||||
<song-card :song="song"/>
|
||||
<SongCard :song="song"/>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
@ -61,7 +61,7 @@
|
|||
<h1>Top Artists</h1>
|
||||
<ol class="two-cols top-artist-list">
|
||||
<li v-for="artist in top.artists" :key="artist.id">
|
||||
<artist-card :artist="artist" layout="compact"/>
|
||||
<ArtistCard :artist="artist" layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
@ -70,90 +70,78 @@
|
|||
<h1>Top Albums</h1>
|
||||
<ol class="two-cols top-album-list">
|
||||
<li v-for="album in top.albums" :key="album.id">
|
||||
<album-card :album="album" layout="compact"/>
|
||||
<AlbumCard :album="album" layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<to-top-button/>
|
||||
<ToTopButton/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import { sample } from 'lodash'
|
||||
import mixins from 'vue-typed-mixins'
|
||||
|
||||
import { eventBus } from '@/utils'
|
||||
import { songStore, albumStore, artistStore, recentlyPlayedStore, userStore, preferenceStore } from '@/stores'
|
||||
import infiniteScroll from '@/mixins/infinite-scroll.ts'
|
||||
import { albumStore, artistStore, preferenceStore, recentlyPlayedStore, songStore, userStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
import { useInfiniteScroll } from '@/composables'
|
||||
import { computed, defineAsyncComponent, reactive, ref } from 'vue'
|
||||
|
||||
export default mixins(infiniteScroll).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
AlbumCard: () => import('@/components/album/card.vue'),
|
||||
ArtistCard: () => import('@/components/artist/card.vue'),
|
||||
SongCard: () => import('@/components/song/card.vue'),
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const AlbumCard = defineAsyncComponent(() => import('@/components/album/card.vue'))
|
||||
const ArtistCard = defineAsyncComponent(() => import('@/components/artist/card.vue'))
|
||||
const SongCard = defineAsyncComponent(() => import('@/components/song/card.vue'))
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
greetings: [
|
||||
'Oh hai!',
|
||||
'Hey, %s!',
|
||||
'Howdy, %s!',
|
||||
'Yo!',
|
||||
'How’s it going, %s?',
|
||||
'Sup, %s?',
|
||||
'How’s life, %s?',
|
||||
'How’s your day, %s?',
|
||||
'How have you been, %s?'
|
||||
],
|
||||
recentSongs: [] as Song[],
|
||||
top: {
|
||||
songs: [] as Song[],
|
||||
albums: [] as Album[],
|
||||
artists: [] as Artist[]
|
||||
},
|
||||
recentlyAdded: {
|
||||
albums: [] as Album[],
|
||||
songs: [] as Song[]
|
||||
},
|
||||
const { ToTopButton, scrolling } = useInfiniteScroll()
|
||||
|
||||
preferences: preferenceStore.state
|
||||
}),
|
||||
const greetings = [
|
||||
'Oh hai!',
|
||||
'Hey, %s!',
|
||||
'Howdy, %s!',
|
||||
'Yo!',
|
||||
'How’s it going, %s?',
|
||||
'Sup, %s?',
|
||||
'How’s life, %s?',
|
||||
'How’s your day, %s?',
|
||||
'How have you been, %s?'
|
||||
]
|
||||
|
||||
computed: {
|
||||
greeting (): string {
|
||||
return sample(this.greetings)!.replace('%s', userStore.current.name)
|
||||
},
|
||||
const recentSongs = ref<Song[]>([])
|
||||
|
||||
showRecentlyAddedSection (): boolean {
|
||||
return Boolean(this.recentlyAdded.albums.length || this.recentlyAdded.songs.length)
|
||||
}
|
||||
},
|
||||
const top = reactive({
|
||||
songs: [] as Song[],
|
||||
albums: [] as Album[],
|
||||
artists: [] as Artist[]
|
||||
})
|
||||
|
||||
methods: {
|
||||
refreshDashboard (): void {
|
||||
this.top.songs = songStore.getMostPlayed(7)
|
||||
this.top.albums = albumStore.getMostPlayed(6)
|
||||
this.top.artists = artistStore.getMostPlayed(6)
|
||||
this.recentlyAdded.albums = albumStore.getRecentlyAdded(6)
|
||||
this.recentlyAdded.songs = songStore.getRecentlyAdded(10)
|
||||
this.recentSongs = recentlyPlayedStore.excerptState.songs
|
||||
},
|
||||
const recentlyAdded = reactive({
|
||||
albums: [] as Album[],
|
||||
songs: [] as Song[]
|
||||
})
|
||||
|
||||
goToRecentlyPlayedScreen: (): void => router.go('recently-played')
|
||||
},
|
||||
const preferences = reactive(preferenceStore.state)
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'KOEL_READY': (): void => this.refreshDashboard(),
|
||||
'SONG_STARTED': (): void => this.refreshDashboard(),
|
||||
'SONG_UPLOADED': (): void => this.refreshDashboard()
|
||||
})
|
||||
}
|
||||
const greeting = computed(() => sample(greetings)!.replace('%s', userStore.current.name))
|
||||
const showRecentlyAddedSection = computed(() => Boolean(recentlyAdded.albums.length || recentlyAdded.songs.length))
|
||||
|
||||
const refreshDashboard = () => {
|
||||
top.songs = songStore.getMostPlayed(7)
|
||||
top.albums = albumStore.getMostPlayed(6)
|
||||
top.artists = artistStore.getMostPlayed(6)
|
||||
recentlyAdded.albums = albumStore.getRecentlyAdded(6)
|
||||
recentlyAdded.songs = songStore.getRecentlyAdded(10)
|
||||
recentSongs.value = recentlyPlayedStore.excerptState.songs
|
||||
}
|
||||
|
||||
const goToRecentlyPlayedScreen = () => router.go('recently-played')
|
||||
|
||||
eventBus.on({
|
||||
'KOEL_READY': () => refreshDashboard(),
|
||||
'SONG_STARTED': () => refreshDashboard(),
|
||||
'SONG_UPLOADED': () => refreshDashboard()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<section id="playlistWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
{{ playlist.name }}
|
||||
<controls-toggler v-if="playlist.populated" :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler v-if="playlist.populated" :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:meta>
|
||||
<span class="meta" v-if="playlist.populated && meta.songCount">
|
||||
{{ meta.songCount | pluralize('song') }}
|
||||
{{ pluralize(meta.songCount, 'song') }}
|
||||
•
|
||||
{{ meta.totalLength }}
|
||||
<template v-if="sharedState.allowDownload && playlist.songs.length">
|
||||
|
@ -19,7 +19,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<song-list-controls
|
||||
<SongListControls
|
||||
v-if="playlist.populated && (!isPhone || showingControls)"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
|
@ -29,10 +29,10 @@
|
|||
:selectedSongs="selectedSongs"
|
||||
/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<template v-if="playlist.populated">
|
||||
<song-list
|
||||
<SongList
|
||||
v-if="playlist.songs.length"
|
||||
:items="playlist.songs"
|
||||
:playlist="playlist"
|
||||
|
@ -40,7 +40,7 @@
|
|||
ref="songList"
|
||||
/>
|
||||
|
||||
<screen-placeholder v-else>
|
||||
<ScreenPlaceholder v-else>
|
||||
<template v-slot:icon>
|
||||
<i class="fa fa-file-o"></i>
|
||||
</template>
|
||||
|
@ -56,75 +56,69 @@
|
|||
or use the "Add To…" button to fill it up.
|
||||
</span>
|
||||
</template>
|
||||
</screen-placeholder>
|
||||
</ScreenPlaceholder>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import { pluralize, eventBus } from '@/utils'
|
||||
<script lang="ts" setup>
|
||||
import { eventBus } from '@/utils'
|
||||
import { playlistStore, sharedStore } from '@/stores'
|
||||
import { download } from '@/services'
|
||||
import hasSongList from '@/mixins/has-song-list.ts'
|
||||
import { download as downloadService } from '@/services'
|
||||
import { useSongList } from '@/composables'
|
||||
import { defineAsyncComponent, nextTick, reactive, ref } from 'vue'
|
||||
import { pluralize } from '@/utils'
|
||||
|
||||
export default mixins(hasSongList).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ScreenPlaceholder: () => import('@/components/ui/screen-placeholder.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ScreenPlaceholder = defineAsyncComponent(() => import('@/components/ui/screen-placeholder.vue'))
|
||||
|
||||
filters: { pluralize },
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
meta,
|
||||
state,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playAll,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList({
|
||||
deletePlaylist: true
|
||||
})
|
||||
|
||||
data: () => ({
|
||||
playlist: playlistStore.stub,
|
||||
sharedState: sharedStore.state,
|
||||
songListControlConfig: {
|
||||
deletePlaylist: true
|
||||
}
|
||||
}),
|
||||
const playlist = ref<Playlist>(playlistStore.stub)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
created (): void {
|
||||
/**
|
||||
* Listen to 'main-content-view:load' event to load the requested
|
||||
* playlist into view if applicable.
|
||||
*/
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, playlist: Playlist): void => {
|
||||
if (view !== 'Playlist') {
|
||||
return
|
||||
}
|
||||
const destroy = () => eventBus.emit('PLAYLIST_DELETE', playlist.value)
|
||||
const download = () => downloadService.fromPlaylist(playlist.value)
|
||||
const editSmartPlaylist = () => eventBus.emit('MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', playlist.value)
|
||||
|
||||
if (playlist.populated) {
|
||||
this.playlist = playlist
|
||||
this.state = playlist
|
||||
} else {
|
||||
this.populate(playlist)
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Fetch a playlist's content from the server, populate it, and use it afterwards.
|
||||
*/
|
||||
const populate = async (_playlist: Playlist) => {
|
||||
await playlistStore.fetchSongs(_playlist)
|
||||
playlist.value = _playlist
|
||||
state.songs = playlist.value.songs
|
||||
await nextTick()
|
||||
// @ts-ignore
|
||||
songList.value?.sort()
|
||||
}
|
||||
|
||||
methods: {
|
||||
destroy (): void {
|
||||
eventBus.emit('PLAYLIST_DELETE', this.playlist)
|
||||
},
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void => {
|
||||
if (view !== 'Playlist') {
|
||||
return
|
||||
}
|
||||
|
||||
download (): void {
|
||||
return download.fromPlaylist(this.playlist)
|
||||
},
|
||||
|
||||
editSmartPlaylist (): void {
|
||||
eventBus.emit('MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', this.playlist)
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a playlist's content from the server, populate it, and use it afterwards.
|
||||
*/
|
||||
async populate (playlist: Playlist): Promise<void> {
|
||||
await playlistStore.fetchSongs(playlist)
|
||||
this.playlist = playlist
|
||||
this.state = playlist
|
||||
this.$nextTick(() => this.$refs.songList && (this.$refs.songList as any).sort())
|
||||
}
|
||||
if (_playlist.populated) {
|
||||
playlist.value = _playlist
|
||||
state.songs = playlist.value.songs
|
||||
} else {
|
||||
populate(_playlist)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,28 +1,24 @@
|
|||
<template>
|
||||
<section id="profileWrapper">
|
||||
<screen-header>Profile & Preferences</screen-header>
|
||||
<ScreenHeader>Profile & Preferences</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap">
|
||||
<profile-form/>
|
||||
<themes/>
|
||||
<preferences/>
|
||||
<lastfm-integration/>
|
||||
<ProfileForm/>
|
||||
<Themes/>
|
||||
<Preferences/>
|
||||
<LastfmIntegration/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ProfileForm: () => import('@/components/profile-preferences/profile-form.vue'),
|
||||
LastfmIntegration: () => import('@/components/profile-preferences/lastfm-integration.vue'),
|
||||
Preferences: () => import('@/components/profile-preferences/preferences.vue'),
|
||||
Themes: () => import('@/components/profile-preferences/themes.vue')
|
||||
}
|
||||
})
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ProfileForm = defineAsyncComponent(() => import('@/components/profile-preferences/profile-form.vue'))
|
||||
const LastfmIntegration = defineAsyncComponent(() => import('@/components/profile-preferences/lastfm-integration.vue'))
|
||||
const Preferences = defineAsyncComponent(() => import('@/components/profile-preferences/preferences.vue'))
|
||||
const Themes = defineAsyncComponent(() => import('@/components/profile-preferences/themes.vue'))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
<template v-slot:meta>
|
||||
<span v-if="meta.songCount" data-test="list-meta">
|
||||
{{ meta.songCount | pluralize('song') }} • {{ meta.totalLength }}
|
||||
{{ pluralize(meta.songCount, 'song') }} • {{ meta.totalLength }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
@ -45,42 +45,42 @@
|
|||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
<script lang="ts" setup>
|
||||
import { pluralize } from '@/utils'
|
||||
import { queueStore, songStore } from '@/stores'
|
||||
import { playback } from '@/services'
|
||||
import hasSongList from '@/mixins/has-song-list.ts'
|
||||
import { useSongList } from '@/composables'
|
||||
import { computed, defineAsyncComponent, reactive } from 'vue'
|
||||
|
||||
export default mixins(hasSongList).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ScreenPlaceholder: () => import('@/components/ui/screen-placeholder.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ScreenPlaceholder = defineAsyncComponent(() => import('@/components/ui/screen-placeholder.vue'))
|
||||
|
||||
filters: { pluralize },
|
||||
|
||||
data: () => ({
|
||||
state: queueStore.state,
|
||||
songState: songStore.state,
|
||||
songListControlConfig: {
|
||||
clearQueue: true
|
||||
}
|
||||
}),
|
||||
|
||||
computed: {
|
||||
shouldShowShufflingAllLink (): boolean {
|
||||
return this.songState.songs.length > 0
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getSongsToPlay (): Song[] {
|
||||
return this.state.songs.length ? (this.$refs.songList as any).getAllSongsWithSort() : songStore.all
|
||||
},
|
||||
|
||||
shuffleAll: async () => await playback.queueAndPlay(songStore.all, true),
|
||||
clearQueue: (): void => queueStore.clear()
|
||||
}
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
meta,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList({
|
||||
clearQueue: true
|
||||
})
|
||||
|
||||
const state = reactive(queueStore.state)
|
||||
const songState = reactive(songStore.state)
|
||||
|
||||
const shouldShowShufflingAllLink = computed(() => songState.songs.length > 0)
|
||||
|
||||
const playAll = () => {
|
||||
// @ts-ignore
|
||||
playback.queueAndPlay(state.songs.length ? songList.value?.getAllSongsWithSort() : songStore.all)
|
||||
}
|
||||
|
||||
const shuffleAll = async () => await playback.queueAndPlay(songStore.all, true)
|
||||
const clearQueue = () => queueStore.clear()
|
||||
</script>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
<template>
|
||||
<section id="recentlyPlayedWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
Recently Played
|
||||
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:meta>
|
||||
<span v-if="meta.songCount">{{ meta.songCount | pluralize('song') }} • {{ meta.totalLength }}</span>
|
||||
<span v-if="meta.songCount">{{ pluralize(meta.songCount, 'song') }} • {{ meta.totalLength }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<song-list-controls
|
||||
<SongListControls
|
||||
v-if="state.songs.length && (!isPhone || showingControls)"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
|
@ -18,11 +18,11 @@
|
|||
:selectedSongs="selectedSongs"
|
||||
/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<song-list v-if="state.songs.length" :items="state.songs" type="recently-played" :sortable="false"/>
|
||||
<SongList v-if="state.songs.length" :items="state.songs" type="recently-played" :sortable="false"/>
|
||||
|
||||
<screen-placeholder v-else>
|
||||
<ScreenPlaceholder v-else>
|
||||
<template v-slot:icon>
|
||||
<i class="fa fa-clock-o"></i>
|
||||
</template>
|
||||
|
@ -30,43 +30,40 @@
|
|||
<span class="secondary d-block">
|
||||
Start playing to populate this playlist.
|
||||
</span>
|
||||
</screen-placeholder>
|
||||
</ScreenPlaceholder>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import { eventBus, pluralize } from '@/utils'
|
||||
import { recentlyPlayedStore } from '@/stores'
|
||||
import hasSongList from '@/mixins/has-song-list.ts'
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import { useSongList } from '@/composables'
|
||||
import { defineAsyncComponent, reactive } from 'vue'
|
||||
import { playback } from '@/services'
|
||||
|
||||
export default mixins(hasSongList).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ScreenPlaceholder: () => import('@/components/ui/screen-placeholder.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ScreenPlaceholder = defineAsyncComponent(() => import('@/components/ui/screen-placeholder.vue'))
|
||||
|
||||
filters: { pluralize },
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
meta,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList()
|
||||
|
||||
data: () => ({
|
||||
state: recentlyPlayedStore.state
|
||||
}),
|
||||
const state = reactive(recentlyPlayedStore.state)
|
||||
|
||||
methods: {
|
||||
getSongsToPlay (): Song[] {
|
||||
return this.state.songs
|
||||
}
|
||||
},
|
||||
const playAll = () => playback.queueAndPlay(state.songs)
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'LOAD_MAIN_CONTENT': (view: MainViewName): void => {
|
||||
if (view === 'RecentlyPlayed') {
|
||||
recentlyPlayedStore.fetchAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
eventBus.on({
|
||||
'LOAD_MAIN_CONTENT': (view: MainViewName) => view === 'RecentlyPlayed' && recentlyPlayedStore.fetchAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
<template>
|
||||
<section id="searchExcerptsWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
<span v-if="q">Search Results for <strong>{{ q }}</strong></span>
|
||||
<span v-else>Search</span>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap" ref="wrapper">
|
||||
<div class="results" v-if="q">
|
||||
<section class="songs" data-testid="song-excerpts">
|
||||
<h1>
|
||||
Songs
|
||||
<btn
|
||||
<Btn
|
||||
v-if="searchState.excerpt.songs.length"
|
||||
@click.prevent="goToSongResults"
|
||||
rounded
|
||||
|
@ -19,10 +19,10 @@
|
|||
data-test="view-all-songs-btn"
|
||||
>
|
||||
View All
|
||||
</btn>
|
||||
</Btn>
|
||||
</h1>
|
||||
<ul v-if="searchState.excerpt.songs.length">
|
||||
<li v-for="song in searchState.excerpt.songs" :key="song.id" :song="song" is="song-card"/>
|
||||
<li v-for="song in searchState.excerpt.songs" :key="song.id" :song="song" is="SongCard"/>
|
||||
</ul>
|
||||
<p v-else>None found.</p>
|
||||
</section>
|
||||
|
@ -31,7 +31,7 @@
|
|||
<h1>Artists</h1>
|
||||
<ul v-if="searchState.excerpt.artists.length">
|
||||
<li v-for="artist in searchState.excerpt.artists" :key="artist.id">
|
||||
<artist-card :artist="artist" layout="compact"/>
|
||||
<ArtistCard :artist="artist" layout="compact"/>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>None found.</p>
|
||||
|
@ -41,57 +41,45 @@
|
|||
<h1>Albums</h1>
|
||||
<ul v-if="searchState.excerpt.albums.length">
|
||||
<li v-for="album in searchState.excerpt.albums" :key="album.id">
|
||||
<album-card :album="album" layout="compact"/>
|
||||
<AlbumCard :album="album" layout="compact"/>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>None found.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<screen-placeholder v-else>
|
||||
<ScreenPlaceholder v-else>
|
||||
<template v-slot:icon>
|
||||
<i class="fa fa-search"></i>
|
||||
</template>
|
||||
Find songs, artists, and albums,
|
||||
<span class="secondary d-block">all in one place.</span>
|
||||
</screen-placeholder>
|
||||
</ScreenPlaceholder>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { searchStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ScreenPlaceholder: () => import('@/components/ui/screen-placeholder.vue'),
|
||||
SongCard: () => import('@/components/song/card.vue'),
|
||||
ArtistCard: () => import('@/components/artist/card.vue'),
|
||||
AlbumCard: () => import('@/components/album/card.vue'),
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ScreenPlaceholder = defineAsyncComponent(() => import('@/components/ui/screen-placeholder.vue'))
|
||||
const SongCard = defineAsyncComponent(() => import('@/components/song/card.vue'))
|
||||
const ArtistCard = defineAsyncComponent(() => import('@/components/artist/card.vue'))
|
||||
const AlbumCard = defineAsyncComponent(() => import('@/components/album/card.vue'))
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
searchState: searchStore.state,
|
||||
q: ''
|
||||
}),
|
||||
const searchState = reactive(searchStore.state)
|
||||
const q = ref('')
|
||||
|
||||
methods: {
|
||||
goToSongResults () {
|
||||
router.go(`search/songs/${this.q}`)
|
||||
}
|
||||
},
|
||||
const goToSongResults = () => router.go(`search/songs/${q.value}`)
|
||||
|
||||
created () {
|
||||
eventBus.on('SEARCH_KEYWORDS_CHANGED', (q: string) => {
|
||||
this.q = q
|
||||
searchStore.excerptSearch(q)
|
||||
})
|
||||
}
|
||||
eventBus.on('SEARCH_KEYWORDS_CHANGED', (_q: string) => {
|
||||
q.value = _q
|
||||
searchStore.excerptSearch(q.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -113,7 +101,7 @@ section ul {
|
|||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
grid-gap: .7em 1em;
|
||||
|
||||
@media only screen and (max-width : 667px) {
|
||||
@media only screen and (max-width: 667px) {
|
||||
display: block;
|
||||
|
||||
> * + * {
|
||||
|
|
|
@ -1,62 +1,60 @@
|
|||
<template>
|
||||
<section id="songResultsWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
Showing Songs for <strong>{{ decodedQ }}</strong>
|
||||
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:meta>
|
||||
<span v-if="meta.songCount">{{ meta.songCount | pluralize('song') }} • {{ meta.totalLength }}</span>
|
||||
<span v-if="meta.songCount">{{ pluralize(meta.songCount, 'song') }} • {{ meta.totalLength }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:controls>
|
||||
<song-list-controls
|
||||
<SongListControls
|
||||
v-if="state.songs.length && (!isPhone || showingControls)"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
:songs="state.songs"
|
||||
:config="songListControlConfig"
|
||||
:selectedSongs="selectedSongs"
|
||||
:songs="state.songs"
|
||||
@playAll="playAll"
|
||||
@playSelected="playSelected"
|
||||
/>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<song-list :items="state.songs" type="search-results" ref="songList"/>
|
||||
<SongList ref="songList" :items="state.songs" type="search-results"/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import { searchStore } from '@/stores'
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import hasSongList from '@/mixins/has-song-list'
|
||||
import { computed, defineAsyncComponent, reactive, toRefs } from 'vue'
|
||||
import { useSongList } from '@/composables'
|
||||
import { pluralize } from '@/utils'
|
||||
|
||||
export default mixins(hasSongList).extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
|
||||
filters: { pluralize },
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggler,
|
||||
songList,
|
||||
state: songListState,
|
||||
meta,
|
||||
selectedSongs,
|
||||
showingControls,
|
||||
songListControlConfig,
|
||||
isPhone,
|
||||
playAll,
|
||||
playSelected,
|
||||
toggleControls
|
||||
} = useSongList()
|
||||
|
||||
props: {
|
||||
q: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
const props = defineProps<{ q: string }>()
|
||||
const { q } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
state: searchStore.state
|
||||
}),
|
||||
const state = reactive(searchStore.state)
|
||||
|
||||
computed: {
|
||||
decodedQ (): string {
|
||||
return decodeURIComponent(this.q)
|
||||
}
|
||||
},
|
||||
const decodedQ = computed(() => decodeURIComponent(q.value))
|
||||
|
||||
created () {
|
||||
searchStore.resetSongResultState()
|
||||
searchStore.songSearch(this.decodedQ)
|
||||
}
|
||||
})
|
||||
searchStore.resetSongResultState()
|
||||
searchStore.songSearch(decodedQ.value)
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<section id="settingsWrapper">
|
||||
<screen-header>Settings</screen-header>
|
||||
<ScreenHeader>Settings</ScreenHeader>
|
||||
|
||||
<form @submit.prevent="confirmThenSave" class="main-scroll-wrap">
|
||||
<div class="form-row">
|
||||
|
@ -16,73 +16,63 @@
|
|||
aria-describedby="mediaPathHelp"
|
||||
id="inputSettingsPath"
|
||||
type="text"
|
||||
v-model="state.settings.media_path"
|
||||
v-model="state.media_path"
|
||||
name="media_path"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<btn type="submit">Scan</btn>
|
||||
<Btn type="submit">Scan</Btn>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive } from 'vue'
|
||||
import { settingStore, sharedStore } from '@/stores'
|
||||
import { parseValidationError, forceReloadWindow, showOverlay, hideOverlay, alerts } from '@/utils'
|
||||
import { alerts, forceReloadWindow, hideOverlay, parseValidationError, showOverlay } from '@/utils'
|
||||
import router from '@/router'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
state: settingStore.state,
|
||||
sharedState: sharedStore.state
|
||||
}),
|
||||
const state = settingStore.state
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
|
||||
computed: {
|
||||
shouldWarn (): boolean {
|
||||
// Warn the user if the media path is not empty and about to change.
|
||||
if (!this.sharedState.originalMediaPath || !this.state.settings.media_path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.sharedState.originalMediaPath !== this.state.settings.media_path.trim()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
confirmThenSave (): void {
|
||||
if (this.shouldWarn) {
|
||||
alerts.confirm('Warning: Changing the media path will essentially remove all existing data – songs, artists, \
|
||||
albums, favorites, everything – and empty your playlists! Sure you want to proceed?', this.save)
|
||||
} else {
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
|
||||
save: async (): Promise<void> => {
|
||||
showOverlay()
|
||||
|
||||
try {
|
||||
await settingStore.update()
|
||||
// Make sure we're back to home first.
|
||||
router.go('home')
|
||||
forceReloadWindow()
|
||||
} catch (err) {
|
||||
hideOverlay()
|
||||
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
}
|
||||
}
|
||||
const shouldWarn = computed(() => {
|
||||
// Warn the user if the media path is not empty and about to change.
|
||||
if (!sharedState.originalMediaPath || !state.media_path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return sharedState.originalMediaPath !== state.media_path.trim()
|
||||
})
|
||||
|
||||
const save = async () => {
|
||||
showOverlay()
|
||||
|
||||
try {
|
||||
await settingStore.update()
|
||||
// Make sure we're back to home first.
|
||||
router.go('home')
|
||||
forceReloadWindow()
|
||||
} catch (err: any) {
|
||||
hideOverlay()
|
||||
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmThenSave = () => {
|
||||
if (shouldWarn.value) {
|
||||
alerts.confirm('Warning: Changing the media path will essentially remove all existing data – songs, artists, \
|
||||
albums, favorites, everything – and empty your playlists! Sure you want to proceed?', save)
|
||||
} else {
|
||||
save()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -92,7 +82,7 @@ export default Vue.extend({
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 667px) {
|
||||
@media only screen and (max-width: 667px) {
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
<template>
|
||||
<section id="uploadWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
Upload Media <sup>Beta</sup>
|
||||
|
||||
<template v-slot:controls>
|
||||
<btn-group uppercased v-if="hasUploadFailures">
|
||||
<btn @click="retryAll" green data-testid="upload-retry-all-btn">
|
||||
<BtnGroup uppercased v-if="hasUploadFailures">
|
||||
<Btn @click="retryAll" green data-testid="upload-retry-all-btn">
|
||||
<i class="fa fa-repeat"></i>
|
||||
Retry All
|
||||
</btn>
|
||||
<btn @click="removeFailedEntries" orange data-testid="upload-remove-all-btn">
|
||||
</Btn>
|
||||
<Btn @click="removeFailedEntries" orange data-testid="upload-remove-all-btn">
|
||||
<i class="fa fa-times"></i>
|
||||
Remove Failed
|
||||
</btn>
|
||||
</btn-group>
|
||||
</Btn>
|
||||
</BtnGroup>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap">
|
||||
<div
|
||||
|
@ -28,10 +28,10 @@
|
|||
v-if="mediaPath"
|
||||
>
|
||||
<div class="upload-files" v-if="uploadState.files.length">
|
||||
<upload-item v-for="file in uploadState.files" :key="file.id" :file="file" data-test="upload-item"/>
|
||||
<UploadItem v-for="file in uploadState.files" :key="file.id" :file="file" data-test="upload-item"/>
|
||||
</div>
|
||||
|
||||
<screen-placeholder v-else>
|
||||
<ScreenPlaceholder v-else>
|
||||
<template v-slot:icon>
|
||||
<i class="fa fa-upload"></i>
|
||||
</template>
|
||||
|
@ -40,135 +40,101 @@
|
|||
<span class="secondary d-block">
|
||||
<a class="or-click d-block" role="button">
|
||||
or click here to select songs
|
||||
<input type="file" name="file[]" multiple @change="onFileInputChange"/>
|
||||
<input type="file" name="file[]" multiple @change="onFileInputChange"/>
|
||||
</a>
|
||||
</span>
|
||||
</screen-placeholder>
|
||||
</ScreenPlaceholder>
|
||||
</div>
|
||||
|
||||
<screen-placeholder v-else>
|
||||
<ScreenPlaceholder v-else>
|
||||
<template v-slot:icon>
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
</template>
|
||||
No media path set.
|
||||
</screen-placeholder>
|
||||
</ScreenPlaceholder>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, reactive, ref, toRef } from 'vue'
|
||||
import ismobile from 'ismobilejs'
|
||||
import md5 from 'blueimp-md5'
|
||||
|
||||
import { settingStore, userStore } from '@/stores'
|
||||
import { getAllFileEntries, eventBus, isDirectoryReadingSupported } from '@/utils'
|
||||
import { UploadFile, validMediaMimeTypes, events } from '@/config'
|
||||
import { eventBus, getAllFileEntries, isDirectoryReadingSupported } from '@/utils'
|
||||
import { UploadFile, validMediaMimeTypes } from '@/config'
|
||||
import { upload } from '@/services'
|
||||
|
||||
import UploadItem from '@/components/ui/upload/upload-item.vue'
|
||||
import BtnGroup from '@/components/ui/btn-group.vue'
|
||||
import Btn from '@/components/ui/btn.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ScreenPlaceholder: () => import('@/components/ui/screen-placeholder.vue'),
|
||||
UploadItem,
|
||||
BtnGroup,
|
||||
Btn
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ScreenPlaceholder = defineAsyncComponent(() => import('@/components/ui/screen-placeholder.vue'))
|
||||
|
||||
data: () => ({
|
||||
settingsState: settingStore.state,
|
||||
droppable: false,
|
||||
userState: userStore.state,
|
||||
uploadState: upload.state,
|
||||
hasUploadFailures: false
|
||||
}),
|
||||
const mediaPath = toRef(settingStore.state, 'media_path')
|
||||
const droppable = ref(false)
|
||||
const userState = reactive(userStore.state)
|
||||
const uploadState = reactive(upload.state)
|
||||
const hasUploadFailures = ref(false)
|
||||
|
||||
computed: {
|
||||
mediaPath (): string | undefined {
|
||||
return this.settingsState.settings.media_path
|
||||
},
|
||||
const allowsUpload = computed(() => userState.current.is_admin && !ismobile.any)
|
||||
|
||||
allowsUpload (): boolean {
|
||||
return this.userState.current.is_admin && !ismobile.any
|
||||
},
|
||||
const instructionText = computed(() => isDirectoryReadingSupported
|
||||
? 'Drop files or folders to upload'
|
||||
: 'Drop files to upload'
|
||||
)
|
||||
|
||||
instructionText (): string {
|
||||
return isDirectoryReadingSupported
|
||||
? 'Drop files or folders to upload'
|
||||
: 'Drop files to upload'
|
||||
}
|
||||
},
|
||||
const onDragEnter = () => (droppable.value = allowsUpload.value)
|
||||
const onDragLeave = () => (droppable.value = false)
|
||||
|
||||
methods: {
|
||||
onDragEnter (): void {
|
||||
this.droppable = this.allowsUpload
|
||||
},
|
||||
const handleFiles = (files: Array<File>) => {
|
||||
const uploadCandidates = files
|
||||
.filter(file => validMediaMimeTypes.includes(file.type))
|
||||
.map((file): UploadFile => ({
|
||||
file,
|
||||
id: md5(`${file.name}-${file.size}`), // for simplicity, a file's identity is determined by its name and size
|
||||
status: 'Ready',
|
||||
name: file.name,
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
onDragLeave (): void {
|
||||
this.droppable = false
|
||||
},
|
||||
upload.queue(uploadCandidates)
|
||||
}
|
||||
|
||||
onFileInputChange (event: InputEvent): void {
|
||||
const selectedFileList = (event.target as HTMLInputElement).files
|
||||
|
||||
if (!selectedFileList) {
|
||||
return
|
||||
}
|
||||
|
||||
this.handleFiles(Array.from(selectedFileList))
|
||||
},
|
||||
|
||||
async onDrop (e: DragEvent): Promise<void> {
|
||||
this.droppable = false
|
||||
|
||||
if (!e.dataTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileEntries = await getAllFileEntries(e.dataTransfer.items)
|
||||
const files = await Promise.all(fileEntries.map(async entry => await this.fileEntryToFile(entry)))
|
||||
this.handleFiles(files)
|
||||
},
|
||||
|
||||
handleFiles: (files: Array<File>) => {
|
||||
const uploadCandidates = files
|
||||
.filter(file => validMediaMimeTypes.includes(file.type))
|
||||
.map((file: File): UploadFile => ({
|
||||
file,
|
||||
id: md5(`${file.name}-${file.size}`), // for simplicity, a file's identity is determined by its name and size
|
||||
status: 'Ready',
|
||||
name: file.name,
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
upload.queue(uploadCandidates)
|
||||
},
|
||||
|
||||
fileEntryToFile: async (entry: FileSystemEntry): Promise<File> => new Promise(resolve => {
|
||||
entry.file((file: File) => resolve(file))
|
||||
}),
|
||||
|
||||
retryAll (): void {
|
||||
upload.retryAll()
|
||||
this.hasUploadFailures = false
|
||||
},
|
||||
|
||||
removeFailedEntries (): void {
|
||||
upload.removeFailed()
|
||||
this.hasUploadFailures = false
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
eventBus.on('UPLOAD_QUEUE_FINISHED', (): void => {
|
||||
this.hasUploadFailures = upload.getFilesByStatus('Errored').length !== 0
|
||||
})
|
||||
}
|
||||
const fileEntryToFile = async (entry: FileSystemEntry): Promise<File> => new Promise(resolve => {
|
||||
entry.file(resolve)
|
||||
})
|
||||
|
||||
const onFileInputChange = (event: InputEvent) => {
|
||||
const selectedFileList = (event.target as HTMLInputElement).files
|
||||
selectedFileList && handleFiles(Array.from(selectedFileList))
|
||||
}
|
||||
|
||||
const onDrop = async (event: DragEvent) => {
|
||||
droppable.value = false
|
||||
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileEntries = await getAllFileEntries(event.dataTransfer.items)
|
||||
const files = await Promise.all(fileEntries.map(async entry => await fileEntryToFile(entry)))
|
||||
handleFiles(files)
|
||||
}
|
||||
|
||||
const retryAll = () => {
|
||||
upload.retryAll()
|
||||
hasUploadFailures.value = false
|
||||
}
|
||||
|
||||
const removeFailedEntries = () => {
|
||||
upload.removeFailed()
|
||||
hasUploadFailures.value = false
|
||||
}
|
||||
|
||||
eventBus.on('UPLOAD_QUEUE_FINISHED', () => (hasUploadFailures.value = upload.getFilesByStatus('Errored').length !== 0))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -1,63 +1,47 @@
|
|||
<template>
|
||||
<section id="usersWrapper">
|
||||
<screen-header>
|
||||
<ScreenHeader>
|
||||
Users
|
||||
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
<ControlsToggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
<template v-slot:controls>
|
||||
<btn-group uppercased v-if="showingControls || !isPhone">
|
||||
<btn class="btn-add" @click="showAddUserForm" green data-testid="add-user-btn">
|
||||
<BtnGroup uppercased v-if="showingControls || !isPhone">
|
||||
<Btn class="btn-add" @click="showAddUserForm" green data-testid="add-user-btn">
|
||||
<i class="fa fa-plus"></i>
|
||||
Add
|
||||
</btn>
|
||||
</btn-group>
|
||||
</Btn>
|
||||
</BtnGroup>
|
||||
</template>
|
||||
</screen-header>
|
||||
</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap">
|
||||
<div class="users">
|
||||
<user-card v-for="user in state.users" :user="user" @editUser="showEditUserForm" :key="user.id"/>
|
||||
<UserCard v-for="user in state.users" :user="user" @editUser="showEditUserForm" :key="user.id"/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue'
|
||||
import isMobile from 'ismobilejs'
|
||||
|
||||
import { userStore } from '@/stores'
|
||||
import { eventBus } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ControlsToggler: () => import('@/components/ui/screen-controls-toggler.vue'),
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
BtnGroup: () => import('@/components/ui/btn-group.vue'),
|
||||
UserCard: () => import('@/components/user/card.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ControlsToggler = defineAsyncComponent(() => import('@/components/ui/screen-controls-toggler.vue'))
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const BtnGroup = defineAsyncComponent(() => import('@/components/ui/btn-group.vue'))
|
||||
const UserCard = defineAsyncComponent(() => import('@/components/user/card.vue'))
|
||||
|
||||
data: () => ({
|
||||
state: userStore.state,
|
||||
isPhone: isMobile.phone,
|
||||
showingControls: false
|
||||
}),
|
||||
const state = reactive(userStore.state)
|
||||
const isPhone = isMobile.phone
|
||||
const showingControls = ref(false)
|
||||
|
||||
methods: {
|
||||
toggleControls (): void {
|
||||
this.showingControls = !this.showingControls
|
||||
},
|
||||
|
||||
showAddUserForm: (): void => {
|
||||
eventBus.emit('MODAL_SHOW_ADD_USER_FORM')
|
||||
},
|
||||
|
||||
showEditUserForm: (user: User): void => {
|
||||
eventBus.emit('MODAL_SHOW_EDIT_USER_FORM', user)
|
||||
}
|
||||
}
|
||||
})
|
||||
const toggleControls = () => (showingControls.value = !showingControls.value)
|
||||
const showAddUserForm = () => eventBus.emit('MODAL_SHOW_ADD_USER_FORM')
|
||||
const showEditUserForm = (user: User) => eventBus.emit('MODAL_SHOW_EDIT_USER_FORM', user)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -16,65 +16,47 @@
|
|||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { YouTubePlayer } from 'youtube-player/dist/types'
|
||||
import { eventBus } from '@/utils'
|
||||
import { playback } from '@/services'
|
||||
import createYouTubePlayer from 'youtube-player'
|
||||
|
||||
let player: YouTubePlayer
|
||||
let player: YouTubePlayer|null = null
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ScreenHeader: () => import('@/components/ui/screen-header.vue'),
|
||||
ScreenPlaceholder: () => import('@/components/ui/screen-placeholder.vue')
|
||||
},
|
||||
const ScreenHeader = defineAsyncComponent(() => import('@/components/ui/screen-header.vue'))
|
||||
const ScreenPlaceholder = defineAsyncComponent(() => import('@/components/ui/screen-placeholder.vue'))
|
||||
|
||||
data: () => ({
|
||||
title: 'YouTube Video'
|
||||
}),
|
||||
const title = ref('YouTube Video')
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Initialize the YouTube player. This should only be called once.
|
||||
*/
|
||||
initPlayer (): void {
|
||||
if (!player) {
|
||||
player = createYouTubePlayer('player', {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
})
|
||||
|
||||
// Pause song playback when video is played
|
||||
player.on('stateChange', (event: any): void => {
|
||||
if (event.data === 1) {
|
||||
playback.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'PLAY_YOUTUBE_VIDEO': ({ id, title }: { id: string, title: string }): void => {
|
||||
this.title = title
|
||||
this.initPlayer()
|
||||
player.loadVideoById(id)
|
||||
player.playVideo()
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop video playback when a song is played/resumed.
|
||||
*/
|
||||
'SONG_STARTED': (): void => {
|
||||
if (player) {
|
||||
player.pauseVideo()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Initialize the YouTube player. This should only be called once.
|
||||
*/
|
||||
const maybeInitPlayer = () => {
|
||||
if (!player) {
|
||||
player = createYouTubePlayer('player', {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
})
|
||||
|
||||
// Pause song playback when video is played
|
||||
player.on('stateChange', ({ data }) => data === 1 && playback.pause())
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.on({
|
||||
'PLAY_YOUTUBE_VIDEO': (payload: { id: string, title: string }) => {
|
||||
title.value = payload.title
|
||||
maybeInitPlayer()
|
||||
player!.loadVideoById(payload.id)
|
||||
player!.playVideo()
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop video playback when a song is played/resumed.
|
||||
*/
|
||||
'SONG_STARTED': () => player && player.pauseVideo()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
data-test="add-to-menu"
|
||||
>
|
||||
<section class="existing-playlists">
|
||||
<p>Add {{ songs.length | pluralize('song') }} to</p>
|
||||
<p>Add {{ pluralize(songs.length, 'song') }} to</p>
|
||||
|
||||
<ul>
|
||||
<template v-if="config.queue">
|
||||
|
@ -53,81 +53,61 @@
|
|||
v-model="newPlaylistName"
|
||||
data-test="new-playlist-name"
|
||||
>
|
||||
<btn type="submit" title="Save">⏎</btn>
|
||||
<Btn type="submit" title="Save">⏎</Btn>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, nextTick, reactive, ref, toRefs, watch } from 'vue'
|
||||
import { pluralize } from '@/utils'
|
||||
import { playlistStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
import songMenuMethods from '@/mixins/song-menu-methods.ts'
|
||||
import { PropOptions } from 'vue'
|
||||
import { useSongMenuMethods } from '@/composables'
|
||||
|
||||
export default mixins(songMenuMethods).extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
props: {
|
||||
showing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
config: {
|
||||
type: Object
|
||||
} as PropOptions<AddToMenuConfig>
|
||||
},
|
||||
const props = defineProps<{ songs: Song[], showing: Boolean, config: AddToMenuConfig }>()
|
||||
const { songs, showing, config } = toRefs(props)
|
||||
|
||||
filters: { pluralize },
|
||||
const newPlaylistName = ref('')
|
||||
const playlistState = reactive(playlistStore.state)
|
||||
|
||||
data: () => ({
|
||||
newPlaylistName: '',
|
||||
playlistState: playlistStore.state
|
||||
}),
|
||||
const emit = defineEmits(['closing'])
|
||||
const close = () => emit('closing')
|
||||
|
||||
watch: {
|
||||
songs (): void {
|
||||
if (!this.songs.length) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
const {
|
||||
queueSongsAfterCurrent,
|
||||
queueSongsToBottom,
|
||||
queueSongsToTop,
|
||||
addSongsToFavorite,
|
||||
addSongsToExistingPlaylist
|
||||
} = useSongMenuMethods(songs.value, close)
|
||||
|
||||
computed: {
|
||||
playlists (): Playlist[] {
|
||||
return this.playlistState.playlists.filter(playlist => !playlist.is_smart)
|
||||
}
|
||||
},
|
||||
watch(songs, () => songs.value.length || close())
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Save the selected songs as a playlist.
|
||||
* As of current we don't have selective save.
|
||||
*/
|
||||
async createNewPlaylistFromSongs (): Promise<void> {
|
||||
this.newPlaylistName = this.newPlaylistName.trim()
|
||||
const playlists = computed(() => playlistState.playlists.filter(playlist => !playlist.is_smart))
|
||||
|
||||
if (!this.newPlaylistName) {
|
||||
return
|
||||
}
|
||||
/**
|
||||
* Save the selected songs as a playlist.
|
||||
* As of current we don't have selective save.
|
||||
*/
|
||||
const createNewPlaylistFromSongs = async () => {
|
||||
newPlaylistName.value = newPlaylistName.value.trim()
|
||||
|
||||
const playlist = await playlistStore.store(this.newPlaylistName, this.songs)
|
||||
this.newPlaylistName = ''
|
||||
// Activate the new playlist right away
|
||||
this.$nextTick((): void => router.go(`playlist/${playlist.id}`))
|
||||
|
||||
this.close()
|
||||
},
|
||||
|
||||
close (): void {
|
||||
this.$emit('closing')
|
||||
}
|
||||
if (!newPlaylistName.value) {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const playlist = await playlistStore.store(newPlaylistName.value, songs.value)
|
||||
newPlaylistName.value = ''
|
||||
// Activate the new playlist right away
|
||||
await nextTick()
|
||||
router.go(`playlist/${playlist.id}`)
|
||||
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<article
|
||||
:class="{ playing: song.playbackState === 'Playing' || song.playbackState === 'Paused' }"
|
||||
@contextmenu.prevent="requestContextMenu"
|
||||
@contextmenu.stop.prevent="requestContextMenu"
|
||||
@dblclick.prevent="play"
|
||||
@dragstart="dragStart"
|
||||
draggable="true"
|
||||
|
@ -20,7 +20,7 @@
|
|||
{{ song.title }}
|
||||
<span class="by text-secondary">
|
||||
<a :href="`#!/artist/${song.artist.id}`">{{ song.artist.name }}</a>
|
||||
<template v-if="showPlayCount">- {{ song.playCount | pluralize('play') }}</template>
|
||||
<template v-if="showPlayCount"> - {{ pluralize(song.playCount, 'play') }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="favorite">
|
||||
|
@ -30,62 +30,36 @@
|
|||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { eventBus, startDragging, pluralize } from '@/utils'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, startDragging } from '@/utils'
|
||||
import { queueStore } from '@/stores'
|
||||
import { playback } from '@/services'
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
LikeButton: () => import('@/components/song/like-button.vue')
|
||||
},
|
||||
const LikeButton = defineAsyncComponent(() => import('@/components/song/like-button.vue'))
|
||||
|
||||
props: {
|
||||
song: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Song>,
|
||||
const props = withDefaults(defineProps<{ song: Song, topPlayCount: number }>(), { topPlayCount: 0 })
|
||||
const { song, topPlayCount } = toRefs(props)
|
||||
|
||||
topPlayCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
const showPlayCount = computed(() => Boolean(topPlayCount && song.value.playCount))
|
||||
|
||||
filters: { pluralize },
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, song.value, 'Song')
|
||||
|
||||
computed: {
|
||||
showPlayCount (): boolean {
|
||||
return Boolean(this.topPlayCount && this.song.playCount)
|
||||
}
|
||||
},
|
||||
const play = () => {
|
||||
queueStore.contains(song.value) || queueStore.queueAfterCurrent(song.value)
|
||||
playback.play(song.value)
|
||||
}
|
||||
|
||||
methods: {
|
||||
requestContextMenu (e: MouseEvent): void {
|
||||
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', e, this.song)
|
||||
},
|
||||
|
||||
play (): void {
|
||||
queueStore.contains(this.song) || queueStore.queueAfterCurrent(this.song)
|
||||
playback.play(this.song)
|
||||
},
|
||||
|
||||
changeSongState (): void {
|
||||
if (this.song.playbackState === 'Stopped') {
|
||||
this.play()
|
||||
} else if (this.song.playbackState === 'Paused') {
|
||||
playback.resume()
|
||||
} else {
|
||||
playback.pause()
|
||||
}
|
||||
},
|
||||
|
||||
dragStart (event: DragEvent): void {
|
||||
startDragging(event, this.song, 'Song')
|
||||
}
|
||||
const changeSongState = () => {
|
||||
if (song.value.playbackState === 'Stopped') {
|
||||
play()
|
||||
} else if (song.value.playbackState === 'Paused') {
|
||||
playback.resume()
|
||||
} else {
|
||||
playback.pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<base-context-menu extra-class="song-menu" ref="base" data-testid="song-context-menu">
|
||||
<template v-show="onlyOneSongSelected">
|
||||
<BaseContextMenu extra-class="song-menu" ref="base" data-testid="song-context-menu">
|
||||
<template v-if="onlyOneSongSelected">
|
||||
<li class="playback" @click.stop.prevent="doPlayback">
|
||||
<span v-if="firstSongPlaying">Pause</span>
|
||||
<span v-else>Play</span>
|
||||
|
@ -35,107 +35,89 @@
|
|||
>
|
||||
Copy Shareable URL
|
||||
</li>
|
||||
</base-context-menu>
|
||||
</BaseContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import { BaseContextMenu } from 'koel/types/ui'
|
||||
import { eventBus, isClipboardSupported, copyText } from '@/utils'
|
||||
import { sharedStore, songStore, queueStore, userStore, playlistStore } from '@/stores'
|
||||
import { playback, download } from '@/services'
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, toRefs } from 'vue'
|
||||
import { copyText, eventBus, isClipboardSupported as copyable } from '@/utils'
|
||||
import { playlistStore, queueStore, sharedStore, songStore, userStore } from '@/stores'
|
||||
import { download as downloadService, playback } from '@/services'
|
||||
import router from '@/router'
|
||||
import songMenuMethods from '@/mixins/song-menu-methods.ts'
|
||||
import { useContextMenu, useSongMenuMethods } from '@/composables'
|
||||
|
||||
export default mixins(songMenuMethods).extend({
|
||||
components: {
|
||||
BaseContextMenu: () => import('@/components/ui/context-menu.vue')
|
||||
},
|
||||
const props = defineProps<{ songs: Song[] }>()
|
||||
const { songs } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
playlistState: playlistStore.state,
|
||||
sharedState: sharedStore.state,
|
||||
copyable: isClipboardSupported,
|
||||
userState: userStore.state
|
||||
}),
|
||||
const {
|
||||
base,
|
||||
BaseContextMenu,
|
||||
open,
|
||||
close
|
||||
} = useContextMenu()
|
||||
|
||||
computed: {
|
||||
onlyOneSongSelected (): boolean {
|
||||
return this.songs.length === 1
|
||||
},
|
||||
const {
|
||||
queueSongsAfterCurrent,
|
||||
queueSongsToBottom,
|
||||
queueSongsToTop,
|
||||
addSongsToFavorite,
|
||||
addSongsToExistingPlaylist
|
||||
} = useSongMenuMethods(songs.value, close)
|
||||
|
||||
firstSongPlaying (): boolean {
|
||||
return this.songs[0] ? this.songs[0].playbackState === 'Playing' : false
|
||||
},
|
||||
const playlistState = reactive(playlistStore.state)
|
||||
const sharedState = reactive(sharedStore.state)
|
||||
const userState = reactive(userStore.state)
|
||||
|
||||
normalPlaylists (): Playlist[] {
|
||||
return this.playlistState.playlists.filter(playlist => !playlist.is_smart)
|
||||
},
|
||||
const onlyOneSongSelected = computed(() => songs.value.length === 1)
|
||||
const firstSongPlaying = computed(() => songs.value.length ? songs.value[0].playbackState === 'Playing' : false)
|
||||
const normalPlaylists = computed(() => playlistState.playlists.filter(playlist => !playlist.is_smart))
|
||||
const isAdmin = computed(() => userState.current.is_admin)
|
||||
|
||||
isAdmin (): boolean {
|
||||
return this.userState.current.is_admin
|
||||
}
|
||||
},
|
||||
const doPlayback = () => {
|
||||
if (!songs.value.length) return
|
||||
|
||||
methods: {
|
||||
open (top: number, left: number): void {
|
||||
if (!this.songs.length) {
|
||||
return
|
||||
}
|
||||
switch (songs.value[0].playbackState) {
|
||||
case 'Playing':
|
||||
playback.pause()
|
||||
break
|
||||
|
||||
(this.$refs.base as BaseContextMenu).open(top, left)
|
||||
},
|
||||
case 'Paused':
|
||||
playback.resume()
|
||||
break
|
||||
|
||||
close (): void {
|
||||
(this.$refs.base as BaseContextMenu).close()
|
||||
},
|
||||
|
||||
doPlayback (): void {
|
||||
switch (this.songs[0].playbackState) {
|
||||
case 'Playing':
|
||||
playback.pause()
|
||||
break
|
||||
|
||||
case 'Paused':
|
||||
playback.resume()
|
||||
break
|
||||
|
||||
default:
|
||||
queueStore.contains(this.songs[0]) || queueStore.queueAfterCurrent(this.songs[0])
|
||||
playback.play(this.songs[0])
|
||||
break
|
||||
}
|
||||
|
||||
this.close()
|
||||
},
|
||||
|
||||
openEditForm (): void {
|
||||
if (this.songs.length) {
|
||||
eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', this.songs)
|
||||
}
|
||||
|
||||
this.close()
|
||||
},
|
||||
|
||||
viewAlbumDetails (album: Album): void {
|
||||
router.go(`album/${album.id}`)
|
||||
this.close()
|
||||
},
|
||||
|
||||
viewArtistDetails (artist: Artist): void {
|
||||
router.go(`artist/${artist.id}`)
|
||||
this.close()
|
||||
},
|
||||
|
||||
download (): void {
|
||||
download.fromSongs(this.songs)
|
||||
this.close()
|
||||
},
|
||||
|
||||
copyUrl (): void {
|
||||
copyText(songStore.getShareableUrl(this.songs[0]))
|
||||
this.close()
|
||||
}
|
||||
default:
|
||||
queueStore.contains(songs.value[0]) || queueStore.queueAfterCurrent(songs.value[0])
|
||||
playback.play(songs.value[0])
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
const openEditForm = () => {
|
||||
songs.value.length && eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', songs)
|
||||
close()
|
||||
}
|
||||
|
||||
const viewAlbumDetails = (album: Album) => {
|
||||
router.go(`album/${album.id}`)
|
||||
close()
|
||||
}
|
||||
|
||||
const viewArtistDetails = (artist: Artist) => {
|
||||
router.go(`artist/${artist.id}`)
|
||||
close()
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
downloadService.fromSongs(songs.value)
|
||||
close()
|
||||
}
|
||||
|
||||
const copyUrl = () => {
|
||||
copyText(songStore.getShareableUrl(songs.value[0]))
|
||||
close()
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="edit-song" data-testid="edit-song-form" @keydown.esc="maybeClose" tabindex="0">
|
||||
<sound-bar v-if="loading"/>
|
||||
<SoundBar v-if="loading"/>
|
||||
<form v-else @submit.prevent="submit">
|
||||
<header>
|
||||
<img :src="coverUrl" width="96" height="96" alt="Album's cover">
|
||||
|
@ -101,21 +101,21 @@
|
|||
</div>
|
||||
|
||||
<footer>
|
||||
<btn type="submit">Update</btn>
|
||||
<btn @click.prevent="maybeClose" class="btn-cancel" white>Cancel</btn>
|
||||
<Btn type="submit">Update</Btn>
|
||||
<Btn @click.prevent="maybeClose" class="btn-cancel" white>Cancel</Btn>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { union, isEqual } from 'lodash'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, nextTick, reactive, ref, toRefs } from 'vue'
|
||||
import { isEqual, union } from 'lodash'
|
||||
|
||||
import { br2nl, getDefaultCover, alerts } from '@/utils'
|
||||
import { alerts, br2nl, getDefaultCover } from '@/utils'
|
||||
import { songInfo } from '@/services/info'
|
||||
import { artistStore, albumStore, songStore } from '@/stores'
|
||||
import { albumStore, artistStore, songStore } from '@/stores'
|
||||
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
import { TypeAheadConfig } from 'koel/types/ui'
|
||||
|
||||
interface EditFormData {
|
||||
|
@ -127,225 +127,188 @@ interface EditFormData {
|
|||
compilationState: number
|
||||
}
|
||||
|
||||
type TabName = 'details' | 'lyrics'
|
||||
|
||||
const COMPILATION_STATES = {
|
||||
NONE: 0, // No songs belong to a compilation album
|
||||
ALL: 1, // All songs belong to compilation album(s)
|
||||
SOME: 2 // Some of the songs belong to compilation album(s)
|
||||
SOME: 2 // Some songs belong to compilation album(s)
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue'),
|
||||
Typeahead: () => import('@/components/ui/typeahead.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
const Typeahead = defineAsyncComponent(() => import('@/components/ui/typeahead.vue'))
|
||||
|
||||
props: {
|
||||
songs: {
|
||||
required: true,
|
||||
type: [Array, Object]
|
||||
} as PropOptions<Song | Song[]>,
|
||||
const props = withDefaults(defineProps<{ songs: Song[], initialTab: TabName }>(), { songs: [], initialTab: 'details' })
|
||||
const { songs, initialTab } = toRefs(props)
|
||||
|
||||
initialTab: {
|
||||
type: String,
|
||||
default: 'details',
|
||||
validator: (value: string): boolean => ['details', 'lyrics'].includes(value)
|
||||
}
|
||||
},
|
||||
const compilationStateChk = ref(null as unknown as HTMLInputElement)
|
||||
const mutatedSongs = ref<Song[]>([])
|
||||
const currentView = ref(null as unknown as TabName)
|
||||
const loading = ref(true)
|
||||
const artistState = reactive(artistStore.state)
|
||||
const albumState = reactive(albumStore.state)
|
||||
|
||||
data: () => ({
|
||||
mutatedSongs: [] as Song[],
|
||||
currentView: '',
|
||||
loading: true,
|
||||
const artistTypeAheadConfig: TypeAheadConfig = {
|
||||
displayKey: 'name',
|
||||
filterKey: 'name',
|
||||
name: 'artist'
|
||||
}
|
||||
|
||||
artistState: artistStore.state,
|
||||
artistTypeAheadConfig: {
|
||||
displayKey: 'name',
|
||||
filterKey: 'name',
|
||||
name: 'artist'
|
||||
} as TypeAheadConfig,
|
||||
const albumTypeAheadConfig: TypeAheadConfig = {
|
||||
displayKey: 'name',
|
||||
filterKey: 'name',
|
||||
name: 'album'
|
||||
}
|
||||
|
||||
albumState: albumStore.state,
|
||||
albumTypeAheadConfig: {
|
||||
displayKey: 'name',
|
||||
filterKey: 'name',
|
||||
name: 'album'
|
||||
} as TypeAheadConfig,
|
||||
/**
|
||||
* In order not to mess up the original songs, we manually assign and manipulate their attributes.
|
||||
*/
|
||||
const formData = reactive<EditFormData>({
|
||||
title: '',
|
||||
albumName: '',
|
||||
artistName: '',
|
||||
lyrics: '',
|
||||
track: null,
|
||||
compilationState: COMPILATION_STATES.NONE
|
||||
})
|
||||
|
||||
/**
|
||||
* In order not to mess up the original songs, we manually assign and manipulate
|
||||
* their attributes.
|
||||
*/
|
||||
formData: {
|
||||
title: '',
|
||||
albumName: '',
|
||||
artistName: '',
|
||||
lyrics: '',
|
||||
track: null,
|
||||
compilationState: 0
|
||||
} as EditFormData,
|
||||
const initialFormData = ref(null as unknown as EditFormData)
|
||||
|
||||
initialFormData: null as unknown as EditFormData
|
||||
}),
|
||||
const editingOnlyOneSong = computed(() => mutatedSongs.value.length === 1)
|
||||
const allSongsAreFromSameArtist = computed(() => new Set(mutatedSongs.value.map(song => song.artist.id)).size === 1)
|
||||
const allSongsAreInSameAlbum = computed(() => new Set(mutatedSongs.value.map(song => song.album.id)).size === 1)
|
||||
const coverUrl = computed(() => allSongsAreInSameAlbum.value ? mutatedSongs.value[0].album.cover : getDefaultCover())
|
||||
|
||||
computed: {
|
||||
editingOnlyOneSong (): boolean {
|
||||
return this.mutatedSongs.length === 1
|
||||
},
|
||||
const compilationState = computed(() => {
|
||||
const albums = mutatedSongs.value.reduce((acc: Album[], song): Album[] => union(acc, [song.album]), [])
|
||||
const compiledAlbums = albums.filter(album => album.is_compilation)
|
||||
|
||||
allSongsAreFromSameArtist (): boolean {
|
||||
return this.mutatedSongs.every((song: Song): boolean => song.artist.id === this.mutatedSongs[0].artist.id)
|
||||
},
|
||||
if (!compiledAlbums.length) {
|
||||
formData.compilationState = COMPILATION_STATES.NONE
|
||||
} else if (compiledAlbums.length === albums.length) {
|
||||
formData.compilationState = COMPILATION_STATES.ALL
|
||||
} else {
|
||||
formData.compilationState = COMPILATION_STATES.SOME
|
||||
}
|
||||
|
||||
allSongsAreInSameAlbum (): boolean {
|
||||
return this.mutatedSongs.every((song: Song): boolean => song.album.id === this.mutatedSongs[0].album.id)
|
||||
},
|
||||
return formData.compilationState
|
||||
})
|
||||
|
||||
coverUrl (): string {
|
||||
return this.allSongsAreInSameAlbum ? this.mutatedSongs[0].album.cover : getDefaultCover()
|
||||
},
|
||||
const displayedTitle = computed(() => {
|
||||
return editingOnlyOneSong.value ? formData.title : `${mutatedSongs.value.length} songs selected`
|
||||
})
|
||||
|
||||
compilationState (): number {
|
||||
const albums = this.mutatedSongs.reduce((acc: Album[], song: Song): Album[] => union(acc, [song.album]), [])
|
||||
const compiledAlbums = albums.filter((album: Album): boolean => album.is_compilation)
|
||||
const displayedArtistName = computed(() => {
|
||||
return allSongsAreFromSameArtist.value || formData.artistName ? formData.artistName : 'Mixed Artists'
|
||||
})
|
||||
|
||||
if (!compiledAlbums.length) {
|
||||
this.formData.compilationState = COMPILATION_STATES.NONE
|
||||
} else if (compiledAlbums.length === albums.length) {
|
||||
this.formData.compilationState = COMPILATION_STATES.ALL
|
||||
} else {
|
||||
this.formData.compilationState = COMPILATION_STATES.SOME
|
||||
}
|
||||
const displayedAlbumName = computed(() => {
|
||||
return allSongsAreInSameAlbum.value || formData.albumName ? formData.albumName : 'Mixed Albums'
|
||||
})
|
||||
|
||||
return this.formData.compilationState
|
||||
},
|
||||
const isPristine = computed(() => isEqual(formData, initialFormData.value))
|
||||
|
||||
displayedTitle (): string {
|
||||
return this.editingOnlyOneSong ? this.formData.title : `${this.mutatedSongs.length} songs selected`
|
||||
},
|
||||
const initCompilationStateCheckbox = async () => {
|
||||
// Wait for the next DOM update, because the form is dynamically
|
||||
// attached into DOM in conjunction with `this.loading` data binding.
|
||||
await nextTick()
|
||||
const checkbox = compilationStateChk.value
|
||||
|
||||
displayedArtistName (): string {
|
||||
return this.allSongsAreFromSameArtist || this.formData.artistName
|
||||
? this.formData.artistName
|
||||
: 'Mixed Artists'
|
||||
},
|
||||
switch (compilationState.value) {
|
||||
case COMPILATION_STATES.ALL:
|
||||
checkbox.checked = true
|
||||
checkbox.indeterminate = false
|
||||
break
|
||||
|
||||
displayedAlbumName (): string {
|
||||
return this.allSongsAreInSameAlbum || this.formData.albumName
|
||||
? this.formData.albumName
|
||||
: 'Mixed Albums'
|
||||
},
|
||||
case COMPILATION_STATES.NONE:
|
||||
checkbox.checked = false
|
||||
checkbox.indeterminate = false
|
||||
break
|
||||
|
||||
isPristine (): boolean {
|
||||
return isEqual(this.formData, this.initialFormData)
|
||||
}
|
||||
},
|
||||
default:
|
||||
checkbox.checked = false
|
||||
checkbox.indeterminate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
methods: {
|
||||
async open (): Promise<void> {
|
||||
this.mutatedSongs = ([] as Song[]).concat(this.songs)
|
||||
this.currentView = this.initialTab
|
||||
const open = async () => {
|
||||
mutatedSongs.value = ([] as Song[]).concat(songs.value)
|
||||
currentView.value = initialTab!.value
|
||||
const firstSong = mutatedSongs.value[0]
|
||||
|
||||
if (this.editingOnlyOneSong) {
|
||||
this.formData.title = this.mutatedSongs[0].title
|
||||
this.formData.albumName = this.mutatedSongs[0].album.name
|
||||
this.formData.artistName = this.mutatedSongs[0].artist.name
|
||||
if (editingOnlyOneSong.value) {
|
||||
formData.title = firstSong.title
|
||||
formData.albumName = firstSong.album.name
|
||||
formData.artistName = firstSong.artist.name
|
||||
|
||||
// If we're editing only one song and the song's info (including lyrics)
|
||||
// hasn't been loaded, load it now.
|
||||
if (!this.mutatedSongs[0].infoRetrieved) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await songInfo.fetch(this.mutatedSongs[0])
|
||||
this.formData.lyrics = br2nl(this.mutatedSongs[0].lyrics)
|
||||
this.formData.track = this.mutatedSongs[0].track || null
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.initCompilationStateCheckbox()
|
||||
}
|
||||
} else {
|
||||
this.loading = false
|
||||
this.formData.lyrics = br2nl(this.mutatedSongs[0].lyrics)
|
||||
this.formData.track = this.mutatedSongs[0].track || null
|
||||
this.initCompilationStateCheckbox()
|
||||
}
|
||||
} else {
|
||||
this.formData.albumName = this.allSongsAreInSameAlbum ? this.mutatedSongs[0].album.name : ''
|
||||
this.formData.artistName = this.allSongsAreFromSameArtist ? this.mutatedSongs[0].artist.name : ''
|
||||
this.loading = false
|
||||
this.initCompilationStateCheckbox()
|
||||
}
|
||||
},
|
||||
|
||||
initCompilationStateCheckbox (): void {
|
||||
// This must be wrapped in a $nextTick callback, because the form is dynamically
|
||||
// attached into DOM in conjunction with `this.loading` data binding.
|
||||
this.$nextTick((): void => {
|
||||
const checkbox = this.$refs.compilationStateChk as HTMLInputElement
|
||||
|
||||
switch (this.compilationState) {
|
||||
case COMPILATION_STATES.ALL:
|
||||
checkbox.checked = true
|
||||
checkbox.indeterminate = false
|
||||
break
|
||||
|
||||
case COMPILATION_STATES.NONE:
|
||||
checkbox.checked = false
|
||||
checkbox.indeterminate = false
|
||||
break
|
||||
|
||||
default:
|
||||
checkbox.checked = false
|
||||
checkbox.indeterminate = true
|
||||
break
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Manually set the compilation state.
|
||||
* We can't use v-model here due to the tri-state nature of the property.
|
||||
* Also, following iTunes style, we don't support circular switching of the states -
|
||||
* once the user clicks the checkbox, there's no going back to indeterminate state.
|
||||
*/
|
||||
changeCompilationState (): void {
|
||||
const checkbox = this.$refs.compilationStateChk as HTMLInputElement
|
||||
this.formData.compilationState = checkbox.checked ? COMPILATION_STATES.ALL : COMPILATION_STATES.NONE
|
||||
},
|
||||
|
||||
close (): void {
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
maybeClose (): void {
|
||||
if (this.isPristine) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', () => this.close())
|
||||
},
|
||||
|
||||
async submit (): Promise<void> {
|
||||
this.loading = true
|
||||
// If we're editing only one song and the song's info (including lyrics)
|
||||
// hasn't been loaded, load it now.
|
||||
if (!firstSong.infoRetrieved) {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await songStore.update(this.mutatedSongs, this.formData)
|
||||
this.close()
|
||||
await songInfo.fetch(firstSong)
|
||||
formData.lyrics = br2nl(firstSong.lyrics)
|
||||
formData.track = firstSong.track || null
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
loading.value = false
|
||||
await initCompilationStateCheckbox()
|
||||
}
|
||||
} else {
|
||||
loading.value = false
|
||||
formData.lyrics = br2nl(firstSong.lyrics)
|
||||
formData.track = firstSong.track || null
|
||||
await initCompilationStateCheckbox()
|
||||
}
|
||||
},
|
||||
|
||||
async created (): Promise<void> {
|
||||
await this.open()
|
||||
this.initialFormData = Object.assign({}, this.formData)
|
||||
} else {
|
||||
formData.albumName = allSongsAreInSameAlbum.value ? firstSong.album.name : ''
|
||||
formData.artistName = allSongsAreFromSameArtist.value ? firstSong.artist.name : ''
|
||||
loading.value = false
|
||||
await initCompilationStateCheckbox()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually set the compilation state.
|
||||
* We can't use v-model here due to the tri-state nature of the property.
|
||||
* Also, following iTunes style, we don't support circular switching of the states -
|
||||
* once the user clicks the checkbox, there's no going back to indeterminate state.
|
||||
*/
|
||||
const changeCompilationState = () => {
|
||||
const checkbox = compilationStateChk.value
|
||||
formData.compilationState = checkbox.checked ? COMPILATION_STATES.ALL : COMPILATION_STATES.NONE
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const close = () => emit('close')
|
||||
|
||||
const maybeClose = () => {
|
||||
if (isPristine.value) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', close)
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await songStore.update(mutatedSongs.value, formData)
|
||||
close()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
open()
|
||||
initialFormData.value = Object.assign({}, formData)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<tr
|
||||
<div
|
||||
class="song-item"
|
||||
draggable="true"
|
||||
@click="clicked"
|
||||
|
@ -9,118 +9,75 @@
|
|||
@dragenter.prevent="dragEnter"
|
||||
@dragover.prevent
|
||||
@drop.stop.prevent="drop"
|
||||
@contextmenu.prevent="contextMenu"
|
||||
@contextmenu.stop.prevent="contextMenu"
|
||||
:class="{ playing, selected: item.selected }"
|
||||
>
|
||||
<td class="track-number text-secondary" v-if="columns.includes('track')">{{ song.track || '' }}</td>
|
||||
<td class="title" v-if="columns.includes('title')">{{ song.title }}</td>
|
||||
<td class="artist" v-if="columns.includes('artist')">{{ song.artist.name }}</td>
|
||||
<td class="album" v-if="columns.includes('album')">{{ song.album.name }}</td>
|
||||
<td class="time text-secondary" v-if="columns.includes('length')">{{ song.fmtLength }}</td>
|
||||
<td class="favorite">
|
||||
<like-button :song="song"/>
|
||||
</td>
|
||||
<td class="play" role="button" @click.stop="doPlayback">
|
||||
<span class="track-number text-secondary" v-if="columns.includes('track')">{{ song.track || '' }}</span>
|
||||
<span class="title" v-if="columns.includes('title')">{{ song.title }}</span>
|
||||
<span class="artist" v-if="columns.includes('artist')">{{ song.artist.name }}</span>
|
||||
<span class="album" v-if="columns.includes('album')">{{ song.album.name }}</span>
|
||||
<span class="time text-secondary" v-if="columns.includes('length')">{{ song.fmtLength }}</span>
|
||||
<span class="favorite">
|
||||
<LikeButton :song="song"/>
|
||||
</span>
|
||||
<span class="play" role="button" @click.stop="doPlayback">
|
||||
<i class="fa fa-pause-circle" v-if="song.playbackState === 'Playing'"></i>
|
||||
<i class="fa fa-play-circle" v-else></i>
|
||||
</td>
|
||||
</tr>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
import $, { VueQuery } from 'vuequery'
|
||||
<script lang="ts" setup>
|
||||
import { ComponentInternalInstance, computed, defineAsyncComponent, getCurrentInstance, toRefs } from 'vue'
|
||||
import { playback } from '@/services'
|
||||
import { queueStore } from '@/stores'
|
||||
import { SongListComponent } from 'koel/types/ui'
|
||||
import { SongListColumn } from '@/components/song/list.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'song-item',
|
||||
const LikeButton = defineAsyncComponent(() => import('@/components/song/like-button.vue'))
|
||||
|
||||
components: {
|
||||
LikeButton: () => import('@/components/song/like-button.vue')
|
||||
},
|
||||
const props = defineProps<{ item: SongProxy, columns: SongListColumn[] }>()
|
||||
const { item, columns } = toRefs(props)
|
||||
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<SongProxy>,
|
||||
const song = computed(() => item.value.song)
|
||||
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playbackState!))
|
||||
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true
|
||||
} as PropOptions<SongListColumn[]>
|
||||
},
|
||||
const playRightAwayyyyyyy = () => {
|
||||
queueStore.contains(song.value) || queueStore.queueAfterCurrent(song.value)
|
||||
playback.play(song.value)
|
||||
}
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* A shortcut to access the current vm's song (instead of this.item.song).
|
||||
*/
|
||||
song (): Song {
|
||||
return this.item.song
|
||||
},
|
||||
const doPlayback = () => {
|
||||
switch (song.value.playbackState) {
|
||||
case 'Playing':
|
||||
playback.pause()
|
||||
break
|
||||
|
||||
playing (): boolean {
|
||||
return this.song.playbackState === 'Playing' || this.song.playbackState === 'Paused'
|
||||
},
|
||||
case 'Paused':
|
||||
playback.resume()
|
||||
break
|
||||
|
||||
parentSongList (): SongListComponent {
|
||||
return ($(this) as VueQuery).closest('song-list')!.vm as unknown as SongListComponent
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
playRightAwayyyyyyy (): void {
|
||||
if (!queueStore.contains(this.song)) {
|
||||
queueStore.queueAfterCurrent(this.song)
|
||||
}
|
||||
|
||||
playback.play(this.song)
|
||||
},
|
||||
|
||||
doPlayback (): void {
|
||||
switch (this.song.playbackState) {
|
||||
case 'Playing':
|
||||
playback.pause()
|
||||
break
|
||||
|
||||
case 'Paused':
|
||||
playback.resume()
|
||||
break
|
||||
|
||||
default:
|
||||
this.playRightAwayyyyyyy()
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
clicked (event: MouseEvent): void {
|
||||
this.parentSongList.rowClicked(this, event)
|
||||
},
|
||||
|
||||
dragStart (event: DragEvent): void {
|
||||
this.parentSongList.dragStart(this, event)
|
||||
},
|
||||
|
||||
dragLeave (event: DragEvent): void {
|
||||
this.parentSongList.removeDroppableState(event)
|
||||
},
|
||||
|
||||
dragEnter (event: DragEvent): void {
|
||||
this.parentSongList.allowDrop(event)
|
||||
},
|
||||
|
||||
drop (event: DragEvent): void {
|
||||
this.parentSongList.handleDrop(this, event)
|
||||
},
|
||||
|
||||
contextMenu (event: MouseEvent): void {
|
||||
this.parentSongList.openContextMenu(this, event)
|
||||
}
|
||||
default:
|
||||
playRightAwayyyyyyy()
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getParentSongList = (instance: ComponentInternalInstance): ComponentInternalInstance => {
|
||||
if (!instance.parent) {
|
||||
throw new Error('Cannot find a parent song list anywhere in the tree')
|
||||
}
|
||||
|
||||
return instance.parent.proxy?.$options.name === 'SongList' ? instance.parent : getParentSongList(instance.parent)
|
||||
}
|
||||
|
||||
const vm = getCurrentInstance()!
|
||||
const exposes = getParentSongList(vm).exposed
|
||||
|
||||
const clicked = (event: MouseEvent) => exposes?.rowClicked(vm, event)
|
||||
const dragStart = (event: DragEvent) => exposes?.dragStart(vm, event)
|
||||
const dragLeave = (event: DragEvent) => exposes?.removeDroppableState(event)
|
||||
const dragEnter = (event: DragEvent) => exposes?.allowDrop(event)
|
||||
const drop = (event: DragEvent) => exposes?.handleDrop(vm, event)
|
||||
const contextMenu = (event: MouseEvent) => exposes?.openContextMenu(vm, event)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -128,6 +85,7 @@ export default Vue.extend({
|
|||
border-bottom: 1px solid var(--color-bg-secondary);
|
||||
max-width: 100% !important; // overriding .item
|
||||
height: 35px;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, .05);
|
||||
|
@ -147,7 +105,7 @@ export default Vue.extend({
|
|||
background-color: rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
&.playing td {
|
||||
&.playing span {
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,30 +5,16 @@
|
|||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { favoriteStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
song: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Song>
|
||||
},
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
computed: {
|
||||
title (): string {
|
||||
return `${this.song.liked ? 'Unlike' : 'Like'} ${this.song.title} by ${this.song.artist.name}`
|
||||
}
|
||||
},
|
||||
const title = computed(() => `${song.value.liked ? 'Unlike' : 'Like'} ${song.value.title} by ${song.value.artist.name}`)
|
||||
|
||||
methods: {
|
||||
toggleLike () {
|
||||
favoriteStore.toggleOne(this.song)
|
||||
}
|
||||
}
|
||||
})
|
||||
const toggleLike = () => favoriteStore.toggleOne(song.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="song-list-controls" data-test="song-list-controls">
|
||||
<btn-group uppercased>
|
||||
<div class="song-list-controls" data-test="song-list-controls" ref="el">
|
||||
<BtnGroup uppercased>
|
||||
<template v-if="mergedConfig.play">
|
||||
<template v-if="altPressed">
|
||||
<btn
|
||||
<Btn
|
||||
@click.prevent="playAll"
|
||||
class="btn-play-all"
|
||||
orange
|
||||
|
@ -12,9 +12,9 @@
|
|||
data-test="btn-play-all"
|
||||
>
|
||||
<i class="fa fa-play"></i> All
|
||||
</btn>
|
||||
</Btn>
|
||||
|
||||
<btn
|
||||
<Btn
|
||||
@click.prevent="playSelected"
|
||||
class="btn-play-selected"
|
||||
orange
|
||||
|
@ -23,11 +23,11 @@
|
|||
data-test="btn-play-selected"
|
||||
>
|
||||
<i class="fa fa-play"></i> Selected
|
||||
</btn>
|
||||
</Btn>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<btn
|
||||
<Btn
|
||||
@click.prevent="shuffle"
|
||||
class="btn-shuffle-all"
|
||||
orange
|
||||
|
@ -36,9 +36,9 @@
|
|||
data-test="btn-shuffle-all"
|
||||
>
|
||||
<i class="fa fa-random"></i> All
|
||||
</btn>
|
||||
</Btn>
|
||||
|
||||
<btn
|
||||
<Btn
|
||||
@click.prevent="shuffleSelected"
|
||||
class="btn-shuffle-selected"
|
||||
orange
|
||||
|
@ -47,11 +47,11 @@
|
|||
data-test="btn-shuffle-selected"
|
||||
>
|
||||
<i class="fa fa-random"></i> Selected
|
||||
</btn>
|
||||
</Btn>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<btn
|
||||
<Btn
|
||||
:title="`${showingAddToMenu ? 'Cancel' : 'Add selected songs to…'}`"
|
||||
@click.prevent.stop="toggleAddToMenu"
|
||||
class="btn-add-to"
|
||||
|
@ -60,9 +60,9 @@
|
|||
data-test="add-to-btn"
|
||||
>
|
||||
{{ showingAddToMenu ? 'Cancel' : 'Add To…' }}
|
||||
</btn>
|
||||
</Btn>
|
||||
|
||||
<btn
|
||||
<Btn
|
||||
@click.prevent="clearQueue"
|
||||
class="btn-clear-queue"
|
||||
red
|
||||
|
@ -70,9 +70,9 @@
|
|||
title="Clear current queue"
|
||||
>
|
||||
Clear
|
||||
</btn>
|
||||
</Btn>
|
||||
|
||||
<btn
|
||||
<Btn
|
||||
@click.prevent="deletePlaylist"
|
||||
class="del btn-delete-playlist"
|
||||
red
|
||||
|
@ -80,11 +80,11 @@
|
|||
v-if="showDeletePlaylistButton"
|
||||
>
|
||||
<i class="fa fa-times"></i> Playlist
|
||||
</btn>
|
||||
</Btn>
|
||||
|
||||
</btn-group>
|
||||
</BtnGroup>
|
||||
|
||||
<add-to-menu
|
||||
<AddToMenu
|
||||
@closing="closeAddToMenu"
|
||||
:config="mergedConfig.addTo"
|
||||
:songs="selectedSongs"
|
||||
|
@ -94,131 +94,79 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, toRefs } from 'vue'
|
||||
|
||||
const KEYCODE_ALT = 18
|
||||
const AddToMenu = defineAsyncComponent(() => import('./add-to-menu.vue'))
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const BtnGroup = defineAsyncComponent(() => import('@/components/ui/btn-group.vue'))
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
config: {
|
||||
type: Object
|
||||
} as PropOptions<Partial<SongListControlsConfig>>,
|
||||
songs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
} as PropOptions<Song[]>,
|
||||
const props = withDefaults(defineProps<{ songs: Song[], selectedSongs: Song[], config: Partial<SongListControlsConfig> }>(), {
|
||||
songs: () => [],
|
||||
selectedSongs: () => [],
|
||||
config: () => ({})
|
||||
})
|
||||
|
||||
selectedSongs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
} as PropOptions<Song[]>
|
||||
},
|
||||
const { config, songs, selectedSongs } = toRefs(props)
|
||||
|
||||
components: {
|
||||
AddToMenu: () => import('./add-to-menu.vue'),
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
BtnGroup: () => import('@/components/ui/btn-group.vue')
|
||||
},
|
||||
const el = ref(null as unknown as HTMLElement)
|
||||
const showingAddToMenu = ref(false)
|
||||
const numberOfQueuedSongs = ref(0)
|
||||
const altPressed = ref(false)
|
||||
|
||||
data: () => ({
|
||||
showingAddToMenu: false,
|
||||
numberOfQueuedSongs: 0,
|
||||
altPressed: false
|
||||
}),
|
||||
|
||||
computed: {
|
||||
showClearQueueButton (): boolean {
|
||||
return this.mergedConfig.clearQueue
|
||||
const mergedConfig = computed((): SongListControlsConfig => Object.assign({
|
||||
play: true,
|
||||
addTo: {
|
||||
queue: true,
|
||||
favorites: true,
|
||||
playlists: true,
|
||||
newPlaylist: true
|
||||
},
|
||||
clearQueue: false,
|
||||
deletePlaylist: false
|
||||
}, config)
|
||||
)
|
||||
|
||||
showDeletePlaylistButton (): boolean {
|
||||
return this.mergedConfig.deletePlaylist
|
||||
},
|
||||
const showClearQueueButton = computed(() => mergedConfig.value.clearQueue)
|
||||
const showDeletePlaylistButton = computed(() => mergedConfig.value.deletePlaylist)
|
||||
|
||||
mergedConfig (): SongListControlsConfig {
|
||||
return Object.assign({
|
||||
play: true,
|
||||
addTo: {
|
||||
queue: true,
|
||||
favorites: true,
|
||||
playlists: true,
|
||||
newPlaylist: true
|
||||
},
|
||||
clearQueue: false,
|
||||
deletePlaylist: false
|
||||
}, this.config)
|
||||
}
|
||||
},
|
||||
const emit = defineEmits(['playAll', 'playSelected', 'clearQueue', 'deletePlaylist'])
|
||||
|
||||
methods: {
|
||||
shuffle (): void {
|
||||
this.$emit('playAll', true)
|
||||
},
|
||||
const shuffle = () => emit('playAll', true)
|
||||
const shuffleSelected = () => emit('playSelected', true)
|
||||
const playAll = () => emit('playAll', false)
|
||||
const playSelected = () => emit('playSelected', false)
|
||||
const clearQueue = () => emit('clearQueue')
|
||||
const deletePlaylist = () => emit('deletePlaylist')
|
||||
const closeAddToMenu = () => (showingAddToMenu.value = false)
|
||||
const registerKeydown = (event: KeyboardEvent) => event.altKey && (altPressed.value = true)
|
||||
const registerKeyup = (event: KeyboardEvent) => event.altKey && (altPressed.value = false)
|
||||
|
||||
shuffleSelected (): void {
|
||||
this.$emit('playSelected', true)
|
||||
},
|
||||
const toggleAddToMenu = async () => {
|
||||
showingAddToMenu.value = !showingAddToMenu.value
|
||||
|
||||
playAll (): void {
|
||||
this.$emit('playAll', false)
|
||||
},
|
||||
|
||||
playSelected (): void {
|
||||
this.$emit('playSelected', false)
|
||||
},
|
||||
|
||||
clearQueue (): void {
|
||||
this.$emit('clearQueue')
|
||||
},
|
||||
|
||||
deletePlaylist (): void {
|
||||
this.$emit('deletePlaylist')
|
||||
},
|
||||
|
||||
closeAddToMenu (): void {
|
||||
this.showingAddToMenu = false
|
||||
},
|
||||
|
||||
registerKeydown (event: KeyboardEvent): void {
|
||||
if (event.keyCode === KEYCODE_ALT) {
|
||||
this.altPressed = true
|
||||
}
|
||||
},
|
||||
|
||||
registerKeyup (event: KeyboardEvent): void {
|
||||
if (event.keyCode === KEYCODE_ALT) {
|
||||
this.altPressed = false
|
||||
}
|
||||
},
|
||||
|
||||
toggleAddToMenu () {
|
||||
this.showingAddToMenu = !this.showingAddToMenu
|
||||
|
||||
if (!this.showingAddToMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const btnAddTo = this.$el.querySelector<HTMLButtonElement>('.btn-add-to')!
|
||||
const { left: btnLeft, bottom: btnBottom, width: btnWidth } = btnAddTo.getBoundingClientRect()
|
||||
const contextMenu = this.$el.querySelector<HTMLElement>('.add-to')!
|
||||
const menuWidth = contextMenu.getBoundingClientRect().width
|
||||
contextMenu.style.top = `${btnBottom + 10}px`
|
||||
contextMenu.style.left = `${btnLeft + btnWidth / 2 - menuWidth / 2}px`
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
mounted (): void {
|
||||
window.addEventListener('keydown', this.registerKeydown)
|
||||
window.addEventListener('keyup', this.registerKeyup)
|
||||
},
|
||||
|
||||
destroyed (): void {
|
||||
window.removeEventListener('keydown', this.registerKeydown)
|
||||
window.removeEventListener('keyup', this.registerKeyup)
|
||||
if (!showingAddToMenu.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const btnAddTo = el.value.querySelector<HTMLButtonElement>('.btn-add-to')!
|
||||
const { left: btnLeft, bottom: btnBottom, width: btnWidth } = btnAddTo.getBoundingClientRect()
|
||||
const contextMenu = el.value.querySelector<HTMLElement>('.add-to')!
|
||||
const menuWidth = contextMenu.getBoundingClientRect().width
|
||||
contextMenu.style.top = `${btnBottom + 10}px`
|
||||
contextMenu.style.left = `${btnLeft + btnWidth / 2 - menuWidth / 2}px`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', registerKeydown)
|
||||
window.addEventListener('keyup', registerKeyup)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', registerKeydown)
|
||||
window.removeEventListener('keyup', registerKeyup)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -8,83 +8,81 @@
|
|||
@keydown.enter.prevent.stop="handleEnter"
|
||||
@keydown.a.prevent="handleA"
|
||||
>
|
||||
<table class="song-list-header" :class="mergedConfig.sortable ? 'sortable' : 'unsortable'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="sort('song.track')" class="track-number" v-if="mergedConfig.columns.includes('track')">
|
||||
#
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.track' && sortOrder > 0"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.track' && sortOrder < 0"></i>
|
||||
</th>
|
||||
<th @click="sort('song.title')" class="title" v-if="mergedConfig.columns.includes('title')">
|
||||
Title
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.title' && sortOrder > 0"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.title' && sortOrder < 0"></i>
|
||||
</th>
|
||||
<th
|
||||
@click="sort(['song.album.artist.name', 'song.album.name', 'song.track'])"
|
||||
class="artist"
|
||||
v-if="mergedConfig.columns.includes('artist')"
|
||||
>
|
||||
Artist
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.album.artist.name' && sortOrder > 0"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.album.artist.name' && sortOrder < 0"></i>
|
||||
</th>
|
||||
<th
|
||||
@click="sort(['song.album.name', 'song.track'])"
|
||||
class="album"
|
||||
v-if="mergedConfig.columns.includes('album')"
|
||||
>
|
||||
Album
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.album.name' && sortOrder > 0"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.album.name' && sortOrder < 0"></i>
|
||||
</th>
|
||||
<th @click="sort('song.length')" class="time" v-if="mergedConfig.columns.includes('length')">
|
||||
Time
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.length' && sortOrder > 0"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.length' && sortOrder < 0"></i>
|
||||
</th>
|
||||
<th class="favorite"></th>
|
||||
<th class="play"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div class="song-list-header" :class="mergedConfig.sortable ? 'sortable' : 'unsortable'">
|
||||
<span @click="sort('song.track')" class="track-number" v-if="mergedConfig.columns.includes('track')">
|
||||
#
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.track' && sortOrder === 'Asc'"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.track' && sortOrder === 'Desc'"></i>
|
||||
</span>
|
||||
<span @click="sort('song.title')" class="title" v-if="mergedConfig.columns.includes('title')">
|
||||
Title
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.title' && sortOrder === 'Asc'"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.title' && sortOrder === 'Desc'"></i>
|
||||
</span>
|
||||
<span
|
||||
@click="sort(['song.album.artist.name', 'song.album.name', 'song.track'])"
|
||||
class="artist"
|
||||
v-if="mergedConfig.columns.includes('artist')"
|
||||
>
|
||||
Artist
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.album.artist.name' && sortOrder === 'Asc'"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.album.artist.name' && sortOrder === 'Desc'"></i>
|
||||
</span>
|
||||
<span
|
||||
@click="sort(['song.album.name', 'song.track'])"
|
||||
class="album"
|
||||
v-if="mergedConfig.columns.includes('album')"
|
||||
>
|
||||
Album
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.album.name' && sortOrder === 'Asc'"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.album.name' && sortOrder === 'Desc'"></i>
|
||||
</span>
|
||||
<span @click="sort('song.length')" class="time" v-if="mergedConfig.columns.includes('length')">
|
||||
Time
|
||||
<i class="fa fa-angle-down" v-show="primarySortField === 'song.length' && sortOrder === 'Asc'"></i>
|
||||
<i class="fa fa-angle-up" v-show="primarySortField === 'song.length' && sortOrder === 'Desc'"></i>
|
||||
</span>
|
||||
<span class="favorite"></span>
|
||||
<span class="play"></span>
|
||||
</div>
|
||||
|
||||
<virtual-scroller
|
||||
<RecycleScroller
|
||||
class="scroller"
|
||||
content-tag="table"
|
||||
:items="songProxies"
|
||||
item-height="35"
|
||||
key-field="song.id"
|
||||
:item-size="35"
|
||||
key-field="id"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<template slot-scope="props">
|
||||
<song-item :item="props.item" :columns="mergedConfig.columns" />
|
||||
</template>
|
||||
</virtual-scroller>
|
||||
<SongItem :item="item" :columns="mergedConfig.columns"/>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import isMobile from 'ismobilejs'
|
||||
export default {
|
||||
name: 'SongList'
|
||||
}
|
||||
</script>
|
||||
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
import { VirtualScroller } from 'vue-virtual-scroller'
|
||||
import { orderBy, eventBus, startDragging, $ } from '@/utils'
|
||||
import { playlistStore, queueStore, favoriteStore } from '@/stores'
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onMounted,
|
||||
PropType,
|
||||
ref,
|
||||
toRefs,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import { $, eventBus, orderBy, startDragging } from '@/utils'
|
||||
import { favoriteStore, playlistStore, queueStore } from '@/stores'
|
||||
import { playback } from '@/services'
|
||||
import router from '@/router'
|
||||
import { SongListRowComponent } from 'koel/types/ui'
|
||||
|
||||
export type SongListType = 'all-songs'
|
||||
| 'queue'
|
||||
| 'playlist'
|
||||
| 'favorites'
|
||||
| 'recently-played'
|
||||
| 'artist'
|
||||
| 'album'
|
||||
| 'search-results'
|
||||
|
||||
export type SongListColumn = 'track' | 'title' | 'album' | 'artist' | 'length'
|
||||
|
||||
type SortField = 'song.track'
|
||||
| 'song.disc'
|
||||
|
@ -93,347 +91,307 @@ type SortField = 'song.track'
|
|||
| 'song.album.name'
|
||||
| 'song.length'
|
||||
|
||||
export interface SongListConfig {
|
||||
sortable: boolean
|
||||
columns: SongListColumn[]
|
||||
}
|
||||
|
||||
const enum SortOrder {
|
||||
Asc = 1,
|
||||
Desc = -1,
|
||||
None = 0
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'song-list',
|
||||
type SortOrder = 'Asc' | 'Desc' | 'None'
|
||||
|
||||
interface SongRow {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
} as PropOptions<Song[]>,
|
||||
item: SongProxy
|
||||
}
|
||||
}
|
||||
|
||||
type: {
|
||||
type: String,
|
||||
default: 'all-songs'
|
||||
} as PropOptions<SongListType>,
|
||||
const SongItem = defineAsyncComponent(() => import('@/components/song/item.vue'))
|
||||
|
||||
config: {
|
||||
type: Object,
|
||||
default: (): Partial<SongListConfig> => ({})
|
||||
} as PropOptions<Partial<SongListConfig>>,
|
||||
// @ts-ignore
|
||||
getCurrentInstance()!.__IS_SONG_LIST__ = true
|
||||
|
||||
playlist: {
|
||||
type: Object
|
||||
} as PropOptions<Playlist>
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as PropType<Song[]>,
|
||||
required: true
|
||||
},
|
||||
|
||||
components: {
|
||||
VirtualScroller,
|
||||
SongItem: () => import('@/components/song/item.vue')
|
||||
playlist: {
|
||||
type: Object as PropType<Playlist>
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
lastSelectedRow: null as unknown as SongListRowComponent,
|
||||
sortFields: [] as SortField[],
|
||||
sortOrder: SortOrder.None,
|
||||
songProxies: [] as SongProxy[]
|
||||
}),
|
||||
|
||||
watch: {
|
||||
items (): void {
|
||||
this.render()
|
||||
},
|
||||
|
||||
selectedSongs (val: Song[]): void {
|
||||
eventBus.emit('SET_SELECTED_SONGS', val, this.$parent)
|
||||
}
|
||||
type: {
|
||||
type: String as PropType<SongListType>,
|
||||
default: 'all-songs'
|
||||
},
|
||||
config: {
|
||||
type: Object as PropType<Partial<SongListConfig>>,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
computed: {
|
||||
allowSongReordering (): boolean {
|
||||
return this.type === 'queue'
|
||||
},
|
||||
const { items, playlist, type, config } = toRefs(props)
|
||||
|
||||
selectedSongs (): Song[] {
|
||||
return this.songProxies.filter(row => row.selected).map(row => row.song)
|
||||
},
|
||||
const lastSelectedRow = ref<SongRow>()
|
||||
const sortFields = ref<SortField[]>([])
|
||||
const sortOrder = ref<SortOrder>('None')
|
||||
const songProxies = ref<SongProxy[]>([])
|
||||
|
||||
mergedConfig (): SongListConfig {
|
||||
return Object.assign({
|
||||
sortable: true,
|
||||
columns: ['track', 'title', 'artist', 'album', 'length']
|
||||
}, this.config)
|
||||
},
|
||||
const allowSongReordering = computed(() => type.value === 'queue')
|
||||
const selectedSongs = computed(() => songProxies.value.filter(row => row.selected).map(row => row.song))
|
||||
const primarySortField = computed(() => sortFields.value.length === 0 ? null : sortFields.value[0])
|
||||
|
||||
primarySortField (): string | null {
|
||||
return this.sortFields.length === 0 ? null : this.sortFields[0]
|
||||
}
|
||||
},
|
||||
const mergedConfig = computed((): SongListConfig => {
|
||||
return Object.assign({
|
||||
sortable: true,
|
||||
columns: ['track', 'title', 'artist', 'album', 'length']
|
||||
}, config.value)
|
||||
})
|
||||
|
||||
methods: {
|
||||
render (): void {
|
||||
if (!this.mergedConfig.sortable) {
|
||||
this.sortFields = []
|
||||
}
|
||||
/**
|
||||
* Since song objects themselves are shared by all song lists, we can't use them directly to
|
||||
* determine their selection status (selected/unselected). Therefore, for each song list, we
|
||||
* maintain an array of "song proxies," each containing the song itself and the "selected" flag.
|
||||
* To comply with virtual-scroller, a "type" attribute also presents.
|
||||
*/
|
||||
const generateSongProxies = () => {
|
||||
// Since this method re-generates the song wrappers, we need to keep track of the
|
||||
// selected songs manually.
|
||||
const selectedSongIds = selectedSongs.value.map(song => song.id)
|
||||
|
||||
this.songProxies = this.generateSongProxies()
|
||||
this.sort(this.sortFields, this.sortOrder)
|
||||
},
|
||||
return items.value.map(song => ({
|
||||
id: song.id,
|
||||
song,
|
||||
selected: selectedSongIds.includes(song.id)
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Since song objects themselves are shared by all song lists, we can't use them directly to
|
||||
* determine their selection status (selected/unselected). Therefore, for each song list, we
|
||||
* maintain an array of "song proxies," each containing the song itself and the "selected" flag.
|
||||
* To comply with virtual-scroller, a "type" attribute also presents.
|
||||
*/
|
||||
generateSongProxies (): SongProxy[] {
|
||||
// Since this method re-generates the song wrappers, we need to keep track of the
|
||||
// selected songs manually.
|
||||
const selectedSongIds = this.selectedSongs.map((song: Song): string => song.id)
|
||||
const nextSortOrder = computed<SortOrder>(() => {
|
||||
if (sortOrder.value === 'None') return 'Asc'
|
||||
if (sortOrder.value === 'Asc') return 'Desc'
|
||||
return 'None'
|
||||
})
|
||||
|
||||
return this.items.map((song): SongProxy => ({
|
||||
song,
|
||||
selected: selectedSongIds.includes(song.id)
|
||||
}))
|
||||
},
|
||||
const sort = (field: SortField | SortField[] = [], order: SortOrder | null = null) => {
|
||||
// there are certain circumstances where sorting is simply disallowed, e.g. in Queue
|
||||
if (!mergedConfig.value.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
sort (field: SortField | SortField[] = [], order: SortOrder | null = null) {
|
||||
// there are certain circumstances where sorting is simply disallowed, e.g. in Queue
|
||||
if (!this.mergedConfig.sortable) {
|
||||
return
|
||||
}
|
||||
sortFields.value = ([] as SortField[]).concat(field)
|
||||
|
||||
this.sortFields = ([] as SortField[]).concat(field)
|
||||
if (!sortFields.value.length && ['album', 'artist'].includes(type.value)) {
|
||||
// by default, sort Album/Artist by track numbers for a more friendly UX
|
||||
sortFields.value.push('song.track')
|
||||
order = 'Asc'
|
||||
}
|
||||
|
||||
if (!this.sortFields.length && ['album', 'artist'].includes(this.type)) {
|
||||
// by default, sort Album/Artist by track numbers for a more friendly UX
|
||||
this.sortFields.push('song.track')
|
||||
order = SortOrder.Asc
|
||||
}
|
||||
if (sortFields.value.includes('song.track') && !sortFields.value.includes('song.disc')) {
|
||||
// Track numbers should always go in conjunction with disc numbers.
|
||||
sortFields.value.push('song.disc')
|
||||
}
|
||||
|
||||
if (this.sortFields.includes('song.track') && !this.sortFields.includes('song.disc')) {
|
||||
// Track numbers should always go in conjunction with disc numbers.
|
||||
this.sortFields.push('song.disc')
|
||||
}
|
||||
sortOrder.value = order === null ? nextSortOrder.value : order
|
||||
|
||||
this.sortOrder = order === null ? this.nextSortOrder() : order
|
||||
songProxies.value = sortOrder.value === 'None'
|
||||
? generateSongProxies()
|
||||
: orderBy(songProxies.value, sortFields.value, sortOrder.value === 'Desc')
|
||||
}
|
||||
|
||||
this.songProxies = this.sortOrder === SortOrder.None
|
||||
? this.generateSongProxies()
|
||||
: orderBy(this.songProxies, this.sortFields, this.sortOrder)
|
||||
},
|
||||
const render = () => {
|
||||
mergedConfig.value.sortable || (sortFields.value = [])
|
||||
songProxies.value = generateSongProxies()
|
||||
sort(sortFields.value, sortOrder.value)
|
||||
}
|
||||
|
||||
nextSortOrder (): SortOrder {
|
||||
if (this.sortOrder === SortOrder.None) return SortOrder.Asc
|
||||
if (this.sortOrder === SortOrder.Asc) return SortOrder.Desc
|
||||
return SortOrder.None
|
||||
},
|
||||
watch(items, () => render())
|
||||
|
||||
handleDelete (): void {
|
||||
if (!this.selectedSongs.length) {
|
||||
return
|
||||
}
|
||||
watch(selectedSongs, () => eventBus.emit('SET_SELECTED_SONGS', selectedSongs, getCurrentInstance()?.parent))
|
||||
|
||||
switch (this.type) {
|
||||
case 'queue':
|
||||
queueStore.unqueue(this.selectedSongs)
|
||||
break
|
||||
const handleDelete = () => {
|
||||
if (!selectedSongs.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
case 'favorites':
|
||||
favoriteStore.unlike(this.selectedSongs)
|
||||
break
|
||||
switch (type.value) {
|
||||
case 'queue':
|
||||
queueStore.unqueue(selectedSongs.value)
|
||||
break
|
||||
|
||||
case 'playlist':
|
||||
playlistStore.removeSongs(this.playlist, this.selectedSongs)
|
||||
break
|
||||
case 'favorites':
|
||||
favoriteStore.unlike(selectedSongs.value)
|
||||
break
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
case 'playlist':
|
||||
playlistStore.removeSongs(playlist!.value!, selectedSongs.value)
|
||||
break
|
||||
|
||||
this.clearSelection()
|
||||
},
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
handleEnter (event: DragEvent): void {
|
||||
if (!this.selectedSongs.length) {
|
||||
return
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
if (this.selectedSongs.length === 1) {
|
||||
// Just play the song
|
||||
playback.play(this.selectedSongs[0])
|
||||
return
|
||||
}
|
||||
const handleEnter = (event: DragEvent) => {
|
||||
if (!selectedSongs.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (this.type) {
|
||||
case 'queue':
|
||||
// Play the first song selected if we're in Queue screen.
|
||||
playback.play(this.selectedSongs[0])
|
||||
break
|
||||
if (selectedSongs.value.length === 1) {
|
||||
// Just play the song
|
||||
playback.play(selectedSongs.value[0])
|
||||
return
|
||||
}
|
||||
|
||||
default:
|
||||
//
|
||||
// --------------------------------------------------------------------
|
||||
// For other screens, follow this map:
|
||||
//
|
||||
// • Enter: Queue songs to bottom
|
||||
// • Shift+Enter: Queues song to top
|
||||
// • Cmd/Ctrl+Enter: Queues song to bottom and play the first selected song
|
||||
// • Cmd/Ctrl+Shift+Enter: Queue songs to top and play the first queued song
|
||||
// --------------------------------------------------------------------
|
||||
//
|
||||
if (event.shiftKey) {
|
||||
queueStore.queueToTop(this.selectedSongs)
|
||||
} else {
|
||||
queueStore.queue(this.selectedSongs)
|
||||
}
|
||||
switch (type.value) {
|
||||
case 'queue':
|
||||
// Play the first song selected if we're in Queue screen.
|
||||
playback.play(selectedSongs.value[0])
|
||||
break
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
playback.play(this.selectedSongs[0])
|
||||
}
|
||||
|
||||
router.go('queue')
|
||||
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
handleA (event: KeyboardEvent): void {
|
||||
if (!event.metaKey && !event.ctrlKey) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectAllRows()
|
||||
},
|
||||
|
||||
/**
|
||||
* Select all (filtered) rows in the current list.
|
||||
*/
|
||||
selectAllRows (): void {
|
||||
this.songProxies.forEach(row => (row.selected = true))
|
||||
},
|
||||
|
||||
rowClicked (rowVm: SongListRowComponent, event: MouseEvent): void {
|
||||
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
|
||||
if (isMobile.any) {
|
||||
this.toggleRow(rowVm)
|
||||
return
|
||||
default:
|
||||
//
|
||||
// --------------------------------------------------------------------
|
||||
// For other screens, follow this map:
|
||||
//
|
||||
// • Enter: Queue songs to bottom
|
||||
// • Shift+Enter: Queues song to top
|
||||
// • Cmd/Ctrl+Enter: Queues song to bottom and play the first selected song
|
||||
// • Cmd/Ctrl+Shift+Enter: Queue songs to top and play the first queued song
|
||||
// --------------------------------------------------------------------
|
||||
//
|
||||
if (event.shiftKey) {
|
||||
queueStore.queueToTop(selectedSongs.value)
|
||||
} else {
|
||||
queueStore.queue(selectedSongs.value)
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
this.toggleRow(rowVm)
|
||||
playback.play(selectedSongs.value[0])
|
||||
}
|
||||
|
||||
if (event.button === 0) {
|
||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey)) {
|
||||
this.clearSelection()
|
||||
this.toggleRow(rowVm)
|
||||
}
|
||||
router.go('queue')
|
||||
|
||||
if (event.shiftKey && this.lastSelectedRow) {
|
||||
this.selectRowsBetween(this.lastSelectedRow, rowVm)
|
||||
}
|
||||
}
|
||||
},
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
toggleRow (rowVm: SongListRowComponent): void {
|
||||
rowVm.item.selected = !rowVm.item.selected
|
||||
this.lastSelectedRow = rowVm
|
||||
},
|
||||
/**
|
||||
* Select all (filtered) rows in the current list.
|
||||
*/
|
||||
const selectAllRows = () => songProxies.value.forEach(row => (row.selected = true))
|
||||
const clearSelection = () => songProxies.value.forEach(row => (row.selected = false))
|
||||
|
||||
selectRowsBetween (firstRowVm: SongListRowComponent, secondRowVm: SongListRowComponent): void {
|
||||
const indexes = [
|
||||
this.songProxies.indexOf(firstRowVm.item),
|
||||
this.songProxies.indexOf(secondRowVm.item)
|
||||
]
|
||||
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
|
||||
|
||||
indexes.sort((a, b) => a - b)
|
||||
const rowClicked = (rowVm: SongRow, event: MouseEvent) => {
|
||||
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
|
||||
if (isMobile.any) {
|
||||
toggleRow(rowVm)
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = indexes[0]; i <= indexes[1]; ++i) {
|
||||
this.songProxies[i].selected = true
|
||||
}
|
||||
},
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
toggleRow(rowVm)
|
||||
}
|
||||
|
||||
clearSelection (): void {
|
||||
this.songProxies.forEach((row: SongProxy): void => {
|
||||
row.selected = false
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable dragging songs by capturing the dragstart event on a table row.
|
||||
* Even though the event is triggered on one row only, we'll collect other
|
||||
* selected rows, if any, as well.
|
||||
*/
|
||||
dragStart (rowVm: SongListRowComponent, event: DragEvent): void {
|
||||
// If the user is dragging an unselected row, clear the current selection.
|
||||
if (!rowVm.item.selected) {
|
||||
this.clearSelection()
|
||||
rowVm.item.selected = true
|
||||
}
|
||||
|
||||
startDragging(event, this.selectedSongs, 'Song')
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a "droppable" class and set the drop effect when other songs are dragged over a row.
|
||||
*/
|
||||
allowDrop (event: DragEvent) {
|
||||
if (!this.allowSongReordering) {
|
||||
return
|
||||
}
|
||||
|
||||
$.addClass((event.target as Element).parentElement, 'droppable')
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Perform reordering songs upon dropping if the current song list is of type Queue.
|
||||
*/
|
||||
handleDrop (rowVm: SongListRowComponent, event: DragEvent): boolean {
|
||||
if (
|
||||
!this.allowSongReordering ||
|
||||
!event.dataTransfer!.getData('application/x-koel.text+plain') ||
|
||||
!this.selectedSongs.length
|
||||
) {
|
||||
return this.removeDroppableState(event)
|
||||
}
|
||||
|
||||
queueStore.move(this.selectedSongs, rowVm.item.song)
|
||||
return this.removeDroppableState(event)
|
||||
},
|
||||
|
||||
removeDroppableState: (event: DragEvent): boolean => {
|
||||
$.removeClass((event.target as Element).parentElement, 'droppable')
|
||||
return false
|
||||
},
|
||||
|
||||
openContextMenu (rowVm: SongListRowComponent, e: MouseEvent): void {
|
||||
// If the user is right-clicking an unselected row,
|
||||
// clear the current selection and select it instead.
|
||||
if (!rowVm.item.selected) {
|
||||
this.clearSelection()
|
||||
this.toggleRow(rowVm)
|
||||
}
|
||||
|
||||
this.$nextTick((): void => {
|
||||
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', e, this.selectedSongs)
|
||||
})
|
||||
},
|
||||
|
||||
getAllSongsWithSort (): Song[] {
|
||||
return this.songProxies.map(proxy => proxy.song)
|
||||
if (event.button === 0) {
|
||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey)) {
|
||||
clearSelection()
|
||||
toggleRow(rowVm)
|
||||
}
|
||||
},
|
||||
|
||||
mounted (): void {
|
||||
if (this.items) {
|
||||
this.render()
|
||||
if (event.shiftKey && lastSelectedRow.value) {
|
||||
selectRowsBetween(lastSelectedRow.value, rowVm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRow = (rowVm: SongRow) => {
|
||||
rowVm.props.item.selected = !rowVm.props.item.selected
|
||||
lastSelectedRow.value = rowVm
|
||||
}
|
||||
|
||||
const selectRowsBetween = (firstRowVm: SongRow, secondRowVm: SongRow) => {
|
||||
const indexes = [
|
||||
songProxies.value.indexOf(firstRowVm.props.item),
|
||||
songProxies.value.indexOf(secondRowVm.props.item)
|
||||
]
|
||||
|
||||
indexes.sort((a, b) => a - b)
|
||||
|
||||
for (let i = indexes[0]; i <= indexes[1]; ++i) {
|
||||
songProxies.value[i].selected = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable dragging songs by capturing the dragstart event on a table row.
|
||||
* Even though the event is triggered on one row only, we'll collect other
|
||||
* selected rows, if any, as well.
|
||||
*/
|
||||
const dragStart = (rowVm: SongRow, event: DragEvent) => {
|
||||
// If the user is dragging an unselected row, clear the current selection.
|
||||
if (!rowVm.props.item.selected) {
|
||||
clearSelection()
|
||||
rowVm.props.item.selected = true
|
||||
}
|
||||
|
||||
startDragging(event, selectedSongs.value, 'Song')
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a "droppable" class and set the drop effect when other songs are dragged over a row.
|
||||
*/
|
||||
const allowDrop = (event: DragEvent) => {
|
||||
if (!allowSongReordering.value) {
|
||||
return
|
||||
}
|
||||
|
||||
$.addClass((event.target as Element).parentElement, 'droppable')
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform reordering songs upon dropping if the current song list is of type Queue.
|
||||
*/
|
||||
const handleDrop = (rowVm: SongRow, event: DragEvent) => {
|
||||
if (
|
||||
!allowSongReordering.value ||
|
||||
!event.dataTransfer!.getData('application/x-koel.text+plain') ||
|
||||
!selectedSongs.value.length
|
||||
) {
|
||||
return removeDroppableState(event)
|
||||
}
|
||||
|
||||
queueStore.move(selectedSongs.value, rowVm.props.item.song)
|
||||
return removeDroppableState(event)
|
||||
}
|
||||
|
||||
const removeDroppableState = (event: DragEvent) => {
|
||||
$.removeClass((event.target as Element).parentElement, 'droppable')
|
||||
return false
|
||||
}
|
||||
|
||||
const openContextMenu = async (rowVm: SongRow, event: MouseEvent) => {
|
||||
// If the user is right-clicking an unselected row,
|
||||
// clear the current selection and select it instead.
|
||||
if (!rowVm.props.item.selected) {
|
||||
clearSelection()
|
||||
toggleRow(rowVm)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, selectedSongs.value)
|
||||
}
|
||||
|
||||
const getAllSongsWithSort = () => songProxies.value.map(proxy => proxy.song)
|
||||
|
||||
onMounted(() => items.value && render())
|
||||
|
||||
defineExpose({
|
||||
rowClicked,
|
||||
dragStart,
|
||||
allowDrop,
|
||||
handleDrop,
|
||||
removeDroppableState,
|
||||
openContextMenu,
|
||||
getAllSongsWithSort
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -449,21 +407,15 @@ export default Vue.extend({
|
|||
right: 0;
|
||||
background: var(--color-bg-secondary);
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
tr.droppable {
|
||||
div.droppable {
|
||||
border-bottom-width: 3px;
|
||||
border-bottom-color: var(--color-green);
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
.song-list-header span, .song-item span {
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
|
@ -472,26 +424,26 @@ export default Vue.extend({
|
|||
white-space: nowrap;
|
||||
|
||||
&.time {
|
||||
width: 96px;
|
||||
flex-basis: 96px;
|
||||
padding-right: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.track-number {
|
||||
width: 66px;
|
||||
flex-basis: 66px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
&.artist {
|
||||
width: 23%;
|
||||
flex-basis: 23%;
|
||||
}
|
||||
|
||||
&.album {
|
||||
width: 27%;
|
||||
flex-basis: 27%;
|
||||
}
|
||||
|
||||
&.favorite {
|
||||
width: 36px;
|
||||
flex-basis: 36px;
|
||||
}
|
||||
|
||||
&.play {
|
||||
|
@ -504,9 +456,13 @@ export default Vue.extend({
|
|||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.title {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
.song-list-header {
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
|
@ -518,7 +474,7 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
.unsortable th {
|
||||
.unsortable span {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
@ -545,14 +501,7 @@ export default Vue.extend({
|
|||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
table,
|
||||
tbody,
|
||||
tr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
thead,
|
||||
tfoot {
|
||||
.song-list-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
@ -566,7 +515,7 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
.song-item {
|
||||
padding: 8px 32px 8px 4px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
@ -576,7 +525,7 @@ export default Vue.extend({
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
td {
|
||||
.song-item span {
|
||||
display: none;
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
|
|
|
@ -2,33 +2,21 @@
|
|||
<div :style="{ backgroundImage: thumbnailUrl ? `url(${thumbnailUrl})` : 'none' }" data-testid="album-art-overlay"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs, watchEffect } from 'vue'
|
||||
import { albumStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
song: {
|
||||
type: Object
|
||||
} as PropOptions<Song | null>
|
||||
},
|
||||
const props = defineProps<{ song: Song | null }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
thumbnailUrl: null as string | null
|
||||
}),
|
||||
const thumbnailUrl = ref<String | null>(null)
|
||||
|
||||
watch: {
|
||||
song: {
|
||||
immediate: true,
|
||||
async handler (): Promise<void> {
|
||||
if (this.song) {
|
||||
try {
|
||||
this.thumbnailUrl = await albumStore.getThumbnail(this.song.album)
|
||||
} catch (e) {
|
||||
this.thumbnailUrl = null
|
||||
}
|
||||
}
|
||||
}
|
||||
watchEffect(async () => {
|
||||
if (song.value) {
|
||||
try {
|
||||
thumbnailUrl.value = await albumStore.getThumbnail(song.value.album)
|
||||
} catch (e) {
|
||||
thumbnailUrl.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -19,128 +19,91 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, toRefs } from 'vue'
|
||||
import { orderBy } from 'lodash'
|
||||
import { queueStore, albumStore, artistStore, userStore } from '@/stores'
|
||||
import { albumStore, artistStore, queueStore, userStore } from '@/stores'
|
||||
import { playback } from '@/services'
|
||||
import { getDefaultCover, fileReader } from '@/utils'
|
||||
import { fileReader, getDefaultCover } from '@/utils'
|
||||
|
||||
const VALID_IMAGE_TYPES = ['image/jpeg', 'image/gif', 'image/png']
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
entity: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Album | Artist>
|
||||
},
|
||||
const props = defineProps<{ entity: Album | Artist }>()
|
||||
const { entity } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
droppable: false,
|
||||
userState: userStore.state
|
||||
}),
|
||||
const droppable = ref(false)
|
||||
const userState = reactive(userStore.state)
|
||||
|
||||
computed: {
|
||||
forAlbum (): boolean {
|
||||
return 'artist' in this.entity
|
||||
},
|
||||
const forAlbum = computed(() => 'artist' in entity)
|
||||
const sortFields = computed(() => forAlbum.value ? ['disc', 'track'] : ['album_id', 'disc', 'track'])
|
||||
|
||||
sortFields (): string[] {
|
||||
return this.forAlbum ? ['disc', 'track'] : ['album_id', 'disc', 'track']
|
||||
},
|
||||
const backgroundImageUrl = computed(() => forAlbum.value
|
||||
? (entity.value as Album).cover ? (entity.value as Album).cover : getDefaultCover()
|
||||
: (entity.value as Artist).image ? (entity.value as Artist).image : getDefaultCover()
|
||||
)
|
||||
|
||||
backgroundImageUrl (): string {
|
||||
if (this.forAlbum) {
|
||||
const entity = this.entity as Album
|
||||
return entity.cover ? entity.cover : getDefaultCover()
|
||||
} else {
|
||||
const entity = this.entity as Artist
|
||||
return entity.image ? entity.image : getDefaultCover()
|
||||
}
|
||||
},
|
||||
const buttonLabel = computed(() => forAlbum.value
|
||||
? `Play all songs in the album ${entity.value.name}`
|
||||
: `Play all songs by the artist ${entity.value.name}`
|
||||
)
|
||||
|
||||
buttonLabel (): string {
|
||||
return this.forAlbum
|
||||
? `Play all songs in the album ${this.entity.name}`
|
||||
: `Play all songs by the artist ${this.entity.name}`
|
||||
},
|
||||
const playbackFunc = computed(() => forAlbum.value ? playback.playAllInAlbum : playback.playAllByArtist)
|
||||
|
||||
playbackFunc (): Function {
|
||||
return this.forAlbum ? playback.playAllInAlbum : playback.playAllByArtist
|
||||
},
|
||||
const allowsUpload = computed(() => userState.current.is_admin)
|
||||
|
||||
allowsUpload (): boolean {
|
||||
return this.userState.current.is_admin
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
playOrQueue (e: KeyboardEvent) {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
queueStore.queue(orderBy(this.entity.songs, this.sortFields))
|
||||
} else {
|
||||
this.playbackFunc.call(playback, this.entity, false)
|
||||
}
|
||||
},
|
||||
|
||||
onDragEnter (): void {
|
||||
this.droppable = this.allowsUpload
|
||||
},
|
||||
|
||||
onDragLeave (): void {
|
||||
this.droppable = false
|
||||
},
|
||||
|
||||
async onDrop (e: DragEvent): Promise<void> {
|
||||
this.droppable = false
|
||||
|
||||
if (!this.allowsUpload) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.validImageDropEvent(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = await fileReader.readAsDataUrl(e.dataTransfer!.files[0])
|
||||
|
||||
if (this.forAlbum) {
|
||||
// Replace the image right away to create a swift effect
|
||||
(this.entity as Album).cover = fileData
|
||||
albumStore.uploadCover(this.entity as Album, fileData)
|
||||
} else {
|
||||
(this.entity as Artist).image = fileData
|
||||
artistStore.uploadImage(this.entity as Artist, fileData)
|
||||
}
|
||||
} catch (exception) {
|
||||
/* eslint no-console: 0 */
|
||||
console.error(exception)
|
||||
}
|
||||
},
|
||||
|
||||
validImageDropEvent: (e: DragEvent): boolean => {
|
||||
if (!e.dataTransfer || !e.dataTransfer.items) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (e.dataTransfer.items.length !== 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (e.dataTransfer.items[0].kind !== 'file') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!VALID_IMAGE_TYPES.includes(e.dataTransfer.items[0].getAsFile()!.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
const playOrQueue = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
queueStore.queue(orderBy(entity.value.songs, sortFields.value))
|
||||
} else {
|
||||
playbackFunc.value.call(playback, entity.value, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDragEnter = () => (droppable.value = allowsUpload.value)
|
||||
const onDragLeave = () => (droppable.value = false)
|
||||
|
||||
const validImageDropEvent = (event: DragEvent) => {
|
||||
if (!event.dataTransfer || !event.dataTransfer.items) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.dataTransfer.items.length !== 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.dataTransfer.items[0].kind !== 'file') {
|
||||
return false
|
||||
}
|
||||
|
||||
return VALID_IMAGE_TYPES.includes(event.dataTransfer.items[0].getAsFile()!.type)
|
||||
}
|
||||
|
||||
const onDrop = async (event: DragEvent) => {
|
||||
droppable.value = false
|
||||
|
||||
if (!allowsUpload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!validImageDropEvent(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = await fileReader.readAsDataUrl(event.dataTransfer!.files[0])
|
||||
|
||||
if (forAlbum.value) {
|
||||
// Replace the image right away to create a swift effect
|
||||
(entity.value as Album).cover = fileData
|
||||
await albumStore.uploadCover(entity.value as Album, fileData)
|
||||
} else {
|
||||
(entity.value as Artist).image = fileData
|
||||
await artistStore.uploadImage(entity.value as Artist, fileData)
|
||||
}
|
||||
} catch (exception) {
|
||||
console.error(exception)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,21 +1,17 @@
|
|||
<template>
|
||||
<span class="btn-group">
|
||||
<slot>
|
||||
<btn green>Foo</btn>
|
||||
<btn orange>Bar</btn>
|
||||
<btn red>Baz</btn>
|
||||
<Btn green>Foo</Btn>
|
||||
<Btn orange>Bar</Btn>
|
||||
<Btn red>Baz</Btn>
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
}
|
||||
})
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -4,8 +4,7 @@
|
|||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {}
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -4,10 +4,7 @@
|
|||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
|
||||
export default Vue.extend({})
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<nav
|
||||
class="menu"
|
||||
class="menu context-menu"
|
||||
:class="extraClass"
|
||||
:style="{ top: `${top}px`, left: `${left}px` }"
|
||||
@contextmenu.prevent
|
||||
|
@ -9,6 +9,7 @@
|
|||
v-koel-clickaway="close"
|
||||
@keydown.esc="close"
|
||||
v-if="shown"
|
||||
ref="el"
|
||||
>
|
||||
<ul>
|
||||
<slot>Menu items go here.</slot>
|
||||
|
@ -16,128 +17,77 @@
|
|||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, ref, toRefs } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
extraClass: {
|
||||
required: false,
|
||||
type: String
|
||||
}
|
||||
},
|
||||
const props = defineProps<{ extraClass: string }>()
|
||||
const { extraClass } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
shown: false,
|
||||
top: 0,
|
||||
left: 0
|
||||
}),
|
||||
const el = ref<HTMLElement>()
|
||||
const shown = ref(false)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
|
||||
methods: {
|
||||
async open (top = 0, left = 0): Promise<void> {
|
||||
this.top = top
|
||||
this.left = left
|
||||
this.shown = true
|
||||
const preventOffScreen = async (element: HTMLElement, isSubmenu = false) => {
|
||||
const { bottom, right } = element.getBoundingClientRect()
|
||||
|
||||
await this.$nextTick()
|
||||
|
||||
try {
|
||||
await this.preventOffScreen(this.$el)
|
||||
this.initSubmenus()
|
||||
} catch (e) {
|
||||
// in a non-browser environment (e.g., unit testing), these two functions are broken due to calls to
|
||||
// getBoundingClientRect() and querySelectorAll
|
||||
}
|
||||
},
|
||||
|
||||
close (): void {
|
||||
this.shown = false
|
||||
},
|
||||
|
||||
async preventOffScreen (element: HTMLElement, isSubmenu = false): Promise<void> {
|
||||
const { bottom, right } = element.getBoundingClientRect()
|
||||
|
||||
if (bottom > window.innerHeight) {
|
||||
element.style.top = 'auto'
|
||||
element.style.bottom = '0'
|
||||
} else {
|
||||
element.style.bottom = 'auto'
|
||||
}
|
||||
|
||||
if (right > window.innerWidth) {
|
||||
element.style.right = isSubmenu ? `${this.$el.getBoundingClientRect().width}px` : '0'
|
||||
element.style.left = 'auto'
|
||||
} else {
|
||||
element.style.right = 'auto'
|
||||
}
|
||||
},
|
||||
|
||||
initSubmenus (): void {
|
||||
Array.from(this.$el.querySelectorAll('.has-sub') as NodeListOf<HTMLElement>).forEach((item): void => {
|
||||
const submenu = item.querySelector<HTMLElement>('.submenu')
|
||||
|
||||
if (!submenu) {
|
||||
return
|
||||
}
|
||||
|
||||
item.addEventListener('mouseenter', (): void => {
|
||||
submenu.style.display = 'block'
|
||||
this.preventOffScreen(submenu, true)
|
||||
})
|
||||
|
||||
item.addEventListener('mouseleave', (): void => {
|
||||
submenu.style.top = '0'
|
||||
submenu.style.bottom = 'auto'
|
||||
submenu.style.display = 'none'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu {
|
||||
@include context-menu();
|
||||
position: fixed;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding: 4px 12px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-highlight);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&.separator {
|
||||
pointer-events: none;
|
||||
padding: 1px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
&.has-sub {
|
||||
padding-right: 24px;
|
||||
|
||||
&:after {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 4px;
|
||||
content: "▶";
|
||||
font-size: .9rem;
|
||||
width: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
if (bottom > window.innerHeight) {
|
||||
element.style.top = 'auto'
|
||||
element.style.bottom = '0'
|
||||
} else {
|
||||
element.style.bottom = 'auto'
|
||||
}
|
||||
|
||||
.submenu {
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
if (right > window.innerWidth) {
|
||||
element.style.right = isSubmenu ? `${el.value?.getBoundingClientRect().width}px` : '0'
|
||||
element.style.left = 'auto'
|
||||
} else {
|
||||
element.style.right = 'auto'
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
const initSubmenus = () => {
|
||||
Array.from(el.value?.querySelectorAll('.has-sub') as NodeListOf<HTMLElement>).forEach(item => {
|
||||
const submenu = item.querySelector<HTMLElement>('.submenu')
|
||||
|
||||
if (!submenu) {
|
||||
return
|
||||
}
|
||||
|
||||
item.addEventListener('mouseenter', async () => {
|
||||
submenu.style.display = 'block'
|
||||
await nextTick()
|
||||
await preventOffScreen(submenu, true)
|
||||
})
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
submenu.style.top = '0'
|
||||
submenu.style.bottom = 'auto'
|
||||
submenu.style.display = 'none'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const open = async (_top = 0, _left = 0) => {
|
||||
top.value = _top
|
||||
left.value = _left
|
||||
shown.value = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
try {
|
||||
await preventOffScreen(el.value!)
|
||||
await initSubmenus()
|
||||
} catch (e) {
|
||||
console.error(el.value, e)
|
||||
// in a non-browser environment (e.g., unit testing), these two functions are broken due to calls to
|
||||
// getBoundingClientRect() and querySelectorAll
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
shown.value = false
|
||||
}
|
||||
|
||||
defineExpose({ open, close, shown })
|
||||
</script>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div id="equalizer" data-testid="equalizer">
|
||||
<div id="equalizer" data-testid="equalizer" ref="root">
|
||||
<div class="presets">
|
||||
<label class="select-wrapper">
|
||||
<select v-model="selectedPresetIndex">
|
||||
|
@ -27,11 +27,11 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import nouislider from 'nouislider'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { eventBus, $ } from '@/utils'
|
||||
import { eventBus } from '@/utils'
|
||||
import { equalizerStore, preferenceStore as preferences } from '@/stores'
|
||||
import { audio as audioService } from '@/services'
|
||||
import { SliderElement } from 'koel/types/ui'
|
||||
|
@ -41,169 +41,151 @@ interface Band {
|
|||
filter: BiquadFilterNode
|
||||
}
|
||||
|
||||
let context: AudioContext
|
||||
let context: AudioContext|null = null
|
||||
let preampGainNode: GainNode|null = null
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
bands: [] as Band[],
|
||||
preampGainValue: 0,
|
||||
selectedPresetIndex: -1,
|
||||
preampGainNode: null as unknown as GainNode
|
||||
}),
|
||||
const root = ref(null as unknown as HTMLElement)
|
||||
const bands = ref<Band[]>([])
|
||||
const preampGainValue = ref(0)
|
||||
const selectedPresetIndex = ref(-1)
|
||||
|
||||
computed: {
|
||||
presets (): EqualizerPreset[] {
|
||||
const clonedPresets = Object.assign([], equalizerStore.presets)
|
||||
const presets: EqualizerPreset[] = Object.assign([], equalizerStore.presets)
|
||||
|
||||
// Prepend an empty option for instruction purpose.
|
||||
clonedPresets.unshift({
|
||||
id: -1,
|
||||
name: 'Preset',
|
||||
preamp: 0,
|
||||
gains: []
|
||||
})
|
||||
// Prepend an empty option for instruction purpose.`
|
||||
presets.unshift({
|
||||
id: -1,
|
||||
name: 'Preset',
|
||||
preamp: 0,
|
||||
gains: []
|
||||
})
|
||||
|
||||
return clonedPresets
|
||||
const changePreampGain = (dbValue: number) => {
|
||||
preampGainValue.value = dbValue
|
||||
preampGainNode!.gain.setTargetAtTime(Math.pow(10, dbValue / 20), context!.currentTime, 0.01)
|
||||
}
|
||||
|
||||
const changeFilterGain = (filter: BiquadFilterNode, value: number) => {
|
||||
filter.gain.setTargetAtTime(value, context!.currentTime, 0.01)
|
||||
}
|
||||
|
||||
const createSliders = () => {
|
||||
const config = equalizerStore.get()
|
||||
|
||||
Array.from(root.value.querySelectorAll('.slider') as NodeListOf<SliderElement>).forEach((el, i) => {
|
||||
if (el.noUiSlider) {
|
||||
el.noUiSlider.destroy()
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
selectedPresetIndex (val: number): void {
|
||||
// Save the selected preset (index) every time the value's changed.
|
||||
preferences.selectedPreset = val
|
||||
nouislider.create(el, {
|
||||
connect: [false, true],
|
||||
// the first element is the preamp. The rest are gains.
|
||||
start: i === 0 ? config.preamp : config.gains[i - 1],
|
||||
range: { min: -20, max: 20 },
|
||||
orientation: 'vertical',
|
||||
direction: 'rtl'
|
||||
})
|
||||
|
||||
if (~~val !== -1) {
|
||||
this.loadPreset(equalizerStore.getPresetById(val))
|
||||
if (!el.noUiSlider) {
|
||||
throw new Error(`Failed to initialize slider on element ${i}`)
|
||||
}
|
||||
|
||||
el.noUiSlider.on('slide', (values, handle) => {
|
||||
const value = values[handle]
|
||||
if (el.parentElement!.matches('.preamp')) {
|
||||
changePreampGain(value)
|
||||
} else {
|
||||
changeFilterGain(bands.value[i - 1].filter, value)
|
||||
}
|
||||
})
|
||||
|
||||
el.noUiSlider.on('change', () => {
|
||||
// User has customized the equalizer. No preset should be selected.
|
||||
selectedPresetIndex.value = -1
|
||||
save()
|
||||
})
|
||||
})
|
||||
|
||||
// Now we set this value to trigger the audio processing.
|
||||
selectedPresetIndex.value = preferences.selectedPreset
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
const config: EqualizerPreset = equalizerStore.get()
|
||||
|
||||
context = audioService.getContext()
|
||||
preampGainNode = context.createGain()
|
||||
changePreampGain(config.preamp)
|
||||
|
||||
const source = audioService.getSource()
|
||||
source.connect(preampGainNode)
|
||||
|
||||
let prevFilter: BiquadFilterNode
|
||||
|
||||
// Create 10 bands with the frequencies similar to those of Winamp and connect them together.
|
||||
const frequencies = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]
|
||||
|
||||
frequencies.forEach((frequency, i) => {
|
||||
const filter = context!.createBiquadFilter()
|
||||
|
||||
if (i === 0) {
|
||||
filter.type = 'lowshelf'
|
||||
} else if (i === 9) {
|
||||
filter.type = 'highshelf'
|
||||
} else {
|
||||
filter.type = 'peaking'
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
init (): void {
|
||||
const config: EqualizerPreset = equalizerStore.get()
|
||||
filter.gain.setTargetAtTime(0, context!.currentTime, 0.01)
|
||||
filter.Q.setTargetAtTime(1, context!.currentTime, 0.01)
|
||||
filter.frequency.setTargetAtTime(frequency, context!.currentTime, 0.01)
|
||||
|
||||
context = audioService.getContext()
|
||||
this.preampGainNode = context.createGain()
|
||||
this.changePreampGain(config.preamp)
|
||||
prevFilter ? prevFilter.connect(filter) : preampGainNode!.connect(filter)
|
||||
prevFilter = filter
|
||||
|
||||
const source = audioService.getSource()
|
||||
source.connect(this.preampGainNode)
|
||||
bands.value.push({
|
||||
filter,
|
||||
label: String(frequency).replace('000', 'K')
|
||||
})
|
||||
})
|
||||
|
||||
let prevFilter: BiquadFilterNode
|
||||
prevFilter!.connect(context.destination)
|
||||
|
||||
// Create 10 bands with the frequencies similar to those of Winamp and connect them together.
|
||||
const frequencies = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]
|
||||
frequencies.forEach((frequency: number, i: number): void => {
|
||||
const filter = context.createBiquadFilter()
|
||||
await nextTick()
|
||||
createSliders()
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
filter.type = 'lowshelf'
|
||||
} else if (i === 9) {
|
||||
filter.type = 'highshelf'
|
||||
} else {
|
||||
filter.type = 'peaking'
|
||||
}
|
||||
const save = () => equalizerStore.set(preampGainValue.value, bands.value.map(band => band.filter.gain.value))
|
||||
|
||||
filter.gain.setTargetAtTime(0, context.currentTime, 0.01)
|
||||
filter.Q.setTargetAtTime(1, context.currentTime, 0.01)
|
||||
filter.frequency.setTargetAtTime(frequency, context.currentTime, 0.01)
|
||||
|
||||
prevFilter ? prevFilter.connect(filter) : this.preampGainNode.connect(filter)
|
||||
prevFilter = filter
|
||||
|
||||
this.bands.push({
|
||||
filter,
|
||||
label: String(frequency).replace('000', 'K')
|
||||
})
|
||||
})
|
||||
|
||||
prevFilter!.connect(context.destination)
|
||||
|
||||
this.$nextTick((): void => this.createSliders())
|
||||
},
|
||||
|
||||
createSliders (): void {
|
||||
const config = equalizerStore.get()
|
||||
|
||||
Array.from(document.querySelectorAll('#equalizer .slider') as NodeListOf<SliderElement>)
|
||||
.forEach((el: SliderElement, i: number): void => {
|
||||
if (el.noUiSlider) {
|
||||
el.noUiSlider.destroy()
|
||||
}
|
||||
|
||||
nouislider.create(el, {
|
||||
connect: [false, true],
|
||||
// the first element is the preamp. The rest are gains.
|
||||
start: i === 0 ? config.preamp : config.gains[i - 1],
|
||||
range: { min: -20, max: 20 },
|
||||
orientation: 'vertical',
|
||||
direction: 'rtl'
|
||||
})
|
||||
|
||||
if (!el.noUiSlider) {
|
||||
throw new Error(`Failed to initialize slider on element ${i}`)
|
||||
}
|
||||
|
||||
el.noUiSlider.on('slide', (values: number[], handle: number): void => {
|
||||
const value = values[handle]
|
||||
if (el.parentElement!.matches('.preamp')) {
|
||||
this.changePreampGain(value)
|
||||
} else {
|
||||
this.changeFilterGain(this.bands[i - 1].filter, value)
|
||||
}
|
||||
})
|
||||
|
||||
el.noUiSlider.on('change', (): void => {
|
||||
// User has customized the equalizer. No preset should be selected.
|
||||
this.selectedPresetIndex = -1
|
||||
this.save()
|
||||
})
|
||||
})
|
||||
|
||||
// Now we set this value to trigger the audio processing.
|
||||
this.selectedPresetIndex = preferences.selectedPreset
|
||||
},
|
||||
|
||||
changePreampGain (dbValue: number): void {
|
||||
this.preampGainValue = dbValue
|
||||
this.preampGainNode.gain.setTargetAtTime(Math.pow(10, dbValue / 20), context.currentTime, 0.01)
|
||||
},
|
||||
|
||||
changeFilterGain: (filter: BiquadFilterNode, value: number): void => {
|
||||
filter.gain.setTargetAtTime(value, context.currentTime, 0.01)
|
||||
},
|
||||
|
||||
loadPreset (preset: EqualizerPreset): void {
|
||||
Array.from(document.querySelectorAll('#equalizer .slider') as NodeListOf<SliderElement>)
|
||||
.forEach((el: SliderElement, i: number): void => {
|
||||
if (!el.noUiSlider) {
|
||||
throw new Error('Preset can only be loaded after sliders have been set up')
|
||||
}
|
||||
|
||||
// We treat our preamp slider differently.
|
||||
if ($.is(el.parentElement!, '.preamp')) {
|
||||
this.changePreampGain(preset.preamp)
|
||||
// Update the slider values into GUI.
|
||||
el.noUiSlider.set(preset.preamp)
|
||||
} else {
|
||||
this.changeFilterGain(this.bands[i - 1].filter, preset.gains[i - 1])
|
||||
// Update the slider values into GUI.
|
||||
el.noUiSlider.set(preset.gains[i - 1])
|
||||
}
|
||||
})
|
||||
|
||||
this.save()
|
||||
},
|
||||
|
||||
save (): void {
|
||||
equalizerStore.set(this.preampGainValue, this.bands.map((band: Band): number => band.filter.gain.value))
|
||||
const loadPreset = (preset: EqualizerPreset) => {
|
||||
Array.from(root.value.querySelectorAll('.slider') as NodeListOf<SliderElement>).forEach((el, i) => {
|
||||
if (!el.noUiSlider) {
|
||||
throw new Error('Preset can only be loaded after sliders have been set up')
|
||||
}
|
||||
},
|
||||
|
||||
mounted (): void {
|
||||
eventBus.on('INIT_EQUALIZER', (): void => this.init())
|
||||
// We treat our preamp slider differently.
|
||||
if (el.parentElement!.matches('.preamp')) {
|
||||
changePreampGain(preset.preamp)
|
||||
// Update the slider values into GUI.
|
||||
el.noUiSlider.set(preset.preamp)
|
||||
} else {
|
||||
changeFilterGain(bands.value[i - 1].filter, preset.gains[i - 1])
|
||||
// Update the slider values into GUI.
|
||||
el.noUiSlider.set(preset.gains[i - 1])
|
||||
}
|
||||
})
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
watch(selectedPresetIndex, () => {
|
||||
preferences.selectedPreset = selectedPresetIndex.value
|
||||
|
||||
if (~~selectedPresetIndex.value !== -1) {
|
||||
loadPreset(equalizerStore.getPresetById(selectedPresetIndex.value))
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -313,6 +295,7 @@ export default Vue.extend({
|
|||
&-connect {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
|
||||
&::after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
|
@ -363,7 +346,7 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 768px) {
|
||||
@media only screen and (max-width: 768px) {
|
||||
position: fixed;
|
||||
max-width: 414px;
|
||||
left: auto;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<template v-if="song">
|
||||
<div v-show="song.lyrics">
|
||||
<div ref="lyricsContainer" v-html="song.lyrics"></div>
|
||||
<text-zoomer :target="textZoomTarget"/>
|
||||
<TextZoomer :target="textZoomTarget"/>
|
||||
</div>
|
||||
<p class="none text-secondary" v-if="song.id && !song.lyrics">
|
||||
<template v-if="isAdmin">
|
||||
|
@ -25,43 +25,27 @@
|
|||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onUpdated, reactive, ref, toRefs } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { userStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
song: {
|
||||
type: Object
|
||||
} as PropOptions<Song | null>
|
||||
},
|
||||
const TextZoomer = defineAsyncComponent(() => import('@/components/ui/text-zoomer.vue'))
|
||||
|
||||
components: {
|
||||
TextZoomer: () => import('@/components/ui/text-zoomer.vue')
|
||||
},
|
||||
const props = defineProps<{ song: Song | null }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
textZoomTarget: null as unknown as Element,
|
||||
userState: userStore.state
|
||||
}),
|
||||
const lyricsContainer = ref(null as unknown as HTMLElement)
|
||||
const textZoomTarget = ref(null as unknown as HTMLElement)
|
||||
const userState = reactive(userStore.state)
|
||||
|
||||
methods: {
|
||||
showEditSongForm (): void {
|
||||
eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', this.song, 'lyrics')
|
||||
}
|
||||
},
|
||||
const showEditSongForm = () => eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', [song], 'lyrics')
|
||||
|
||||
computed: {
|
||||
isAdmin (): boolean {
|
||||
return this.userState.current.is_admin
|
||||
}
|
||||
},
|
||||
const isAdmin = computed(() => userState.current.is_admin)
|
||||
|
||||
updated (): void {
|
||||
// Since Vue's $refs are not reactive, we work around by assigning to a data property
|
||||
this.textZoomTarget = this.$refs.lyricsContainer as Element
|
||||
}
|
||||
onUpdated(() => {
|
||||
// Since Vue's $refs are not reactive, we work around by assigning to a data property
|
||||
textZoomTarget.value = lyricsContainer.value
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,50 +10,41 @@
|
|||
<span class="message" v-html="state.message"></span>
|
||||
</div>
|
||||
|
||||
<button class="btn-dismiss" v-if="state.dismissible" @click.prevent="state.showing = false">Close</button>
|
||||
<button class="btn-dismiss" v-if="state.dismissible" @click.prevent="hide">Close</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { assign } from 'lodash'
|
||||
import { eventBus } from '@/utils'
|
||||
import { defineAsyncComponent, reactive } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue')
|
||||
},
|
||||
export type OverlayState = {
|
||||
showing: boolean
|
||||
dismissible: boolean
|
||||
type: 'loading' | 'success' | 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
data: () => ({
|
||||
state: {
|
||||
showing: true,
|
||||
dismissible: false,
|
||||
/**
|
||||
* Either 'loading', 'success', 'info', 'warning', or 'error'.
|
||||
* This dictates the icon as well as possibly other visual appearances.
|
||||
*/
|
||||
type: 'loading',
|
||||
message: ''
|
||||
}
|
||||
}),
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
|
||||
methods: {
|
||||
show (options: object): void {
|
||||
assign(this.state, options)
|
||||
this.state.showing = true
|
||||
},
|
||||
const state = reactive<OverlayState>({
|
||||
showing: true,
|
||||
dismissible: false,
|
||||
type: 'loading',
|
||||
message: ''
|
||||
})
|
||||
|
||||
hide (): void {
|
||||
this.state.showing = false
|
||||
}
|
||||
},
|
||||
const show = (options: Partial<OverlayState>) => {
|
||||
assign(state, options)
|
||||
state.showing = true
|
||||
}
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'SHOW_OVERLAY': (options: object): void => this.show(options),
|
||||
'HIDE_OVERLAY': (): void => this.hide()
|
||||
})
|
||||
}
|
||||
const hide = () => (state.showing = false)
|
||||
|
||||
eventBus.on({
|
||||
'SHOW_OVERLAY': show,
|
||||
'HIDE_OVERLAY': hide
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,29 +10,20 @@
|
|||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { playback } from '@/services'
|
||||
import { preferenceStore } from '@/stores'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
preferences: preferenceStore.state
|
||||
}),
|
||||
const preferences = reactive(preferenceStore.state)
|
||||
|
||||
computed: {
|
||||
readableRepeatMode (): string {
|
||||
return this.preferences.repeatMode
|
||||
.split('_')
|
||||
.map((part: string) => part[0].toUpperCase() + part.substr(1).toLowerCase())
|
||||
.join(' ')
|
||||
}
|
||||
},
|
||||
const readableRepeatMode = computed(() => preferences.repeatMode
|
||||
.split('_')
|
||||
.map(part => part[0].toUpperCase() + part.substring(1).toLowerCase())
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
methods: {
|
||||
changeRepeatMode: (): void => playback.changeRepeatMode()
|
||||
}
|
||||
})
|
||||
const changeRepeatMode = () => playback.changeRepeatMode()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,32 +1,16 @@
|
|||
<template>
|
||||
<span class="controls-toggler text-orange" v-if="isPhone" @click="toggleControls">
|
||||
<span class="controls-toggler text-orange" v-if="isMobile.phone" @click="$emit('toggleControls')">
|
||||
<i class="fa fa-angle-up toggler" v-if="showingControls"/>
|
||||
<i class="fa fa-angle-down toggler" v-else/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
showingControls: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
isPhone: isMobile.phone
|
||||
}),
|
||||
|
||||
methods: {
|
||||
toggleControls (): void {
|
||||
this.$emit('toggleControls')
|
||||
}
|
||||
}
|
||||
})
|
||||
const props = withDefaults(defineProps<{ showingControls: boolean }>(), { showingControls: true })
|
||||
const { showingControls } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -17,18 +17,15 @@
|
|||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { onUpdated, ref } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
hasThumbnail: false
|
||||
}),
|
||||
const thumbnailWrapper = ref(null as unknown as HTMLElement)
|
||||
const hasThumbnail = ref(false)
|
||||
|
||||
updated () {
|
||||
// until :empty is supported, we'll have to resort to this manual check
|
||||
this.hasThumbnail = Boolean((this.$refs.thumbnailWrapper as HTMLElement).children.length)
|
||||
}
|
||||
onUpdated(() => {
|
||||
// until :empty is supported, we'll have to resort to this manual check
|
||||
hasThumbnail.value = Boolean(thumbnailWrapper.value.children.length)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -45,7 +42,7 @@ header {
|
|||
line-height: normal;
|
||||
gap: 1.5rem;
|
||||
|
||||
.thumbnail-wrapper{
|
||||
.thumbnail-wrapper {
|
||||
width: 64px;
|
||||
display: none;
|
||||
|
||||
|
|
|
@ -13,10 +13,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
|
||||
export default Vue.extend({})
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="side search" id="searchForm" :class="{ showing: showing }" role="search">
|
||||
<div class="side search" id="searchForm" :class="{ showing }" role="search">
|
||||
<input
|
||||
type="search"
|
||||
:class="{ dirty: q }"
|
||||
|
@ -15,43 +15,31 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { ref } from 'vue'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
import { eventBus } from '@/utils'
|
||||
import router from '@/router'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
q: '',
|
||||
showing: !isMobile.phone
|
||||
}),
|
||||
const input = ref(null as unknown as HTMLInputElement)
|
||||
const q = ref('')
|
||||
const showing = ref(!isMobile.phone)
|
||||
|
||||
methods: {
|
||||
onInput: debounce(function (): void {
|
||||
// @ts-ignore because of `this`
|
||||
const q = this.q.trim()
|
||||
if (q) {
|
||||
eventBus.emit('SEARCH_KEYWORDS_CHANGED', q)
|
||||
}
|
||||
}, 500),
|
||||
const onInput = debounce(() => {
|
||||
const _q = q.value.trim()
|
||||
_q && eventBus.emit('SEARCH_KEYWORDS_CHANGED', _q)
|
||||
}, 500)
|
||||
|
||||
goToSearchScreen: () => router.go('/search')
|
||||
},
|
||||
const goToSearchScreen = () => router.go('/search')
|
||||
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'TOGGLE_SEARCH_FORM': (): void => {
|
||||
this.showing = !this.showing
|
||||
},
|
||||
eventBus.on({
|
||||
'TOGGLE_SEARCH_FORM': () => (showing.value = !showing.value),
|
||||
|
||||
'FOCUS_SEARCH_FIELD': (): void => {
|
||||
(this.$refs.input as HTMLInputElement).focus()
|
||||
;(this.$refs.input as HTMLInputElement).select()
|
||||
}
|
||||
})
|
||||
FOCUS_SEARCH_FIELD () {
|
||||
input.value.focus()
|
||||
input.value.select()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@ -67,7 +55,7 @@ export default Vue.extend({
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 667px) {
|
||||
@media only screen and (max-width: 667px) {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
|
|
@ -4,45 +4,43 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
// Since we don't have anything here, let us sing a song instead.
|
||||
//
|
||||
// "The House Of The Rising Sun"
|
||||
// -- by The Animals
|
||||
//
|
||||
// There is a house in New Orleans
|
||||
// They call the Rising Sun
|
||||
// And it's been the ruin of many a poor boy
|
||||
// And God, I know I'm one
|
||||
//
|
||||
// My mother was a tailor
|
||||
// She sewed my new blue jeans
|
||||
// My father was a gamblin' man
|
||||
// Down in New Orleans
|
||||
//
|
||||
// Now the only thing a gambler needs
|
||||
// Is a suitcase and trunk
|
||||
// And the only time he's satisfied
|
||||
// Is when he's on a drunk
|
||||
//
|
||||
// [Organ Solo]
|
||||
//
|
||||
// Oh mother, tell your children
|
||||
// Not to do what I have done
|
||||
// Spend your lives in sin and misery
|
||||
// In the House of the Rising Sun
|
||||
//
|
||||
// Well, I got one foot on the platform
|
||||
// The other foot on the train
|
||||
// I'm goin' back to New Orleans
|
||||
// To wear that ball and chain
|
||||
//
|
||||
// Well, there is a house in New Orleans
|
||||
// They call the Rising Sun
|
||||
// And it's been the ruin of many a poor boy
|
||||
// And God, I know I'm one.
|
||||
}
|
||||
<script lang="ts" setup>
|
||||
// Since we don't have anything here, let us sing a song instead.
|
||||
//
|
||||
// "The House Of The Rising Sun"
|
||||
// -- by The Animals
|
||||
//
|
||||
// There is a house in New Orleans
|
||||
// They call the Rising Sun
|
||||
// And it's been the ruin of many a poor boy
|
||||
// And God, I know I'm one
|
||||
//
|
||||
// My mother was a tailor
|
||||
// She sewed my new blue jeans
|
||||
// My father was a gamblin' man
|
||||
// Down in New Orleans
|
||||
//
|
||||
// Now the only thing a gambler needs
|
||||
// Is a suitcase and trunk
|
||||
// And the only time he's satisfied
|
||||
// Is when he's on a drunk
|
||||
//
|
||||
// [Organ Solo]
|
||||
//
|
||||
// Oh mother, tell your children
|
||||
// Not to do what I have done
|
||||
// Spend your lives in sin and misery
|
||||
// In the House of the Rising Sun
|
||||
//
|
||||
// Well, I got one foot on the platform
|
||||
// The other foot on the train
|
||||
// I'm goin' back to New Orleans
|
||||
// To wear that ball and chain
|
||||
//
|
||||
// Well, there is a house in New Orleans
|
||||
// They call the Rising Sun
|
||||
// And it's been the ruin of many a poor boy
|
||||
// And God, I know I'm one.
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -5,34 +5,27 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
target: {
|
||||
type: HTMLElement
|
||||
}
|
||||
},
|
||||
const props = defineProps<{ target: HTMLElement | null }>()
|
||||
const { target } = toRefs(props)
|
||||
|
||||
methods: {
|
||||
zoom (multiplier: number): void {
|
||||
if (!this.target) {
|
||||
return
|
||||
}
|
||||
|
||||
const style = this.target.style
|
||||
|
||||
if (style.fontSize === '') {
|
||||
style.fontSize = '1em'
|
||||
style.lineHeight = '1.6'
|
||||
}
|
||||
|
||||
style.fontSize = parseFloat(style.fontSize) + multiplier * 0.2 + 'em'
|
||||
style.lineHeight = String(parseFloat(style.lineHeight) + multiplier * 0.15)
|
||||
}
|
||||
const zoom = (level: number) => {
|
||||
if (!target.value) {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const style = target.value.style
|
||||
|
||||
if (style.fontSize === '') {
|
||||
style.fontSize = '1em'
|
||||
style.lineHeight = '1.6'
|
||||
}
|
||||
|
||||
style.fontSize = parseFloat(style.fontSize) + level * 0.2 + 'em'
|
||||
style.lineHeight = String(parseFloat(style.lineHeight) + level * 0.15)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<transition name="fade">
|
||||
<div class="to-top-btn-wrapper" v-show="showing">
|
||||
<div class="to-top-btn-wrapper" v-show="showing" ref="el">
|
||||
<button @click="scrollToTop" title="Scroll to top">
|
||||
<i class="fa fa-arrow-circle-up"></i> Top
|
||||
</button>
|
||||
|
@ -8,28 +8,19 @@
|
|||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { $ } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
showing: false
|
||||
}),
|
||||
const el = ref(null as unknown as HTMLElement)
|
||||
const showing = ref(false)
|
||||
|
||||
methods: {
|
||||
scrollToTop (): void {
|
||||
$.scrollTo(this.$el.parentElement!, 0, 500, (): void => {
|
||||
this.showing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
const scrollToTop = () => $.scrollTo(el.value.parentElement!, 0, 500, () => (showing.value = false))
|
||||
|
||||
mounted (): void {
|
||||
this.$el.parentElement && this.$el.parentElement.addEventListener('scroll', (e: Event): void => {
|
||||
this.showing = (e.target as HTMLElement).scrollTop > 64
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
el.value.parentElement?.addEventListener('scroll', event => {
|
||||
showing.value = (event.target as HTMLElement).scrollTop > 64
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -2,15 +2,9 @@
|
|||
<i class="fa fa-question-circle help-trigger text-blue" :title="title"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
})
|
||||
const props = defineProps<{ title: string }>()
|
||||
const { title } = toRefs(props)
|
||||
</script>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div v-koel-clickaway="hideResults">
|
||||
<div v-koel-clickaway="hideResults" ref="el">
|
||||
<input
|
||||
type="text"
|
||||
:name="config.name"
|
||||
|
@ -24,155 +24,131 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, toRefs } from 'vue'
|
||||
import { uniq } from 'lodash'
|
||||
import { filterBy, $ } from '@/utils'
|
||||
import { $, filterBy } from '@/utils'
|
||||
|
||||
interface TypeAheadItem {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
config: Object,
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
} as PropOptions<TypeAheadItem[]>,
|
||||
value: String
|
||||
},
|
||||
const props = defineProps<{ config: Record<string, any>, items: TypeAheadItem[], value: string }>()
|
||||
const { config, items, value } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
filter: '',
|
||||
showingResult: false,
|
||||
mutatedValue: '',
|
||||
lastSelectedValue: ''
|
||||
}),
|
||||
const el = ref(null as unknown as HTMLElement)
|
||||
const showingResult = ref(false)
|
||||
|
||||
computed: {
|
||||
displayedItems (): TypeAheadItem[] {
|
||||
return uniq(filterBy(this.items, this.filter, this.config.filterKey))
|
||||
}
|
||||
},
|
||||
const mutatedValue = ref(value.value)
|
||||
const filter = ref(value.value)
|
||||
let lastSelectedValue = value.value
|
||||
|
||||
methods: {
|
||||
down (): void {
|
||||
this.showingResult = true
|
||||
this.$nextTick((): void => {
|
||||
const selected = this.$el.querySelector('.result li.selected')
|
||||
const displayedItems = computed(() => uniq(filterBy(items.value, filter.value, config.value.filterKey)))
|
||||
|
||||
if (!selected || !selected.nextElementSibling) {
|
||||
// No item selected, or we're at the end of the list.
|
||||
// Select the first item now.
|
||||
$.addClass(this.$el.querySelector('.result li:first-child'), 'selected')
|
||||
selected && $.removeClass(selected, 'selected')
|
||||
} else {
|
||||
$.removeClass(selected, 'selected')
|
||||
$.addClass(selected.nextElementSibling, 'selected')
|
||||
}
|
||||
const down = async () => {
|
||||
showingResult.value = true
|
||||
await nextTick()
|
||||
const selected = el.value.querySelector('.result li.selected')
|
||||
|
||||
this.apply()
|
||||
this.scrollSelectedIntoView(false)
|
||||
})
|
||||
},
|
||||
|
||||
up (): void {
|
||||
this.showingResult = true
|
||||
this.$nextTick((): void => {
|
||||
const selected = this.$el.querySelector('.result li.selected')
|
||||
|
||||
if (!selected || !selected.previousElementSibling) {
|
||||
$.addClass(this.$el.querySelector('.result li:last-child'), 'selected')
|
||||
selected && $.removeClass(selected, 'selected')
|
||||
} else {
|
||||
$.removeClass(selected, 'selected')
|
||||
$.addClass(selected.previousElementSibling, 'selected')
|
||||
}
|
||||
|
||||
this.apply()
|
||||
this.scrollSelectedIntoView(true)
|
||||
})
|
||||
},
|
||||
|
||||
keyup (e: KeyboardEvent): void {
|
||||
/**
|
||||
* If it's an UP or DOWN arrow key, stop event bubbling to allow traversing the result dropdown
|
||||
*/
|
||||
if (e.keyCode === 38 || e.keyCode === 40) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// If it's an ENTER or TAB key, don't do anything.
|
||||
// We've handled ENTER & TAB on keydown.
|
||||
if (e.keyCode === 13 || e.keyCode === 9) {
|
||||
this.apply()
|
||||
return
|
||||
}
|
||||
|
||||
// Hide the typeahead results and reset the value if ESC is pressed.
|
||||
if (e.keyCode === 27) {
|
||||
this.mutatedValue = this.lastSelectedValue
|
||||
this.hideResults()
|
||||
return
|
||||
}
|
||||
|
||||
this.filter = this.mutatedValue
|
||||
this.showingResult = true
|
||||
},
|
||||
|
||||
change (): void {
|
||||
this.apply()
|
||||
},
|
||||
|
||||
resultClick (e: MouseEvent): void {
|
||||
const selected = this.$el.querySelector('.result li.selected')
|
||||
$.removeClass(selected, 'selected')
|
||||
$.addClass(e.target as Element, 'selected')
|
||||
this.$nextTick(() => {
|
||||
this.change()
|
||||
this.hideResults()
|
||||
})
|
||||
},
|
||||
|
||||
apply (): void {
|
||||
const selected = this.$el.querySelector<HTMLElement>('.result li.selected')
|
||||
this.mutatedValue = (selected && selected.innerText.trim()) || this.mutatedValue
|
||||
this.lastSelectedValue = this.mutatedValue
|
||||
this.$emit('input', this.mutatedValue)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {boolean} alignTop Whether the item should be aligned to top of its container.
|
||||
*/
|
||||
scrollSelectedIntoView (alignTop: boolean): void {
|
||||
const elem = this.$el.querySelector<HTMLElement>('.result li.selected')
|
||||
|
||||
if (!elem) {
|
||||
return
|
||||
}
|
||||
|
||||
const elemRect = elem.getBoundingClientRect()
|
||||
const containerRect = elem.offsetParent!.getBoundingClientRect()
|
||||
|
||||
if (elemRect.bottom > containerRect.bottom || elemRect.top < containerRect.top) {
|
||||
elem.scrollIntoView(alignTop)
|
||||
}
|
||||
},
|
||||
|
||||
hideResults (): void {
|
||||
this.showingResult = false
|
||||
}
|
||||
},
|
||||
|
||||
created (): void {
|
||||
this.mutatedValue = this.value
|
||||
this.filter = this.value
|
||||
this.lastSelectedValue = this.value
|
||||
if (!selected || !selected.nextElementSibling) {
|
||||
// No item selected, or we're at the end of the list.
|
||||
// Select the first item now.
|
||||
$.addClass(el.value.querySelector('.result li:first-child'), 'selected')
|
||||
selected && $.removeClass(selected, 'selected')
|
||||
} else {
|
||||
$.removeClass(selected, 'selected')
|
||||
$.addClass(selected.nextElementSibling, 'selected')
|
||||
}
|
||||
})
|
||||
|
||||
apply()
|
||||
scrollSelectedIntoView(false)
|
||||
}
|
||||
|
||||
const up = async () => {
|
||||
showingResult.value = true
|
||||
nextTick()
|
||||
const selected = el.value.querySelector('.result li.selected')
|
||||
|
||||
if (!selected || !selected.previousElementSibling) {
|
||||
$.addClass(el.value.querySelector('.result li:last-child'), 'selected')
|
||||
selected && $.removeClass(selected, 'selected')
|
||||
} else {
|
||||
$.removeClass(selected, 'selected')
|
||||
$.addClass(selected.previousElementSibling, 'selected')
|
||||
}
|
||||
|
||||
apply()
|
||||
scrollSelectedIntoView(true)
|
||||
}
|
||||
|
||||
const keyup = (e: KeyboardEvent) => {
|
||||
/**
|
||||
* If it's an Up or Down arrow key, stop event bubbling to allow traversing the result dropdown
|
||||
*/
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// If it's an ENTER or TAB key, don't do anything.
|
||||
// We've handled ENTER & TAB on keydown.
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
apply()
|
||||
return
|
||||
}
|
||||
|
||||
// Hide the typeahead results and reset the value if ESC is pressed.
|
||||
if (e.key === 'Escape') {
|
||||
mutatedValue.value = lastSelectedValue
|
||||
hideResults()
|
||||
return
|
||||
}
|
||||
|
||||
filter.value = mutatedValue.value
|
||||
showingResult.value = true
|
||||
}
|
||||
|
||||
const change = () => apply()
|
||||
|
||||
const resultClick = async (e: MouseEvent) => {
|
||||
const selected = el.value.querySelector('.result li.selected')
|
||||
$.removeClass(selected, 'selected')
|
||||
$.addClass(e.target as Element, 'selected')
|
||||
|
||||
await nextTick()
|
||||
apply()
|
||||
hideResults()
|
||||
}
|
||||
|
||||
const emit = defineEmits(['input'])
|
||||
|
||||
const apply = () => {
|
||||
const selected = el.value.querySelector<HTMLElement>('.result li.selected')
|
||||
mutatedValue.value = (selected && selected.innerText.trim()) || mutatedValue.value
|
||||
lastSelectedValue = mutatedValue.value
|
||||
emit('input', mutatedValue.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} alignTop Whether the item should be aligned to top of its container.
|
||||
*/
|
||||
const scrollSelectedIntoView = (alignTop: boolean) => {
|
||||
const elem = el.value.querySelector<HTMLElement>('.result li.selected')
|
||||
|
||||
if (!elem) {
|
||||
return
|
||||
}
|
||||
|
||||
const elemRect = elem.getBoundingClientRect()
|
||||
const containerRect = elem.offsetParent!.getBoundingClientRect()
|
||||
|
||||
if (elemRect.bottom > containerRect.bottom || elemRect.top < containerRect.top) {
|
||||
elem.scrollIntoView(alignTop)
|
||||
}
|
||||
}
|
||||
|
||||
const hideResults = () => (showingResult.value = false)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -189,8 +165,8 @@ export default Vue.extend({
|
|||
padding: 2px 8px;
|
||||
|
||||
&.selected, &:hover {
|
||||
background: var(--color-highlight);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-highlight);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,44 +31,21 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, toRefs } from 'vue'
|
||||
import { UploadFile } from '@/config'
|
||||
import { upload } from '@/services'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
file: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<UploadFile>
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const props = defineProps<{ file: UploadFile }>()
|
||||
const { file } = toRefs(props)
|
||||
|
||||
computed: {
|
||||
canRetry (): boolean {
|
||||
return this.file.status === 'Canceled' || this.file.status === 'Errored'
|
||||
},
|
||||
const canRetry = computed(() => file.value.status === 'Canceled' || file.value.status === 'Errored')
|
||||
const canRemove = computed(() => file.value.status !== 'Uploading') // we're not supporting cancel tokens (yet).
|
||||
|
||||
canRemove (): boolean {
|
||||
// we're not supporting cancel tokens (yet).
|
||||
return this.file.status !== 'Uploading'
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
remove (): void {
|
||||
upload.remove(this.file)
|
||||
},
|
||||
|
||||
retry (): void {
|
||||
upload.retry(this.file)
|
||||
}
|
||||
}
|
||||
})
|
||||
const remove = () => upload.remove(file.value)
|
||||
const retry = () => upload.retry(file.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -2,57 +2,40 @@
|
|||
<span class="view-modes">
|
||||
<label
|
||||
class="thumbnails"
|
||||
:class="{ active: mutatedValue === 'thumbnails' }"
|
||||
:class="{ active: modelValue === 'thumbnails' }"
|
||||
title="View as thumbnails"
|
||||
data-test="view-mode-thumbnail"
|
||||
>
|
||||
<input class="hidden" type="radio" value="thumbnails" v-model="mutatedValue" @input="onInput">
|
||||
<input class="hidden" type="radio" value="thumbnails" v-model="modelValue" @input="onInput">
|
||||
<i class="fa fa-th-large"></i>
|
||||
<span class="hidden">View as thumbnails</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="list"
|
||||
:class="{ active: mutatedValue === 'list' }"
|
||||
:class="{ active: modelValue === 'list' }"
|
||||
title="View as list"
|
||||
data-test="view-mode-list"
|
||||
>
|
||||
<input class="hidden" type="radio" value="list" v-model="mutatedValue" @input="onInput">
|
||||
<input class="hidden" type="radio" v-model="modelValue" @input="onInput">
|
||||
<i class="fa fa-list"></i>
|
||||
<span class="hidden">View as list</span>
|
||||
</label>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs, watchEffect } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: {
|
||||
type: String
|
||||
} as PropOptions<ArtistAlbumViewMode>
|
||||
},
|
||||
const props = defineProps<{ value: ArtistAlbumViewMode }>()
|
||||
const { value } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
mutatedValue: null as ArtistAlbumViewMode | null
|
||||
}),
|
||||
let modelValue = ref<ArtistAlbumViewMode>(null as unknown as ArtistAlbumViewMode)
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler (mode: ArtistAlbumViewMode) {
|
||||
this.mutatedValue = mode
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
watchEffect(() => (modelValue.value = value.value))
|
||||
|
||||
methods: {
|
||||
onInput (e: InputEvent): void {
|
||||
this.$emit('input', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const onInput = (e: InputEvent) => emit('update:modelValue', (e.target as HTMLInputElement).value)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -3,47 +3,31 @@
|
|||
:class="{ fullscreen: isFullscreen }"
|
||||
@dblclick="toggleFullscreen"
|
||||
id="vizContainer"
|
||||
ref="visualizerContainer"
|
||||
ref="el"
|
||||
data-testid="visualizer"
|
||||
>
|
||||
<close-modal-btn class="close" @click="hide"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
import initVisualizer from '@/utils/visualizer'
|
||||
import { eventBus } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
CloseModalBtn: () => import('@/components/ui/close-modal-btn.vue')
|
||||
},
|
||||
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/close-modal-btn.vue'))
|
||||
|
||||
data: () => ({
|
||||
isFullscreen: false
|
||||
}),
|
||||
const el = ref(null as unknown as HTMLElement)
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
methods: {
|
||||
toggleFullscreen (): void {
|
||||
if (this.isFullscreen) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
(this.$refs.visualizerContainer as HTMLElement).requestFullscreen()
|
||||
}
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value ? document.exitFullscreen() : el.value.requestFullscreen()
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
}
|
||||
|
||||
this.isFullscreen = !this.isFullscreen
|
||||
},
|
||||
const hide = () => eventBus.emit('TOGGLE_VISUALIZER')
|
||||
|
||||
hide: (): void => {
|
||||
eventBus.emit('TOGGLE_VISUALIZER')
|
||||
}
|
||||
},
|
||||
|
||||
mounted (): void {
|
||||
initVisualizer(this.$refs.visualizerContainer as HTMLElement)
|
||||
}
|
||||
})
|
||||
onMounted(() => initVisualizer(el.value))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -29,40 +29,34 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { playback, socket } from '@/services'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
muted: false
|
||||
}),
|
||||
const muted = ref(false)
|
||||
|
||||
methods: {
|
||||
mute (): void {
|
||||
this.muted = true
|
||||
playback.mute()
|
||||
},
|
||||
const mute = () => {
|
||||
muted.value = true
|
||||
playback.mute()
|
||||
}
|
||||
|
||||
unmute (): void {
|
||||
this.muted = false
|
||||
playback.unmute()
|
||||
},
|
||||
const unmute = () => {
|
||||
muted.value = false
|
||||
playback.unmute()
|
||||
}
|
||||
|
||||
setVolume (e: InputEvent): void {
|
||||
const volume = parseFloat((e.target as HTMLInputElement).value)
|
||||
playback.setVolume(volume)
|
||||
this.muted = volume === 0
|
||||
},
|
||||
const setVolume = (e: InputEvent) => {
|
||||
const volume = parseFloat((e.target as HTMLInputElement).value)
|
||||
playback.setVolume(volume)
|
||||
muted.value = volume === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the volume changed event to remote controller.
|
||||
*/
|
||||
broadcastVolume: (e: InputEvent): void => {
|
||||
socket.broadcast('SOCKET_VOLUME_CHANGED', parseFloat((e.target as HTMLInputElement).value))
|
||||
}
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Broadcast the volume changed event to remote controller.
|
||||
*/
|
||||
const broadcastVolume = (e: InputEvent) => {
|
||||
socket.broadcast('SOCKET_VOLUME_CHANGED', parseFloat((e.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -11,16 +11,16 @@
|
|||
data-test="youtube-search-result"
|
||||
>
|
||||
<div class="thumb">
|
||||
<img :src="video.snippet.thumbnails.default.url" width="90">
|
||||
<img :src="video.snippet.thumbnails.default.url" width="90" :alt="video.snippet.title">
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h3 class="title">{{ video.snippet.title }}</h3>
|
||||
<p class="desc">{{ video.snippet.description }}</p>
|
||||
</div>
|
||||
</a>
|
||||
<btn @click.prevent="loadMore" v-if="!loading" class="more" data-testid="youtube-search-more-btn">
|
||||
<Btn @click.prevent="loadMore" v-if="!loading" class="more" data-testid="youtube-search-more-btn">
|
||||
Load More
|
||||
</btn>
|
||||
</Btn>
|
||||
</template>
|
||||
|
||||
<p class="nope" v-else>Play a song to retrieve related YouTube videos.</p>
|
||||
|
@ -28,56 +28,37 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref, toRefs, watchEffect } from 'vue'
|
||||
import { youtube as youtubeService } from '@/services'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
props: {
|
||||
song: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<Song>
|
||||
},
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
loading: false,
|
||||
videos: [] as YouTubeVideo[]
|
||||
}),
|
||||
const loading = ref(false)
|
||||
const videos = ref<YouTubeVideo[]>([])
|
||||
|
||||
watch: {
|
||||
song: {
|
||||
immediate: true,
|
||||
handler (val: Song): void {
|
||||
this.videos = val.youtube ? val.youtube.items : []
|
||||
}
|
||||
}
|
||||
},
|
||||
watchEffect(() => (videos.value = song.value.youtube ? song.value.youtube.items : []))
|
||||
|
||||
methods: {
|
||||
play: (video: YouTubeVideo): void => youtubeService.play(video),
|
||||
const play = (video: YouTubeVideo) => youtubeService.play(video)
|
||||
|
||||
async loadMore (): Promise<void> {
|
||||
this.loading = true
|
||||
const loadMore = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
this.song.youtube = this.song.youtube || { nextPageToken: '', items: [] }
|
||||
try {
|
||||
song.value.youtube = song.value.youtube || { nextPageToken: '', items: [] }
|
||||
|
||||
const result = await youtubeService.searchVideosRelatedToSong(this.song, this.song.youtube.nextPageToken!)
|
||||
this.song.youtube.nextPageToken = result.nextPageToken
|
||||
this.song.youtube.items.push(...result.items as YouTubeVideo[])
|
||||
const result = await youtubeService.searchVideosRelatedToSong(song.value, song.value.youtube.nextPageToken!)
|
||||
song.value.youtube.nextPageToken = result.nextPageToken
|
||||
song.value.youtube.items.push(...result.items as YouTubeVideo[])
|
||||
|
||||
this.videos = this.song.youtube.items
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
videos.value = song.value.youtube.items
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="add-user" @keydown.esc="maybeClose">
|
||||
<sound-bar v-if="loading"/>
|
||||
<SoundBar v-if="loading"/>
|
||||
<form class="user-add" @submit.prevent="submit" v-else data-testid="add-user-form">
|
||||
<header>
|
||||
<h1>Add New User</h1>
|
||||
|
@ -30,76 +30,67 @@
|
|||
<div class="form-row">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" v-model="newUser.is_admin"> User is an admin
|
||||
<tooltip-icon title="Admins can perform administrative tasks like managing users and uploading songs."/>
|
||||
<TooltipIcon title="Admins can perform administrative tasks like managing users and uploading songs."/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<btn class="btn-add" type="submit">Save</btn>
|
||||
<btn class="btn-cancel" @click.prevent="maybeClose" white>Cancel</btn>
|
||||
<Btn class="btn-add" type="submit">Save</Btn>
|
||||
<Btn class="btn-cancel" @click.prevent="maybeClose" white>Cancel</Btn>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue'
|
||||
import { isEqual } from 'lodash'
|
||||
import { CreateUserData, userStore } from '@/stores'
|
||||
import { alerts, parseValidationError } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue'),
|
||||
TooltipIcon: () => import('@/components/ui/tooltip-icon.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
const TooltipIcon = defineAsyncComponent(() => import('@/components/ui/tooltip-icon.vue'))
|
||||
|
||||
data: () => ({
|
||||
loading: false,
|
||||
emptyUserData: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_admin: false
|
||||
} as CreateUserData,
|
||||
newUser: {} as CreateUserData
|
||||
}),
|
||||
|
||||
methods: {
|
||||
async submit (): Promise<void> {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await userStore.store(this.newUser)
|
||||
this.close()
|
||||
} catch (err) {
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
close (): void {
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
maybeClose (): void {
|
||||
if (isEqual(this.newUser, this.emptyUserData)) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', () => this.close())
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
Object.assign(this.newUser, this.emptyUserData)
|
||||
}
|
||||
const loading = ref(false)
|
||||
const emptyUserData = reactive<CreateUserData>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_admin: false
|
||||
})
|
||||
|
||||
const newUser = reactive<CreateUserData>({} as unknown as CreateUserData)
|
||||
|
||||
Object.assign(newUser, emptyUserData)
|
||||
|
||||
const submit = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await userStore.store(newUser)
|
||||
close()
|
||||
} catch (err: any) {
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const close = () => emit('close')
|
||||
|
||||
const maybeClose = () => {
|
||||
if (isEqual(newUser, emptyUserData)) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', close)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<span class="profile" id="userBadge">
|
||||
<a class="view-profile" href="/#!/profile" title="View/edit user profile" data-testid="view-profile-link">
|
||||
<img class="avatar" :src="state.current.avatar" :alt="`Avatar of ${state.current.name}`"/>
|
||||
<span class="name">{{ state.current.name }}</span>
|
||||
<img class="avatar" :src="user.avatar" :alt="`Avatar of ${user.name}`"/>
|
||||
<span class="name">{{ user.name }}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
:title="`Log ${state.current.name} out`"
|
||||
:title="`Log ${user.name} out`"
|
||||
@click.prevent="logout"
|
||||
class="logout control"
|
||||
data-testid="btn-logout"
|
||||
|
@ -18,22 +18,14 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { toRef } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
import { eventBus } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
state: userStore.state
|
||||
}),
|
||||
const user = toRef(userStore.state, 'current')
|
||||
|
||||
methods: {
|
||||
logout: (): void => {
|
||||
eventBus.emit('LOG_OUT')
|
||||
}
|
||||
}
|
||||
})
|
||||
const logout = () => eventBus.emit('LOG_OUT')
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -64,7 +56,7 @@ export default Vue.extend({
|
|||
@include vertical-center();
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 667px) {
|
||||
@media only screen and (max-width: 667px) {
|
||||
flex: 0 0 96px;
|
||||
margin-right: 0;
|
||||
padding-right: 0;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<article class="user-card" :class="{ me: isCurrentUser }" data-test="user-card">
|
||||
<article class="user-card" :class="{ me: isCurrentUser }" data-test="user-card" v-if="showing">
|
||||
<div class="info">
|
||||
<img :src="user.avatar" width="96" height="96" :alt="`${user.name}'s avatar`">
|
||||
|
||||
|
@ -24,8 +24,8 @@
|
|||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<btn class="btn-edit" @click="edit" small data-test="edit-user-btn">{{ editButtonLabel }}</btn>
|
||||
<btn
|
||||
<Btn class="btn-edit" @click="edit" small data-test="edit-user-btn">{{ editButtonLabel }}</Btn>
|
||||
<Btn
|
||||
v-if="!isCurrentUser"
|
||||
class="btn-delete"
|
||||
red
|
||||
|
@ -34,68 +34,38 @@
|
|||
data-test="delete-user-btn"
|
||||
>
|
||||
Delete
|
||||
</btn>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref, toRefs } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
import { alerts } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<User>
|
||||
},
|
||||
const props = defineProps<{ user: User }>()
|
||||
const { user } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
confirmingDelete: false
|
||||
}),
|
||||
const showing = ref(true)
|
||||
|
||||
computed: {
|
||||
isCurrentUser (): boolean {
|
||||
return this.user.id === userStore.current.id
|
||||
},
|
||||
const isCurrentUser = computed(() => user.value.id === userStore.current.id)
|
||||
const editButtonLabel = computed(() => isCurrentUser.value ? 'Update Profile' : 'Edit')
|
||||
|
||||
editButtonLabel (): string {
|
||||
return this.isCurrentUser ? 'Update Profile' : 'Edit'
|
||||
}
|
||||
},
|
||||
const emit = defineEmits(['editUser'])
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Trigger editing a user.
|
||||
* If the user is the current logged-in user, redirect to the profile screen instead.
|
||||
*/
|
||||
edit (): void {
|
||||
if (this.isCurrentUser) {
|
||||
router.go('profile')
|
||||
} else {
|
||||
this.$emit('editUser', this.user)
|
||||
}
|
||||
},
|
||||
const edit = () => isCurrentUser.value ? router.go('profile') : emit('editUser', user.value)
|
||||
const confirmDelete = () => alerts.confirm(`You’re about to unperson ${user.value.name}. Are you sure?`, destroy)
|
||||
|
||||
confirmDelete (): void {
|
||||
alerts.confirm(`You’re about to unperson ${this.user.name}. Are you sure?`, this.destroy)
|
||||
},
|
||||
|
||||
destroy (): void {
|
||||
userStore.destroy(this.user)
|
||||
this.$destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
const destroy = () => {
|
||||
userStore.destroy(user.value)
|
||||
showing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="edit-user" @keydown.esc="maybeClose">
|
||||
<sound-bar v-if="loading"/>
|
||||
<SoundBar v-if="loading"/>
|
||||
<form class="user-edit" @submit.prevent="submit" v-else data-testid="edit-user-form">
|
||||
<header>
|
||||
<h1>Edit User</h1>
|
||||
|
@ -29,83 +29,71 @@
|
|||
<div class="form-row">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" v-model="updateData.is_admin"> User is an admin
|
||||
<tooltip-icon title="Admins can perform administrative tasks like managing users and uploading songs."/>
|
||||
<TooltipIcon title="Admins can perform administrative tasks like managing users and uploading songs."/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<btn class="btn-update" type="submit">Update</btn>
|
||||
<btn class="btn-cancel" @click.prevent="maybeClose" white data-test="cancel-btn">Cancel</btn>
|
||||
<Btn class="btn-update" type="submit">Update</Btn>
|
||||
<Btn class="btn-cancel" @click.prevent="maybeClose" white data-test="cancel-btn">Cancel</Btn>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue'
|
||||
import { isEqual } from 'lodash'
|
||||
import { alerts, parseValidationError } from '@/utils'
|
||||
import { UpdateUserData, userStore } from '@/stores'
|
||||
import Vue, { PropOptions } from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Btn: () => import('@/components/ui/btn.vue'),
|
||||
SoundBar: () => import('@/components/ui/sound-bar.vue'),
|
||||
TooltipIcon: () => import('@/components/ui/tooltip-icon.vue')
|
||||
},
|
||||
const Btn = defineAsyncComponent(() => import('@/components/ui/btn.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
|
||||
const TooltipIcon = defineAsyncComponent(() => import('@/components/ui/tooltip-icon.vue'))
|
||||
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
} as PropOptions<User>
|
||||
},
|
||||
const props = defineProps<{ user: User }>()
|
||||
const { user } = toRefs(props)
|
||||
|
||||
data: () => ({
|
||||
loading: false,
|
||||
updateData: {} as UpdateUserData,
|
||||
originalData: {} as UpdateUserData
|
||||
}),
|
||||
const loading = ref(false)
|
||||
const updateData = reactive({} as unknown as UpdateUserData)
|
||||
const originalData = reactive({} as unknown as UpdateUserData)
|
||||
|
||||
methods: {
|
||||
async submit (): Promise<void> {
|
||||
this.loading = true
|
||||
const submit = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await userStore.update(this.user, this.updateData)
|
||||
this.close()
|
||||
} catch (err) {
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
close (): void {
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
maybeClose (): void {
|
||||
if (isEqual(this.originalData, this.updateData)) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', () => this.close())
|
||||
}
|
||||
},
|
||||
|
||||
mounted (): void {
|
||||
this.updateData = {
|
||||
name: this.user.name,
|
||||
email: this.user.email,
|
||||
is_admin: this.user.is_admin
|
||||
}
|
||||
|
||||
Object.assign(this.originalData, this.updateData)
|
||||
try {
|
||||
await userStore.update(user.value, updateData)
|
||||
close()
|
||||
} catch (err: any) {
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
alerts.error(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const close = () => emit('close')
|
||||
|
||||
const maybeClose = () => {
|
||||
if (isEqual(originalData, updateData)) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
alerts.confirm('Discard all changes?', close)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Object.assign(updateData, {
|
||||
name: user.value.name,
|
||||
email: user.value.email,
|
||||
is_admin: user.value.is_admin
|
||||
})
|
||||
|
||||
Object.assign(originalData, updateData)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,52 +1,45 @@
|
|||
<script lang="ts">
|
||||
<template>
|
||||
<span></span>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* Global event listeners (basically, those without a Vue instance access) go here.
|
||||
*/
|
||||
import Vue, { VNode } from 'vue'
|
||||
import isMobile from 'ismobilejs'
|
||||
import router from '@/router'
|
||||
import { auth } from '@/services'
|
||||
import { playlistStore, preferenceStore, userStore } from '@/stores'
|
||||
import { alerts, eventBus, forceReloadWindow } from '@/utils'
|
||||
|
||||
export default Vue.extend({
|
||||
render: (h: Function): VNode => h(),
|
||||
created (): void {
|
||||
eventBus.on({
|
||||
'PLAYLIST_DELETE': (playlist: Playlist): void => {
|
||||
const destroy = async (): Promise<void> => {
|
||||
await playlistStore.delete(playlist)
|
||||
alerts.success(`Deleted playlist "${playlist.name}."`)
|
||||
router.go('home')
|
||||
}
|
||||
eventBus.on({
|
||||
PLAYLIST_DELETE (playlist: Playlist) {
|
||||
const destroy = async () => {
|
||||
await playlistStore.delete(playlist)
|
||||
alerts.success(`Deleted playlist "${playlist.name}."`)
|
||||
router.go('home')
|
||||
}
|
||||
|
||||
if (!playlist.songs.length) {
|
||||
destroy()
|
||||
} else {
|
||||
alerts.confirm(`Delete the playlist "${playlist.name}"?`, destroy)
|
||||
}
|
||||
},
|
||||
if (!playlist.songs.length) {
|
||||
destroy()
|
||||
} else {
|
||||
alerts.confirm(`Delete the playlist "${playlist.name}"?`, destroy)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log the current user out and reset the application state.
|
||||
*/
|
||||
'LOG_OUT': async (): Promise<void> => {
|
||||
await userStore.logout()
|
||||
auth.destroy()
|
||||
forceReloadWindow()
|
||||
},
|
||||
/**
|
||||
* Log the current user out and reset the application state.
|
||||
*/
|
||||
async LOG_OUT () {
|
||||
await userStore.logout()
|
||||
auth.destroy()
|
||||
forceReloadWindow()
|
||||
},
|
||||
|
||||
'KOEL_READY': (): void => router.init(),
|
||||
'KOEL_READY': () => router.init(),
|
||||
|
||||
/**
|
||||
* Hide the panel away if a main view is triggered on mobile.
|
||||
*/
|
||||
'LOAD_MAIN_CONTENT': (): void => {
|
||||
if (isMobile.phone) {
|
||||
preferenceStore.showExtraPanel = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Hide the panel away if a main view is triggered on mobile.
|
||||
*/
|
||||
'LOAD_MAIN_CONTENT': () => isMobile.phone && (preferenceStore.showExtraPanel = false)
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,167 +1,101 @@
|
|||
<template>
|
||||
<global-events
|
||||
@keydown.space = "togglePlayback"
|
||||
@keydown.j = "playNext"
|
||||
@keydown.k = "playPrev"
|
||||
@keydown.f = "search"
|
||||
@keydown.l = "toggleLike"
|
||||
@keydown.mediaPrev = "playPrev"
|
||||
@keydown.mediaNext = "playNext"
|
||||
@keydown.mediaToggle = "togglePlayback"
|
||||
<GlobalEvents
|
||||
@keydown.space="togglePlayback"
|
||||
@keydown.j="playNext"
|
||||
@keydown.k="playPrev"
|
||||
@keydown.f="search"
|
||||
@keydown.l="toggleLike"
|
||||
@keydown.176="playPrev"
|
||||
@keydown.177="playNext"
|
||||
@keydown.179="togglePlayback"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import GlobalEvents from 'vue-global-events'
|
||||
import { $, eventBus, noop } from '@/utils'
|
||||
import { events as eventNames } from '@/config'
|
||||
<script lang="ts" setup>
|
||||
import { GlobalEvents } from 'vue-global-events'
|
||||
import { $, eventBus } from '@/utils'
|
||||
import { playback, socket } from '@/services'
|
||||
import { queueStore, favoriteStore, songStore } from '@/stores'
|
||||
import { favoriteStore, queueStore, songStore } from '@/stores'
|
||||
|
||||
let ipc: any, events: any
|
||||
const togglePlayback = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!(e.target instanceof Document) &&
|
||||
$.is(e.target as Element, 'input, textarea, button, select') &&
|
||||
!$.is(e.target as Element, 'input[type=range]')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (KOEL_ENV === 'app') {
|
||||
ipc = require('electron').ipcRenderer
|
||||
events = require('&/events').default
|
||||
}
|
||||
e.preventDefault()
|
||||
playback.toggle()
|
||||
|
||||
// Register our custom key codes
|
||||
Vue.config.keyCodes = {
|
||||
a: 65,
|
||||
j: 74,
|
||||
k: 75,
|
||||
f: 70,
|
||||
l: 76,
|
||||
mediaNext: 176,
|
||||
mediaPrev: 177,
|
||||
mediaToggle: 179
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the global shortcuts (media keys).
|
||||
* Only works in the app.
|
||||
* Play the previous song when user presses K.
|
||||
*/
|
||||
let listenToGlobalShortcuts = noop
|
||||
|
||||
if (KOEL_ENV === 'app') {
|
||||
listenToGlobalShortcuts = (): void => {
|
||||
const mediaFunctionMap = {
|
||||
MediaNextTrack: () => playback.playNext(),
|
||||
MediaPreviousTrack: () => playback.playPrev(),
|
||||
MediaStop: () => playback.stop(),
|
||||
MediaPlayPause: () => playback.toggle()
|
||||
} as { [propName: string]: Function }
|
||||
|
||||
ipc.on('GLOBAL_SHORTCUT', (e: any, msg: string) => msg in mediaFunctionMap && mediaFunctionMap[msg]())
|
||||
const playPrev = (e: KeyboardEvent) => {
|
||||
if (!(e.target instanceof Document) && $.is(e.target as Element, 'input, select, textarea')) {
|
||||
return true
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
playback.playPrev()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
GlobalEvents
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Toggle playback when user presses Space key.
|
||||
*/
|
||||
togglePlayback: (e: KeyboardEvent): boolean => {
|
||||
if (
|
||||
!(e.target instanceof Document) &&
|
||||
$.is(e.target as Element, 'input, textarea, button, select') &&
|
||||
!$.is(e.target as Element, 'input[type=range]')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
playback.toggle()
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Play the previous song when user presses K.
|
||||
*/
|
||||
playPrev: (e: KeyboardEvent): boolean => {
|
||||
if (
|
||||
!(e.target instanceof Document) &&
|
||||
$.is(e.target as Element, 'input, select, textarea')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
playback.playPrev()
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Play the next song when user presses J.
|
||||
*/
|
||||
playNext: (e: KeyboardEvent): boolean => {
|
||||
if (
|
||||
!(e.target instanceof Document) &&
|
||||
$.is(e.target as Element, 'input, select, textarea')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
playback.playNext()
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Put focus into the search field when user presses F.
|
||||
*/
|
||||
search: (e: KeyboardEvent): boolean => {
|
||||
if (
|
||||
!(e.target instanceof Document) &&
|
||||
($.is(e.target as Element, 'input, select, textarea') &&
|
||||
!$.is(e.target as Element, 'input[type=range]'))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
eventBus.emit(eventNames.FOCUS_SEARCH_FIELD)
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Like/unlike the current song when use presses L.
|
||||
*/
|
||||
toggleLike: (e: KeyboardEvent): boolean => {
|
||||
if (
|
||||
!(e.target instanceof Document) &&
|
||||
$.is(e.target as Element, 'input, select, textarea')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!queueStore.current) {
|
||||
return false
|
||||
}
|
||||
|
||||
favoriteStore.toggleOne(queueStore.current)
|
||||
socket.broadcast(eventNames.SOCKET_SONG, songStore.generateDataToBroadcast(queueStore.current))
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
created: (): void => {
|
||||
if (KOEL_ENV === 'app') {
|
||||
listenToGlobalShortcuts()
|
||||
}
|
||||
/**
|
||||
* Play the next song when user presses J.
|
||||
*/
|
||||
const playNext = (e: KeyboardEvent) => {
|
||||
if (!(e.target instanceof Document) && $.is(e.target as Element, 'input, select, textarea')) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
e.preventDefault()
|
||||
playback.playNext()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Put focus into the search field when user presses F.
|
||||
*/
|
||||
const search = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!(e.target instanceof Document) &&
|
||||
($.is(e.target as Element, 'input, select, textarea') && !$.is(e.target as Element, 'input[type=range]'))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
eventBus.emit('FOCUS_SEARCH_FIELD')
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Like/unlike the current song when use presses L.
|
||||
*/
|
||||
const toggleLike = (e: KeyboardEvent) => {
|
||||
if (!(e.target instanceof Document) && $.is(e.target as Element, 'input, select, textarea')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!queueStore.current) {
|
||||
return false
|
||||
}
|
||||
|
||||
favoriteStore.toggleOne(queueStore.current)
|
||||
socket.broadcast('SOCKET_SONG', songStore.generateDataToBroadcast(queueStore.current))
|
||||
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
|
6
resources/assets/js/composables/index.ts
Normal file
6
resources/assets/js/composables/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from './useAlbumAttributes'
|
||||
export * from './useArtistAttributes'
|
||||
export * from './useInfiniteScroll'
|
||||
export * from './useSongList'
|
||||
export * from './useSongMenuMethods'
|
||||
export * from './useContextMenu'
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue