feat: revamp drag-n-drop functionalities

This commit is contained in:
Phan An 2022-09-03 15:32:09 +07:00
parent 52dd323c96
commit e8a1cdece7
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
27 changed files with 318 additions and 259 deletions

View file

@ -2,7 +2,7 @@ import isMobile from 'ismobilejs'
import { isObject, mergeWith } from 'lodash'
import { cleanup, render, RenderOptions } from '@testing-library/vue'
import { afterEach, beforeEach, vi } from 'vitest'
import { clickaway, droppable, focus } from '@/directives'
import { clickaway, focus } from '@/directives'
import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores'
import factory from '@/__tests__/factory'
@ -81,8 +81,7 @@ export default abstract class UnitTestCase {
global: {
directives: {
'koel-clickaway': clickaway,
'koel-focus': focus,
'koel-droppable': droppable
'koel-focus': focus
},
components: {
icon: this.stub('icon')

View file

@ -1,6 +1,6 @@
import 'plyr/dist/plyr.js'
import { createApp } from 'vue'
import { clickaway, droppable, focus } from '@/directives'
import { clickaway, focus } from '@/directives'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import App from './App.vue'
@ -8,7 +8,6 @@ createApp(App)
.component('icon', FontAwesomeIcon)
.directive('koel-focus', focus)
.directive('koel-clickaway', clickaway)
.directive('koel-droppable', droppable)
/**
* For Ancelot, the ancient cross of war
* for the holy town of Gods

View file

@ -8,7 +8,7 @@
draggable="true"
tabindex="0"
@dblclick="shuffle"
@dragstart="dragStart"
@dragstart="onDragStart"
@contextmenu.prevent="requestContextMenu"
>
<AlbumThumbnail :entity="album"/>
@ -56,12 +56,15 @@
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize, secondsToHis, startDragging } from '@/utils'
import { eventBus, pluralize, secondsToHis } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
const { startDragging } = useDraggable('album')
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { album, layout } = toRefs(props)
@ -76,7 +79,7 @@ const shuffle = async () => {
}
const download = () => downloadService.fromAlbum(album.value)
const dragStart = (event: DragEvent) => startDragging(event, album.value, 'Album')
const onDragStart = (event: DragEvent) => startDragging(event, album.value)
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', event, album.value)
</script>

View file

@ -8,7 +8,7 @@
draggable="true"
tabindex="0"
@dblclick="shuffle"
@dragstart="dragStart"
@dragstart="onDragStart"
@contextmenu.prevent="requestContextMenu"
>
<ArtistThumbnail :entity="artist"/>
@ -56,11 +56,15 @@
<script lang="ts" setup>
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef, toRefs } from 'vue'
import { eventBus, pluralize, startDragging } from '@/utils'
import { eventBus, pluralize } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import { useDraggable } from '@/composables'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
const { startDragging } = useDraggable('artist')
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
const { artist, layout } = toRefs(props)
@ -73,7 +77,7 @@ const shuffle = async () => {
}
const download = () => downloadService.fromArtist(artist.value)
const dragStart = (event: DragEvent) => startDragging(event, artist.value, 'Artist')
const onDragStart = (event: DragEvent) => startDragging(event, artist.value)
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', event, artist.value)
</script>

View file

@ -10,8 +10,8 @@
Home
</a>
</li>
<li>
<a v-koel-droppable="handleDrop" :class="['queue', currentView === 'Queue' ? 'active' : '']" href="#!/queue">
<li ref="queueMenuItemEl" @dragleave="onQueueDragLeave" @dragover="onQueueDragOver" @drop="onQueueDrop">
<a :class="['queue', currentView === 'Queue' ? 'active' : '']" href="#!/queue">
<icon :icon="faListOl" fixed-width/>
Current Queue
</a>
@ -86,19 +86,37 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { ref } from 'vue'
import { eventBus, resolveSongsFromDragEvent } from '@/utils'
import { eventBus } from '@/utils'
import { queueStore } from '@/stores'
import { useAuthorization, useThirdPartyServices } from '@/composables'
import { useAuthorization, useDroppable, useThirdPartyServices } from '@/composables'
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
const showing = ref(!isMobile.phone)
const currentView = ref<MainViewName>('Home')
const queueMenuItemEl = ref<HTMLLIElement>()
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist'])
const { useYouTube } = useThirdPartyServices()
const { isAdmin } = useAuthorization()
const handleDrop = async (event: DragEvent) => {
const songs = await resolveSongsFromDragEvent(event)
const onQueueDragOver = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
queueMenuItemEl.value!.classList.add('droppable')
}
const onQueueDragLeave = () => queueMenuItemEl.value!.classList.remove('droppable')
const onQueueDrop = async (event: DragEvent) => {
queueMenuItemEl.value!.classList.remove('droppable')
if (!acceptsDrop(event)) return false
event.preventDefault()
const songs = await resolveDroppedSongs(event) || []
songs.length && queueStore.queue(songs)
return false
@ -137,13 +155,10 @@ nav {
-webkit-overflow-scrolling: touch;
}
a.droppable {
transform: scale(1.2);
transition: .3s;
transform-origin: center left;
color: var(--color-text-primary);
background-color: rgba(0, 0, 0, .3);
.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
.queue > span {

View file

@ -2,9 +2,9 @@
<li
ref="el"
class="playlist-folder"
@dragleave.prevent="onDragLeave"
@dragleave="onDragLeave"
@dragover="onDragOver"
@drop.prevent="onDrop"
@drop="onDrop"
tabindex="0"
>
<a @click.prevent="toggle" @contextmenu.prevent="onContextMenu">
@ -36,7 +36,8 @@
import { faFolder, faFolderOpen } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, ref, toRefs } from 'vue'
import { playlistFolderStore, playlistStore } from '@/stores'
import { eventBus, logger } from '@/utils'
import { eventBus } from '@/utils'
import { useDroppable } from '@/composables'
const PlaylistSidebarItem = defineAsyncComponent(() => import('@/components/playlist/PlaylistSidebarItem.vue'))
@ -49,41 +50,47 @@ const opened = ref(false)
const playlistsInFolder = computed(() => playlistStore.byFolder(folder.value))
const { acceptsDrop, resolveDroppedValue } = useDroppable(['playlist'])
const toggle = () => (opened.value = !opened.value)
const onDragOver = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('playlist')) return false
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
el.value!.classList.add('drag-over')
opened.value || (opened.value = true)
event.dataTransfer!.dropEffect = 'move'
el.value!.classList.add('droppable')
opened.value = true
}
const onDragLeave = () => el.value!.classList.remove('drag-over')
const onDragLeave = () => el.value!.classList.remove('droppable')
const onDrop = async (event: DragEvent) => {
el.value!.classList.remove('drag-over')
const playlist = getPlaylistFromDropEvent(event)
if (!acceptsDrop(event)) return false
event.preventDefault()
el.value!.classList.remove('droppable')
const playlist = await resolveDroppedValue<Playlist>(event)
if (!playlist || playlist.folder_id === folder.value.id) return
await playlistFolderStore.addPlaylistToFolder(folder.value, playlist)
}
const onDragLeaveHatch = () => hatch.value!.classList.remove('drag-over')
const onDragLeaveHatch = () => hatch.value!.classList.remove('droppable')
const onDragOverHatch = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('playlist')) return false
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
hatch.value!.classList.add('drag-over')
hatch.value!.classList.add('droppable')
}
const onDropOnHatch = async (event: DragEvent) => {
hatch.value!.classList.remove('drag-over')
el.value!.classList.remove('drag-over')
const playlist = getPlaylistFromDropEvent(event)!
hatch.value!.classList.remove('droppable')
el.value!.classList.remove('droppable')
const playlist = (await resolveDroppedValue<Playlist>(event))!
// if the playlist isn't in the folder, don't do anything. The folder will handle the drop.
if (playlist.folder_id !== folder.value.id) return
@ -93,15 +100,6 @@ const onDropOnHatch = async (event: DragEvent) => {
await playlistFolderStore.removePlaylistFromFolder(folder.value, playlist)
}
const getPlaylistFromDropEvent = (event: DragEvent) => {
try {
const data = JSON.parse(event.dataTransfer?.getData('application/x-koel.text+plain')!)
return playlistStore.byId(data.value as number)
} catch (e) {
logger.error(e)
}
}
const onContextMenu = event => eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', event, folder.value)
</script>
@ -109,7 +107,7 @@ const onContextMenu = event => eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUE
li.playlist-folder {
position: relative;
&.drag-over {
&.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
@ -121,7 +119,7 @@ li.playlist-folder {
width: 100%;
height: .5rem;
&.drag-over {
&.droppable {
border-bottom: 3px solid var(--color-highlight);
}
}

View file

@ -1,25 +1,25 @@
<template>
<li
ref="el"
:class="['playlist', type, playlist.is_smart ? 'smart' : '']"
data-testid="playlist-sidebar-item"
draggable="true"
@dragstart="dragStart"
@dragleave="onDragLeave"
@dragover="onDragOver"
@dragstart="onDragStart"
@drop="onDrop"
>
<a
v-if="contentEditable"
v-koel-droppable="handleDrop"
:class="{ active }"
:href="url"
@contextmenu.prevent="openContextMenu"
>
<icon v-if="type === 'favorites'" :icon="faHeart" class="text-maroon" fixed-width/>
<icon v-else :icon="faMusic" :mask="faFile" transform="shrink-7 down-2" fixed-width/>
{{ playlist.name }}
</a>
<a v-else :class="{ active }" :href="url" @contextmenu.prevent="openContextMenu">
<a :class="{ active }" :href="url" @contextmenu.prevent="onContextMenu">
<icon v-if="type === 'recently-played'" :icon="faClockRotateLeft" class="text-green" fixed-width/>
<icon v-else :icon="faBoltLightning" :mask="faFile" transform="shrink-7 down-2" fixed-width/>
<icon v-else-if="type === 'favorites'" :icon="faHeart" class="text-maroon" fixed-width/>
<icon
v-else-if="playlist.is_smart"
:icon="faBoltLightning"
:mask="faFile"
fixed-width
transform="shrink-7 down-2"
/>
<icon v-else :icon="faMusic" :mask="faFile" fixed-width transform="shrink-7 down-2"/>
{{ playlist.name }}
</a>
@ -30,15 +30,20 @@
<script lang="ts" setup>
import { faBoltLightning, faClockRotateLeft, faFile, faHeart, faMusic } from '@fortawesome/free-solid-svg-icons'
import { computed, nextTick, ref, toRefs } from 'vue'
import { eventBus, pluralize, requireInjection, resolveSongsFromDragEvent, startDragging } from '@/utils'
import { eventBus, pluralize, requireInjection } from '@/utils'
import { favoriteStore, playlistStore } from '@/stores'
import router from '@/router'
import { MessageToasterKey } from '@/symbols'
import { useDraggable, useDroppable } from '@/composables'
import ContextMenu from '@/components/playlist/PlaylistContextMenu.vue'
const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
const toaster = requireInjection(MessageToasterKey)
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const el = ref<HTMLLIElement>()
const props = withDefaults(defineProps<{ playlist: Playlist, type?: PlaylistType }>(), { type: 'playlist' })
const { playlist, type } = toRefs(props)
@ -64,35 +69,11 @@ const url = computed(() => {
const hasContextMenu = computed(() => type.value === 'playlist')
const contentEditable = computed(() => {
if (playlist.value.is_smart) {
return false
}
if (playlist.value.is_smart) return false
return type.value === 'playlist' || type.value === 'favorites'
})
const handleDrop = async (event: DragEvent) => {
if (!contentEditable.value) {
return false
}
const songs = await resolveSongsFromDragEvent(event)
if (!songs.length) {
return false
}
if (type.value === 'favorites') {
await favoriteStore.like(songs)
} else if (type.value === 'playlist') {
await playlistStore.addSongs(playlist.value, songs)
toaster.value.success(`Added ${pluralize(songs.length, 'song')} into "${playlist.value.name}."`)
}
return false
}
const openContextMenu = async (event: MouseEvent) => {
const onContextMenu = async (event: MouseEvent) => {
if (hasContextMenu.value) {
await nextTick()
router.go(`/playlist/${playlist.value.id}`)
@ -100,7 +81,44 @@ const openContextMenu = async (event: MouseEvent) => {
}
}
const dragStart = (event: DragEvent) => startDragging(event, playlist.value, 'Playlist')
const onDragStart = (event: DragEvent) => {
if (type.value === 'playlist') {
startDragging(event, playlist.value)
}
}
const onDragOver = (event: DragEvent) => {
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'copy'
el.value!.classList.add('droppable')
return false
}
const onDragLeave = () => el.value!.classList.remove('droppable')
const onDrop = async (event: DragEvent) => {
el.value!.classList.remove('droppable')
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
const songs = await resolveDroppedSongs(event)
if (!songs?.length) return false
if (type.value === 'favorites') {
await favoriteStore.like(songs)
} else if (type.value === 'playlist') {
await playlistStore.addSongs(playlist.value, songs)
toaster.value.success(`Added ${pluralize(songs, 'song')} into "${playlist.value.name}."`)
}
return false
}
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void => {
switch (view) {
@ -128,6 +146,12 @@ eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void
user-select: none;
overflow: hidden;
&.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
::v-deep(a) {
span {
pointer-events: none;

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a
v-if="allowDownload"

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>

View file

@ -9,7 +9,7 @@
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
</template>

View file

@ -9,7 +9,7 @@
@keydown.esc="close"
>
<section class="existing-playlists">
<p>Add {{ pluralize(songs.length, 'song') }} to</p>
<p>Add {{ pluralize(songs, 'song') }} to</p>
<ul>
<template v-if="config.queue">

View file

@ -286,7 +286,7 @@ const submit = async () => {
try {
await songStore.update(mutatedSongs.value, formData)
toaster.value.success(`Updated ${pluralize(mutatedSongs.value.length, 'song')}.`)
toaster.value.success(`Updated ${pluralize(mutatedSongs.value, 'song')}.`)
close()
} finally {
loading.value = false

View file

@ -4,7 +4,7 @@
data-testid="song-card"
draggable="true"
tabindex="0"
@dragstart="dragStart"
@dragstart="onDragStart"
@contextmenu.prevent="requestContextMenu"
@dblclick.prevent="play"
>
@ -29,17 +29,20 @@
<script lang="ts" setup>
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import { toRefs } from 'vue'
import { defaultCover, eventBus, pluralize, startDragging } from '@/utils'
import { defaultCover, eventBus, pluralize } from '@/utils'
import { queueStore } from '@/stores'
import { playbackService } from '@/services'
import { useDraggable } from '@/composables'
import LikeButton from '@/components/song/SongLikeButton.vue'
const props = defineProps<{ song: Song }>()
const { song } = toRefs(props)
const { startDragging } = useDraggable('songs')
const requestContextMenu = (event: MouseEvent) => eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
const dragStart = (event: DragEvent) => startDragging(event, song.value, 'Song')
const onDragStart = (event: DragEvent) => startDragging(event, [song.value])
const play = () => {
queueStore.queueIfNotQueued(song.value)

View file

@ -105,7 +105,8 @@ import isMobile from 'ismobilejs'
import { faAngleDown, faAngleUp } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { eventBus, requireInjection, startDragging } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { useDraggable } from '@/composables'
import {
SelectedSongsKey,
SongListConfigKey,
@ -118,6 +119,8 @@ import {
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
import SongListItem from '@/components/song/SongListItem.vue'
const { startDragging } = useDraggable('songs')
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scroll-breakpoint', 'scrolled-to-end'])
const [items] = requireInjection(SongsKey)
@ -260,7 +263,7 @@ const rowDragStart = (row: SongRow, event: DragEvent) => {
row.selected = true
}
startDragging(event, selectedSongs.value, 'Song')
startDragging(event, selectedSongs.value)
}
/**

View file

@ -5,3 +5,4 @@ export * from './useContextMenu'
export * from './useAuthorization'
export * from './useNewVersionNotification'
export * from './useThirdPartyServices'
export * from './useDragAndDrop'

View file

@ -0,0 +1,145 @@
import { arrayify, logger, pluralize } from '@/utils'
import { albumStore, artistStore, playlistStore, songStore } from '@/stores'
type Draggable = Song | Song[] | Album | Artist | Playlist
type DraggableType = 'songs' | 'album' | 'artist' | 'playlist'
const createGhostDragImage = (event: DragEvent, text: string): void => {
if (!event.dataTransfer) {
return
}
let dragGhost = document.querySelector<HTMLElement>('#dragGhost')
if (!dragGhost) {
// Create the element to be the ghost drag image.
dragGhost = document.createElement('div')
dragGhost.id = 'dragGhost'
document.body.appendChild(dragGhost)
}
dragGhost.innerText = text
event.dataTransfer.setDragImage(dragGhost, 0, 0)
}
const getDragType = (event: DragEvent) => {
const types: DraggableType[] = ['songs', 'album', 'artist', 'playlist']
for (let i = 0, count = types.length; i < count; ++i) {
if (event.dataTransfer?.types.includes(`application/x-koel.${types[i]}`)) return types[i]
}
}
export const useDraggable = (type: DraggableType) => {
const startDragging = (event: DragEvent, dragged: Draggable) => {
if (!event.dataTransfer) {
return
}
let text
let data: any
switch (type) {
case 'songs':
dragged = arrayify(<Song>dragged)
text = dragged.length === 1 ? `${dragged[0].title} by ${dragged[0].artist_name}` : pluralize(dragged, 'song')
data = dragged.map(song => song.id)
break
case 'album':
dragged = <Album>dragged
text = `All songs in ${dragged.name}`
data = dragged.id
break
case 'artist':
dragged = <Artist>dragged
text = `All songs by ${dragged.name}`
data = dragged.id
break
case 'playlist':
dragged = <Playlist>dragged
text = dragged.name
data = dragged.id
break
default:
return
}
event.dataTransfer.setData(`application/x-koel.${type}`, JSON.stringify(data))
createGhostDragImage(event, text)
}
return {
startDragging
}
}
export const useDroppable = (acceptedTypes: DraggableType[]) => {
const acceptsDrop = (event: DragEvent) => {
const type = getDragType(event)
return type && acceptedTypes.includes(type)
}
const getDroppedData = (event: DragEvent) => {
const type = getDragType(event)
if (!type) return null
try {
return JSON.parse(event.dataTransfer?.getData(`application/x-koel.${type}`)!)
} catch (e) {
logger.warn('Failed to parse dropped data', e)
return null
}
}
const resolveDroppedValue = async <T = Playlist> (event: DragEvent): Promise<T | undefined> => {
try {
switch (getDragType(event)) {
case 'playlist':
return playlistStore
.byId(parseInt(event.dataTransfer!.getData('application/x-koel.playlist'))) as T | undefined
default:
return
}
} catch (error) {
logger.error(error, event)
}
}
const resolveDroppedSongs = async (event: DragEvent) => {
try {
const type = getDragType(event)
if (!type) return <Song[]>[]
const data = getDroppedData(event)
switch (type) {
case 'songs':
return songStore.byIds(<string[]>data)
case 'album':
const album = await albumStore.resolve(<number>data)
return album ? await songStore.fetchForAlbum(album) : <Song[]>[]
case 'artist':
const artist = await artistStore.resolve(<number>data)
return artist ? await songStore.fetchForArtist(artist) : <Song[]>[]
case 'playlist':
const playlist = await playlistStore.byId(<number>data)
return playlist ? await songStore.fetchForPlaylist(playlist) : <Song[]>[]
}
} catch (error) {
logger.error(error, event)
return <Song[]>[]
}
}
return {
acceptsDrop,
resolveDroppedValue,
resolveDroppedSongs
}
}

View file

@ -32,7 +32,7 @@ export const useSongMenuMethods = (songs: Ref<Song[]>, close: Closure) => {
try {
await playlistStore.addSongs(playlist, songs.value)
toaster.value.success(`Added ${pluralize(songs.value.length, 'song')} into "${playlist.name}."`)
toaster.value.success(`Added ${pluralize(songs.value, 'song')} into "${playlist.name}."`)
} catch (error) {
dialog.value.error('Something went wrong. Please try again.', 'Error')
}

View file

@ -1,23 +0,0 @@
import { Directive } from 'vue'
export const droppable: Directive = {
created: (el: HTMLElement, binding) => {
el.addEventListener('dragenter', (event: DragEvent) => {
event.preventDefault()
el.classList.add('droppable')
event.dataTransfer!.dropEffect = 'move'
return false
})
el.addEventListener('dragover', (event: DragEvent) => event.preventDefault())
el.addEventListener('dragleave', () => el.classList.remove('droppable'))
el.addEventListener('drop', (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
el.classList.remove('droppable')
binding.value(event)
})
}
}

View file

@ -1,3 +1,2 @@
export * from './droppable'
export * from './clickaway'
export * from './focus'

View file

@ -24,7 +24,9 @@ const DEFAULT_VOLUME_VALUE = 7
const VOLUME_INPUT_SELECTOR = '#volumeInput'
class PlaybackService {
// @ts-ignore
public player: Plyr
// @ts-ignore
private volumeInput: HTMLInputElement
private repeatModes: RepeatMode[] = ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE']
private initialized = false

View file

@ -280,14 +280,6 @@ interface EqualizerPreset {
gains: number[]
}
type Draggable = Song | Song[] | Album | Artist | Playlist
type DragType = 'Song' | 'Album' | 'Artist' | 'Playlist'
type DragData = {
type: 'songs' | 'album' | 'artist' | 'playlist'
value: string | string[] | number
}
declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused'
declare type MainViewName =
| 'Home'

View file

@ -1,113 +0,0 @@
import { arrayify, logger, pluralize } from '@/utils'
import { albumStore, artistStore, songStore } from '@/stores'
const createGhostDragImage = (event: DragEvent, text: string): void => {
if (!event.dataTransfer) {
return
}
let dragGhost = document.querySelector<HTMLElement>('#dragGhost')
if (!dragGhost) {
// Create the element to be the ghost drag image.
dragGhost = document.createElement('div')
dragGhost.id = 'dragGhost'
document.body.appendChild(dragGhost)
}
dragGhost.innerText = text
event.dataTransfer.setDragImage(dragGhost, 0, 0)
}
const startDragging = (event: DragEvent, dragged: Draggable, type: DragType): void => {
if (!event.dataTransfer) {
return
}
let text
let data: DragData
switch (type) {
case 'Song':
dragged = arrayify(dragged as Song)
text = dragged.length === 1
? `${dragged[0].title} by ${dragged[0].artist_name}`
: pluralize(dragged.length, 'song')
data = {
type: 'songs',
value: dragged.map(song => song.id)
}
break
case 'Album':
dragged = dragged as Album
text = `All songs in ${dragged.name}`
data = {
type: 'album',
value: dragged.id
}
break
case 'Artist':
dragged = dragged as Artist
text = `All songs by ${dragged.name}`
data = {
type: 'artist',
value: dragged.id
}
break
case 'Playlist':
dragged = dragged as Playlist
text = dragged.name
data = {
type: 'playlist',
value: dragged.id
}
break
default:
throw Error(`Invalid drag type: ${type}`)
}
event.dataTransfer.setData(data.type, '')
event.dataTransfer.setData('application/x-koel.text+plain', JSON.stringify(data))
event.dataTransfer.effectAllowed = 'move'
createGhostDragImage(event, text)
}
const resolveSongsFromDragEvent = async (event: DragEvent) => {
if (!event.dataTransfer?.getData('application/x-koel.text+plain')) {
return []
}
const data: DragData = JSON.parse(event.dataTransfer.getData('application/x-koel.text+plain'))
switch (data.type) {
case 'songs':
return songStore.byIds(data.value as string[])
case 'album':
const album = await albumStore.resolve(data.value as number)
return album ? await songStore.fetchForAlbum(album) : []
case 'artist':
const artist = await artistStore.resolve(data.value as number)
return artist ? await songStore.fetchForArtist(artist) : []
default:
logger.warn('Unhandled drag data type', data.type)
return []
}
}
export {
startDragging,
resolveSongsFromDragEvent
}

View file

@ -35,10 +35,10 @@ new class extends UnitTestCase {
})
it.each([
['foo<br>bar', "foo\nbar"],
['foo<br/>bar', "foo\nbar"],
['foo<br />bar', "foo\nbar"],
['foo<br>bar<br/>baz', "foo\nbar\nbaz"]
['foo<br>bar', 'foo\nbar'],
['foo<br/>bar', 'foo\nbar'],
['foo<br />bar', 'foo\nbar'],
['foo<br>bar<br/>baz', 'foo\nbar\nbaz']
])('converts <br> tags in %s to line breaks', (input, output) => expect(br2nl(input)).toEqual(output))
it.each([
@ -53,5 +53,14 @@ new class extends UnitTestCase {
[2, 'cat', 'cats'],
[0, 'cat', 'cats']
])('pluralizes %d %s', (count, noun, plural) => expect(pluralize(count, noun)).toEqual(`${count} ${plural}`))
it.each([
[['foo'], 'cat', 'cat'],
[['foo', 'bar'], 'cat', 'cats'],
[[], 'cat', 'cats'],
])(
'pluralizes with array parameters',
(arr, noun, plural) => expect(pluralize(arr, noun)).toEqual(`${arr.length} ${plural}`)
)
}
}

View file

@ -44,7 +44,7 @@ export const slugToTitle = (slug: string, separator = '-') => {
return title.replace(/\s+/g, ' ').trim()
}
export const pluralize = (count: number | undefined, singular: string) => {
count = count ?? 0
export const pluralize = (count: any[] | number | undefined, singular: string) => {
count = Array.isArray(count) ? count.length : (count ?? 0)
return count === 1 ? `${count} ${singular}` : `${count.toLocaleString()} ${singular}s`
}

View file

@ -6,5 +6,4 @@ export * from './$'
export * from './helpers'
export * from './fileReader'
export * from './directoryReader'
export * from './dragAndDrop'
export * from './logger'