chore: vue3-ify

This commit is contained in:
Phan An 2022-04-15 19:00:08 +02:00
parent 1ab5837c76
commit 7c88e96206
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
122 changed files with 3589 additions and 5055 deletions

View file

@ -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",

View file

@ -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
}
}

View file

@ -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, {

View file

@ -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)

View file

@ -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>

View file

@ -1,9 +0,0 @@
<template>
{{ message }}
</template>
<script setup>
import { ref } from 'vue'
const message = ref('It works!')
</script>

View file

@ -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">

View file

@ -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>

View file

@ -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 &copy; <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">

View file

@ -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);
}

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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;
}

View file

@ -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>

View file

@ -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 */

View file

@ -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>

View file

@ -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%);
}
}

View file

@ -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">

View file

@ -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%;

View file

@ -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">

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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%;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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!',
'Hows it going, %s?',
'Sup, %s?',
'Hows life, %s?',
'Hows 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!',
'Hows it going, %s?',
'Sup, %s?',
'Hows life, %s?',
'Hows 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>

View file

@ -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 &quot;Add To&quot; 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>

View file

@ -1,28 +1,24 @@
<template>
<section id="profileWrapper">
<screen-header>Profile &amp; Preferences</screen-header>
<ScreenHeader>Profile &amp; 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">

View file

@ -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>

View file

@ -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>

View file

@ -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;
> * + * {

View file

@ -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>

View file

@ -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%;
}

View file

@ -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">

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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
}
}
})

View file

@ -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>

View file

@ -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>

View file

@ -4,8 +4,7 @@
</button>
</template>
<script lang="ts">
export default {}
<script lang="ts" setup>
</script>
<style lang="scss" scoped>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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);
}
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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(`Youre about to unperson ${user.value.name}. Are you sure?`, destroy)
confirmDelete (): void {
alerts.confirm(`Youre 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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