feat: implement drop zone for uploading

This commit is contained in:
Phan An 2022-09-12 18:11:56 +07:00
parent 91e51f8b54
commit aea0fabe73
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
36 changed files with 296 additions and 192 deletions

View file

@ -3,7 +3,7 @@
<DialogBox ref="dialog"/>
<MessageToaster ref="toaster"/>
<div id="main" v-if="authenticated">
<div id="main" v-if="authenticated" @dragover="onDragOver" @drop="onDrop" @dragend="onDragEnd">
<Hotkeys/>
<GlobalEventListeners/>
<AppHeader/>
@ -16,6 +16,7 @@
<PlaylistContextMenu/>
<PlaylistFolderContextMenu/>
<CreateNewPlaylistContextMenu/>
<DropZone v-show="showDropZone"/>
</div>
<div class="login-wrapper" v-else>
@ -28,7 +29,7 @@ import { defineAsyncComponent, nextTick, onMounted, provide, ref } from 'vue'
import { eventBus, hideOverlay, showOverlay } from '@/utils'
import { commonStore, preferenceStore as preferences } from '@/stores'
import { authService, playbackService, socketListener, socketService, uploadService } from '@/services'
import { DialogBoxKey, MessageToasterKey } from '@/symbols'
import { ActiveScreenKey, DialogBoxKey, MessageToasterKey } from '@/symbols'
import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue'
@ -50,10 +51,13 @@ const PlaylistFolderContextMenu = defineAsyncComponent(() => import('@/component
const SongContextMenu = defineAsyncComponent(() => import('@/components/song/SongContextMenu.vue'))
const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/CreateNewPlaylistContextMenu.vue'))
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
const dialog = ref<InstanceType<typeof DialogBox>>()
const toaster = ref<InstanceType<typeof MessageToaster>>()
const authenticated = ref(false)
const showDropZone = ref(false)
const activeScreen = ref<ScreenName>()
/**
* Request for notification permission if it's not provided and the user is OK with notifications.
@ -110,6 +114,18 @@ const init = async () => {
}
}
const onDragOver = (e: DragEvent) => {
showDropZone.value = Boolean(e.dataTransfer?.types.includes('Files')) && activeScreen.value !== 'Upload'
}
const onDragEnd = () => (showDropZone.value = false)
const onDrop = () => (showDropZone.value = false)
onMounted(() => {
eventBus.on('ACTIVATE_SCREEN', (screen: ScreenName) => (activeScreen.value = screen))
})
provide(ActiveScreenKey, activeScreen)
provide(DialogBoxKey, dialog)
provide(MessageToasterKey, toaster)
</script>

View file

@ -62,29 +62,29 @@ provideReadonly(SongsKey, songsToEdit, false)
provideReadonly(EditSongFormInitialTabKey, editSongFormInitialTab)
eventBus.on({
'MODAL_SHOW_ABOUT_KOEL': () => (showingModalName.value = 'about-koel'),
'MODAL_SHOW_ADD_USER_FORM': () => (showingModalName.value = 'add-user-form'),
'MODAL_SHOW_CREATE_PLAYLIST_FORM': () => (showingModalName.value = 'create-playlist-form'),
'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM': () => (showingModalName.value = 'create-smart-playlist-form'),
'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM': () => (showingModalName.value = 'create-playlist-folder-form'),
MODAL_SHOW_ABOUT_KOEL: () => (showingModalName.value = 'about-koel'),
MODAL_SHOW_ADD_USER_FORM: () => (showingModalName.value = 'add-user-form'),
MODAL_SHOW_CREATE_PLAYLIST_FORM: () => (showingModalName.value = 'create-playlist-form'),
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: () => (showingModalName.value = 'create-smart-playlist-form'),
MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM: () => (showingModalName.value = 'create-playlist-folder-form'),
'MODAL_SHOW_EDIT_PLAYLIST_FORM': (playlist: Playlist) => {
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => {
playlistToEdit.value = playlist
showingModalName.value = playlist.is_smart ? 'edit-smart-playlist-form' : 'edit-playlist-form'
},
'MODAL_SHOW_EDIT_USER_FORM': (user: User) => {
MODAL_SHOW_EDIT_USER_FORM: (user: User) => {
userToEdit.value = user
showingModalName.value = 'edit-user-form'
},
'MODAL_SHOW_EDIT_SONG_FORM': (songs: Song | Song[], initialTab: EditSongFormTabName = 'details') => {
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab: EditSongFormTabName = 'details') => {
songsToEdit.value = arrayify(songs)
editSongFormInitialTab.value = initialTab
showingModalName.value = 'edit-song-form'
},
'MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM': (folder: PlaylistFolder) => {
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (folder: PlaylistFolder) => {
playlistFolderToEdit.value = folder
showingModalName.value = 'edit-playlist-folder-form'
}

View file

@ -73,7 +73,7 @@ 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'))
eventBus.on('ACTIVATE_SCREEN', (screen: ScreenName) => (viewingQueue.value = screen === 'Queue'))
</script>
<style lang="scss" scoped>

View file

@ -25,16 +25,8 @@ const requestContextMenu = (event: MouseEvent) => {
}
eventBus.on({
/**
* Listen to song:played event to set the current playing song.
*/
'SONG_STARTED': (newSong: Song) => (song.value = newSong),
/**
* 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')
SONG_STARTED: (newSong: Song) => (song.value = newSong),
ACTIVATE_SCREEN: (screen: ScreenName) => (viewingQueue.value = screen === 'Queue')
})
</script>

View file

@ -139,8 +139,8 @@ const fetchSongInfo = async (_song: Song) => {
}
eventBus.on({
'SONG_STARTED': async (song: Song) => await fetchSongInfo(song),
'LOAD_MAIN_CONTENT': (): void => {
SONG_STARTED: async (song: Song) => await fetchSongInfo(song),
KOEL_READY: () => {
// On ready, add 'with-extra-panel' class.
isMobile.any || document.documentElement.classList.add('with-extra-panel')

View file

@ -8,24 +8,24 @@
<Visualizer v-if="showingVisualizer"/>
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album_id"/>
<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'"/>
<HomeScreen v-show="screen === 'Home'"/>
<QueueScreen v-show="screen === 'Queue'"/>
<AllSongsScreen v-show="screen === 'Songs'"/>
<AlbumListScreen v-show="screen === 'Albums'"/>
<ArtistListScreen v-show="screen === 'Artists'"/>
<PlaylistScreen v-show="screen === 'Playlist'"/>
<FavoritesScreen v-show="screen === 'Favorites'"/>
<RecentlyPlayedScreen v-show="screen === 'RecentlyPlayed'"/>
<UploadScreen v-show="screen === 'Upload'"/>
<SearchExcerptsScreen v-show="screen === 'Search.Excerpt'"/>
<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="useYouTube" v-show="view === 'YouTube'"/>
<SearchSongResultsScreen v-if="screen === 'Search.Songs'" :q="screenProps"/>
<AlbumScreen v-if="screen === 'Album'" :album="screenProps"/>
<ArtistScreen v-if="screen === 'Artist'" :artist="screenProps"/>
<SettingsScreen v-if="screen === 'Settings'"/>
<ProfileScreen v-if="screen === 'Profile'"/>
<UserListScreen v-if="screen === 'Users'"/>
<YoutubeScreen v-if="useYouTube" v-show="screen === 'YouTube'"/>
</section>
</template>
@ -61,17 +61,17 @@ const { useYouTube } = useThirdPartyServices()
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
const showingVisualizer = ref(false)
const screenProps = ref<any>(null)
const view = ref<MainViewName>('Home')
const screen = ref<ScreenName>('Home')
const currentSong = ref<Song | null>(null)
eventBus.on({
LOAD_MAIN_CONTENT (_view: MainViewName, data: any) {
ACTIVATE_SCREEN (screenName: ScreenName, data: any) {
screenProps.value = data
view.value = _view
screen.value = screenName
},
'TOGGLE_VISUALIZER': () => (showingVisualizer.value = !showingVisualizer.value),
'SONG_STARTED': (song: Song) => (currentSong.value = song)
TOGGLE_VISUALIZER: () => (showingVisualizer.value = !showingVisualizer.value),
SONG_STARTED: (song: Song) => (currentSong.value = song)
})
</script>

View file

@ -5,37 +5,42 @@
<ul class="menu">
<li>
<a :class="['home', currentView === 'Home' ? 'active' : '']" href="#!/home">
<a :class="['home', activeScreen === 'Home' ? 'active' : '']" href="#!/home">
<icon :icon="faHome" fixed-width/>
Home
</a>
</li>
<li ref="queueMenuItemEl" @dragleave="onQueueDragLeave" @dragover="onQueueDragOver" @drop="onQueueDrop">
<a :class="['queue', currentView === 'Queue' ? 'active' : '']" href="#!/queue">
<li
:class="droppableToQueue && 'droppable'"
@dragleave="onQueueDragLeave"
@dragover="onQueueDragOver"
@drop="onQueueDrop"
>
<a :class="['queue', activeScreen === 'Queue' ? 'active' : '']" href="#!/queue">
<icon :icon="faListOl" fixed-width/>
Current Queue
</a>
</li>
<li>
<a :class="['songs', currentView === 'Songs' ? 'active' : '']" href="#!/songs">
<a :class="['songs', activeScreen === 'Songs' ? 'active' : '']" href="#!/songs">
<icon :icon="faMusic" fixed-width/>
All Songs
</a>
</li>
<li>
<a :class="['albums', currentView === 'Albums' ? 'active' : '']" href="#!/albums">
<a :class="['albums', activeScreen === 'Albums' ? 'active' : '']" href="#!/albums">
<icon :icon="faCompactDisc" fixed-width/>
Albums
</a>
</li>
<li>
<a :class="['artists', currentView === 'Artists' ? 'active' : '']" href="#!/artists">
<a :class="['artists', activeScreen === 'Artists' ? 'active' : '']" href="#!/artists">
<icon :icon="faMicrophone" fixed-width/>
Artists
</a>
</li>
<li v-if="useYouTube">
<a :class="['youtube', currentView === 'YouTube' ? 'active' : '']" href="#!/youtube">
<a :class="['youtube', activeScreen === 'YouTube' ? 'active' : '']" href="#!/youtube">
<icon :icon="faYoutube" fixed-width/>
YouTube Video
</a>
@ -43,26 +48,26 @@
</ul>
</section>
<PlaylistList :current-view="currentView"/>
<PlaylistList/>
<section v-if="isAdmin" class="manage">
<h1>Manage</h1>
<ul class="menu">
<li>
<a :class="['settings', currentView === 'Settings' ? 'active' : '']" href="#!/settings">
<a :class="['settings', activeScreen === 'Settings' ? 'active' : '']" href="#!/settings">
<icon :icon="faTools" fixed-width/>
Settings
</a>
</li>
<li>
<a :class="['upload', currentView === 'Upload' ? 'active' : '']" href="#!/upload">
<a :class="['upload', activeScreen === 'Upload' ? 'active' : '']" href="#!/upload">
<icon :icon="faUpload" fixed-width/>
Upload
</a>
</li>
<li>
<a :class="['users', currentView === 'Users' ? 'active' : '']" href="#!/users">
<a :class="['users', activeScreen === 'Users' ? 'active' : '']" href="#!/users">
<icon :icon="faUsers" fixed-width/>
Users
</a>
@ -86,15 +91,16 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { ref } from 'vue'
import { eventBus } from '@/utils'
import { eventBus, requireInjection } from '@/utils'
import { queueStore } from '@/stores'
import { useAuthorization, useDroppable, useThirdPartyServices } from '@/composables'
import { ActiveScreenKey } from '@/symbols'
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
const showing = ref(!isMobile.phone)
const currentView = ref<MainViewName>('Home')
const queueMenuItemEl = ref<HTMLLIElement>()
const activeScreen = requireInjection(ActiveScreenKey, ref('Home'))
const droppableToQueue = ref(false)
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist'])
const { useYouTube } = useThirdPartyServices()
@ -105,13 +111,13 @@ const onQueueDragOver = (event: DragEvent) => {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
queueMenuItemEl.value?.classList.add('droppable')
droppableToQueue.value = true
}
const onQueueDragLeave = () => queueMenuItemEl.value?.classList.remove('droppable')
const onQueueDragLeave = () => (droppableToQueue.value = false)
const onQueueDrop = async (event: DragEvent) => {
queueMenuItemEl.value?.classList.remove('droppable')
droppableToQueue.value = false
if (!acceptsDrop(event)) return false
@ -123,16 +129,16 @@ const onQueueDrop = async (event: DragEvent) => {
}
eventBus.on({
LOAD_MAIN_CONTENT (view: MainViewName) {
currentView.value = view
// Hide the sidebar if on mobile
isMobile.phone && (showing.value = false)
},
/**
* Listen to sidebar:toggle event to show or hide the sidebar.
* On mobile, hide the sidebar whenever a screen is activated.
*/
ACTIVATE_SCREEN: () => isMobile.phone && (showing.value = false),
/**
* Listen to toggle sidebar event to show or hide the sidebar.
* This should only be triggered on a mobile device.
*/
['TOGGLE_SIDEBAR']: () => (showing.value = !showing.value)
TOGGLE_SIDEBAR: () => (showing.value = !showing.value)
})
</script>

View file

@ -1,7 +1,7 @@
<template>
<li
ref="el"
class="playlist-folder"
:class="{ droppable }"
@dragleave="onDragLeave"
@dragover="onDragOver"
@drop="onDrop"
@ -18,10 +18,10 @@
<div
v-if="opened"
ref="hatch"
:class="droppableOnHatch && 'droppable'"
class="hatch"
@dragleave.prevent="onDragLeaveHatch"
@dragover="onDragOverHatch"
@dragleave.prevent="onDragLeaveHatch"
@drop.prevent="onDropOnHatch"
/>
</li>
@ -39,9 +39,9 @@ const PlaylistSidebarItem = defineAsyncComponent(() => import('@/components/play
const props = defineProps<{ folder: PlaylistFolder }>()
const { folder } = toRefs(props)
const el = ref<HTMLLIElement>()
const hatch = ref<HTMLLIElement>()
const opened = ref(false)
const droppable = ref(false)
const droppableOnHatch = ref(false)
const playlistsInFolder = computed(() => playlistStore.byFolder(folder.value))
@ -54,37 +54,39 @@ const onDragOver = (event: DragEvent) => {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
el.value?.classList.add('droppable')
droppable.value = true
opened.value = true
}
const onDragLeave = () => el.value?.classList.remove('droppable')
const onDragLeave = () => (droppable.value = false)
const onDrop = async (event: DragEvent) => {
droppable.value = false
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('droppable')
const onDragLeaveHatch = () => (droppableOnHatch.value = false)
const onDragOverHatch = (event: DragEvent) => {
if (!acceptsDrop(event)) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
hatch.value?.classList.add('droppable')
droppableOnHatch.value = true
}
const onDropOnHatch = async (event: DragEvent) => {
hatch.value?.classList.remove('droppable')
el.value?.classList.remove('droppable')
droppableOnHatch.value = false
droppable.value = false
const playlist = (await resolveDroppedValue<Playlist>(event))!
// if the playlist isn't in the folder, don't do anything. The folder will handle the drop.

View file

@ -2,6 +2,7 @@
<li
ref="el"
class="playlist"
:class="{ droppable }"
data-testid="playlist-sidebar-item"
draggable="true"
@contextmenu="onContextMenu"
@ -38,7 +39,7 @@ const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
const toaster = requireInjection(MessageToasterKey)
const el = ref<HTMLLIElement>()
const droppable = ref(false)
const props = defineProps<{ list: PlaylistLike }>()
const { list } = toRefs(props)
@ -79,15 +80,15 @@ const onDragOver = (event: DragEvent) => {
event.preventDefault()
event.dataTransfer!.dropEffect = 'copy'
el.value?.classList.add('droppable')
droppable.value = true
return false
}
const onDragLeave = () => el.value?.classList.remove('droppable')
const onDragLeave = () => (droppable.value = false)
const onDrop = async (event: DragEvent) => {
el.value?.classList.remove('droppable')
droppable.value = false
if (!contentEditable.value) return false
if (!acceptsDrop(event)) return false
@ -106,8 +107,8 @@ const onDrop = async (event: DragEvent) => {
return false
}
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _list: PlaylistLike): void => {
switch (view) {
eventBus.on('ACTIVATE_SCREEN', (screen: ScreenName, _list: PlaylistLike): void => {
switch (screen) {
case 'Favorites':
active.value = isFavoriteList(list.value)
break

View file

@ -25,7 +25,7 @@ new class extends UnitTestCase {
preferenceStore.albumsViewMode = mode
const { getByTestId } = this.renderComponent()
eventBus.emit('LOAD_MAIN_CONTENT', 'Albums')
eventBus.emit('ACTIVATE_SCREEN', 'Albums')
await waitFor(() => expect(getByTestId('album-list').classList.contains(`as-${mode}`)).toBe(true))
})

View file

@ -64,8 +64,8 @@ const fetchAlbums = async () => {
loading.value = false
}
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {
if (view === 'Albums' && !initialized) {
eventBus.on('ACTIVATE_SCREEN', async (screen: ScreenName) => {
if (screen === 'Albums' && !initialized) {
viewMode.value = preferences.albumsViewMode || 'thumbnails'
await makeScrollable()
initialized = true

View file

@ -12,7 +12,7 @@ new class extends UnitTestCase {
private async renderComponent () {
commonStore.state.song_count = 420
commonStore.state.song_length = 123_456
songStore.state.songs = factory<Song[]>('song', 20)
songStore.state.songs = factory<Song>('song', 20)
const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2)
const rendered = this.render(AllSongsScreen, {
@ -23,7 +23,7 @@ new class extends UnitTestCase {
}
})
eventBus.emit('LOAD_MAIN_CONTENT', 'Songs')
eventBus.emit('ACTIVATE_SCREEN', 'Songs')
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('title', 'asc', 1))
return rendered

View file

@ -102,8 +102,8 @@ const playAll = async (shuffle: boolean) => {
await router.go('queue')
}
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {
if (view === 'Songs' && !initialized) {
eventBus.on('ACTIVATE_SCREEN', async (screen: ScreenName) => {
if (screen === 'Songs' && !initialized) {
await fetchSongs()
initialized = true
}

View file

@ -25,7 +25,7 @@ new class extends UnitTestCase {
preferenceStore.artistsViewMode = mode
const { getByTestId } = this.renderComponent()
eventBus.emit('LOAD_MAIN_CONTENT', 'Artists')
eventBus.emit('ACTIVATE_SCREEN', 'Artists')
await waitFor(() => expect(getByTestId('artist-list').classList.contains(`as-${mode}`)).toBe(true))
})

View file

@ -64,8 +64,8 @@ const fetchArtists = async () => {
loading.value = false
}
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {
if (view === 'Artists' && !initialized) {
eventBus.on('ACTIVATE_SCREEN', async (screen: ScreenName) => {
if (screen === 'Artists' && !initialized) {
viewMode.value = preferences.artistsViewMode || 'thumbnails'
await makeScrollable()
initialized = true

View file

@ -11,7 +11,7 @@ new class extends UnitTestCase {
const fetchMock = this.mock(favoriteStore, 'fetch')
const rendered = this.render(FavoritesScreen)
eventBus.emit('LOAD_MAIN_CONTENT', 'Favorites')
eventBus.emit('ACTIVATE_SCREEN', 'Favorites')
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
return rendered
@ -19,7 +19,7 @@ new class extends UnitTestCase {
protected test () {
it('renders a list of favorites', async () => {
favoriteStore.state.songs = factory<Song[]>('song', 13)
favoriteStore.state.songs = factory<Song>('song', 13)
const { queryByTestId } = await this.renderComponent()
await waitFor(() => {

View file

@ -106,8 +106,8 @@ const fetchSongs = async () => {
sort()
}
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {
if (view === 'Favorites' && !initialized) {
eventBus.on('ACTIVATE_SCREEN', async (screen: ScreenName) => {
if (screen === 'Favorites' && !initialized) {
await fetchSongs()
initialized = true
}

View file

@ -72,7 +72,7 @@ const libraryEmpty = computed(() => commonStore.state.song_length === 0)
const loading = ref(false)
let initialized = false
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {
eventBus.on('ACTIVATE_SCREEN', async (view: ScreenName) => {
if (view === 'Home' && !initialized) {
loading.value = true
await overviewStore.init()

View file

@ -15,7 +15,7 @@ new class extends UnitTestCase {
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
const rendered = this.render(PlaylistScreen)
eventBus.emit('LOAD_MAIN_CONTENT', 'Playlist', playlist)
eventBus.emit('ACTIVATE_SCREEN', 'Playlist', playlist)
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist))
@ -54,7 +54,7 @@ new class extends UnitTestCase {
it('deletes the playlist', async () => {
const { getByTitle } = (await this.renderComponent([])).rendered
// mock *after* rendering to not tamper with "LOAD_MAIN_CONTENT" emission
// mock *after* rendering to not tamper with "ACTIVATE_SCREEN" emission
const emitMock = this.mock(eventBus, 'emit')
await fireEvent.click(getByTitle('Delete this playlist'))

View file

@ -124,14 +124,14 @@ const fetchSongs = async () => {
}
eventBus.on({
'LOAD_MAIN_CONTENT': async (view: MainViewName, p: any) => {
if (view === 'Playlist') {
ACTIVATE_SCREEN: async (screen: ScreenName, p: any) => {
if (screen === 'Playlist') {
playlist.value = p as Playlist
await fetchSongs()
}
},
'SMART_PLAYLIST_UPDATED': async (updated: Playlist) => updated === playlist.value && await fetchSongs()
SMART_PLAYLIST_UPDATED: async (updated: Playlist) => updated === playlist.value && await fetchSongs()
}
)
</script>

View file

@ -19,7 +19,7 @@ new class extends UnitTestCase {
}
})
eventBus.emit('LOAD_MAIN_CONTENT', 'RecentlyPlayed')
eventBus.emit('ACTIVATE_SCREEN', 'RecentlyPlayed')
await waitFor(() => expect(fetchMock).toHaveBeenCalled())

View file

@ -70,8 +70,8 @@ let initialized = false
let loading = ref(false)
eventBus.on({
'LOAD_MAIN_CONTENT': async (view: MainViewName) => {
if (view === 'RecentlyPlayed' && !initialized) {
ACTIVATE_SCREEN: async (screen: ScreenName) => {
if (screen === 'RecentlyPlayed' && !initialized) {
loading.value = true
await recentlyPlayedStore.fetch()
loading.value = false

View file

@ -19,7 +19,7 @@
<div class="main-scroll-wrap">
<div
v-if="mediaPath"
v-if="mediaPathSetUp"
:class="{ droppable }"
class="upload-panel"
@dragenter.prevent="onDragEnter"
@ -58,15 +58,13 @@
</template>
<script lang="ts" setup>
import ismobile from 'ismobilejs'
import { faRotateBack, faTimes, faUpload, faWarning } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, ref, toRef } from 'vue'
import { settingStore } from '@/stores'
import { eventBus, getAllFileEntries, isDirectoryReadingSupported as canDropFolders } from '@/utils'
import { acceptedMediaTypes, UploadFile } from '@/config'
import { isDirectoryReadingSupported as canDropFolders } from '@/utils'
import { acceptedMediaTypes } from '@/config'
import { uploadService } from '@/services'
import { useAuthorization } from '@/composables'
import { useUpload } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
@ -77,63 +75,31 @@ const UploadItem = defineAsyncComponent(() => import('@/components/ui/upload/Upl
const acceptAttribute = acceptedMediaTypes.join(',')
const mediaPath = toRef(settingStore.state, 'media_path')
const { allowsUpload, mediaPathSetUp, queueFilesForUpload, handleDropEvent } = useUpload()
const files = toRef(uploadService.state, 'files')
const droppable = ref(false)
const hasUploadFailures = ref(false)
const { isAdmin } = useAuthorization()
const allowsUpload = computed(() => isAdmin.value && !ismobile.any)
const hasUploadFailures = computed(() => files.value.filter((file) => file.status === 'Errored').length > 0)
const onDragEnter = () => (droppable.value = allowsUpload.value)
const onDragLeave = () => (droppable.value = false)
const handleFiles = (files: Array<File>) => {
const uploadCandidates = files
.filter(file => acceptedMediaTypes.includes(file.type))
.map((file): UploadFile => ({
file,
id: `${file.name}-${file.size}`, // for simplicity, a file's identity is determined by its name and size
status: 'Ready',
name: file.name,
progress: 0
}))
uploadService.queue(uploadCandidates)
}
const fileEntryToFile = async (entry: FileSystemEntry) => new Promise<File>(resolve => entry.file(resolve))
const onFileInputChange = (event: InputEvent) => {
const selectedFileList = (event.target as HTMLInputElement).files
selectedFileList?.length && handleFiles(Array.from(selectedFileList))
if (selectedFileList?.length) {
queueFilesForUpload(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)
await handleDropEvent(event)
}
const retryAll = () => {
uploadService.retryAll()
hasUploadFailures.value = false
}
const removeFailedEntries = () => {
uploadService.removeFailed()
hasUploadFailures.value = false
}
eventBus.on('UPLOAD_QUEUE_FINISHED', () => {
hasUploadFailures.value = uploadService.getFilesByStatus('Errored').length !== 0
})
const retryAll = () => uploadService.retryAll()
const removeFailedEntries = () => uploadService.removeFailed()
</script>
<style lang="scss">

View file

@ -55,7 +55,7 @@ eventBus.on({
/**
* Stop video playback when a song is played/resumed.
*/
'SONG_STARTED': () => player && player.pauseVideo()
SONG_STARTED: () => player && player.pauseVideo()
})
</script>

View file

@ -17,8 +17,8 @@ new class extends UnitTestCase {
},
global: {
provide: {
[SongsKey]: [ref(songs)],
[SelectedSongsKey]: [ref(take(songs, selectedSongCount))]
[<symbol>SongsKey]: [ref(songs)],
[<symbol>SelectedSongsKey]: [ref(take(songs, selectedSongCount))]
}
}
})

View file

@ -36,8 +36,8 @@ const show = (options: Partial<OverlayState>) => {
const hide = () => (state.showing = false)
eventBus.on({
'SHOW_OVERLAY': show,
'HIDE_OVERLAY': hide
SHOW_OVERLAY: show,
HIDE_OVERLAY: hide
})
</script>

View file

@ -36,7 +36,7 @@ if (process.env.NODE_ENV !== 'test') {
const goToSearchScreen = () => router.go('search')
eventBus.on({
FOCUS_SEARCH_FIELD () {
FOCUS_SEARCH_FIELD: () => {
input.value?.focus()
input.value?.select()
}

View file

@ -0,0 +1,74 @@
<template>
<div
v-if="allowsUpload && mediaPathSetUp"
:class="{ droppable }"
class="drop-zone"
@dragleave="onDropLeave"
@dragover="onDragOver"
@drop="onDrop"
>
<icon :icon="faUpload" size="6x"/>
<h3>Drop to upload</h3>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { faUpload } from '@fortawesome/free-solid-svg-icons'
import { useUpload } from '@/composables'
const { allowsUpload, mediaPathSetUp, handleDropEvent } = useUpload()
const droppable = ref(false)
const onDropLeave = () => (droppable.value = false)
const onDragOver = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('Files')) return false
event.preventDefault()
event.dataTransfer!.dropEffect = 'copy'
droppable.value = true
}
const onDrop = async (event: DragEvent) => {
event.preventDefault()
droppable.value = false
await handleDropEvent(event)
}
</script>
<style lang="scss" scoped>
.drop-zone {
height: 256px;
max-height: 66vh;
aspect-ratio: 1/1;
outline: 3px dashed #ccc;
position: fixed;
z-index: 9;
transform: translate(calc(50vw - 50%), calc(50vh - 50%));
top: 0;
left: 0;
border-radius: 2rem;
background: rgba(0, 0, 0, .4);
display: flex;
align-content: center;
justify-content: center;
flex-direction: column;
align-items: center;
overflow: hidden;
transition: 0.2s;
h3 {
font-size: 2rem;
font-weight: var(--font-weight-thin);
margin-top: 1rem;
}
&.droppable {
height: 384px;
outline: 3px dashed #fff;
background: rgba(0, 0, 0, .9);
box-shadow: 0 0 0 999rem rgba(0, 0, 0, .7);
}
}
</style>

View file

@ -17,7 +17,7 @@ const toaster = requireInjection(MessageToasterKey)
const dialog = requireInjection(DialogBoxKey)
eventBus.on({
'PLAYLIST_DELETE': async (playlist: Playlist) => {
PLAYLIST_DELETE: async (playlist: Playlist) => {
if (await dialog.value.confirm(`Delete the playlist "${playlist.name}"?`)) {
await playlistStore.delete(playlist)
toaster.value.success(`Playlist "${playlist.name}" deleted.`)
@ -25,7 +25,7 @@ eventBus.on({
}
},
'PLAYLIST_FOLDER_DELETE': async (folder: PlaylistFolder) => {
PLAYLIST_FOLDER_DELETE: async (folder: PlaylistFolder) => {
if (await dialog.value.confirm(`Delete the playlist folder "${folder.name}"?`)) {
await playlistFolderStore.delete(folder)
toaster.value.success(`Playlist folder "${folder.name}" deleted.`)
@ -36,17 +36,17 @@ eventBus.on({
/**
* Log the current user out and reset the application state.
*/
'LOG_OUT': async () => {
LOG_OUT: async () => {
await userStore.logout()
authService.destroy()
forceReloadWindow()
},
'KOEL_READY': () => router.resolveRoute(),
KOEL_READY: () => router.resolveRoute(),
/**
* Hide the panel away if a main view is triggered on mobile.
*/
'LOAD_MAIN_CONTENT': () => isMobile.phone && (preferenceStore.showExtraPanel = false)
ACTIVATE_SCREEN: () => isMobile.phone && (preferenceStore.showExtraPanel = false)
})
</script>

View file

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

View file

@ -0,0 +1,62 @@
import isMobile from 'ismobilejs'
import { computed, toRef } from 'vue'
import { useAuthorization } from '@/composables/useAuthorization'
import { settingStore } from '@/stores'
import { acceptedMediaTypes, UploadFile } from '@/config'
import { uploadService } from '@/services'
import { getAllFileEntries, pluralize, requireInjection } from '@/utils'
import { ActiveScreenKey, MessageToasterKey } from '@/symbols'
import router from '@/router'
export const useUpload = () => {
const { isAdmin } = useAuthorization()
const activeScreen = requireInjection(ActiveScreenKey)
const toaster = requireInjection(MessageToasterKey)
const mediaPath = toRef(settingStore.state, 'media_path')
const mediaPathSetUp = computed(() => Boolean(mediaPath.value))
const allowsUpload = computed(() => isAdmin.value && !isMobile.any)
const fileEntryToFile = async (entry: FileSystemEntry) => new Promise<File>(resolve => entry.file(resolve))
const queueFilesForUpload = (files: Array<File>) => {
const uploadCandidates = files
.filter(file => acceptedMediaTypes.includes(file.type))
.map((file): UploadFile => ({
file,
id: `${file.name}-${file.size}`, // for simplicity, a file's identity is determined by its name and size
status: 'Ready',
name: file.name,
progress: 0
}))
uploadService.queue(uploadCandidates)
return uploadCandidates
}
const handleDropEvent = async (event: DragEvent) => {
if (!event.dataTransfer) {
return
}
const fileEntries = await getAllFileEntries(event.dataTransfer.items)
const files = await Promise.all(fileEntries.map(async entry => await fileEntryToFile(entry)))
const queuedFiles = queueFilesForUpload(files)
if (queuedFiles.length) {
toaster.value.success(`Queued ${pluralize(queuedFiles, 'file')} for upload`)
activeScreen.value === 'Upload' || router.go('upload')
} else {
toaster.value.warning('No files applicable for upload')
}
}
return {
mediaPathSetUp,
allowsUpload,
handleDropEvent,
queueFilesForUpload
}
}

View file

@ -1,6 +1,6 @@
export type EventName =
'KOEL_READY'
| 'LOAD_MAIN_CONTENT'
| 'ACTIVATE_SCREEN'
| 'LOG_OUT'
| 'TOGGLE_SIDEBAR'
| 'SHOW_OVERLAY'
@ -36,10 +36,6 @@ export type EventName =
| 'SONGS_UPDATED'
| 'SONG_QUEUED_FROM_ROUTE'
// upload-related
| 'SONG_UPLOADED'
| 'UPLOAD_QUEUE_FINISHED'
// socket events
| 'SOCKET_TOGGLE_PLAYBACK'
| 'SOCKET_TOGGLE_FAVORITE'

View file

@ -3,7 +3,7 @@ import { reactive } from 'vue'
import { UploadFile, UploadStatus } from '@/config'
import { httpService } from '@/services'
import { albumStore, overviewStore, songStore } from '@/stores'
import { eventBus, logger } from '@/utils'
import { logger } from '@/utils'
interface UploadResult {
song: Song
@ -81,8 +81,6 @@ export const uploadService = {
file.message = `Upload failed: ${error.response?.data?.message || 'Unknown error'}`
file.status = 'Errored'
this.proceed() // upload the next file
} finally {
this.checkUploadQueueStatus()
}
},
@ -102,22 +100,10 @@ export const uploadService = {
file.progress = 0
},
clear () {
this.state.files = []
},
removeFailed () {
this.state.files = this.state.files.filter(file => file.status !== 'Errored')
},
checkUploadQueueStatus () {
const uploadingFiles = this.state.files.filter(file => file.status === 'Uploading')
if (uploadingFiles.length === 0) {
eventBus.emit('UPLOAD_QUEUE_FINISHED')
}
},
getFilesByStatus (status: UploadStatus) {
return this.state.files.filter(file => file.status === status)
}

View file

@ -20,3 +20,5 @@ export const EditSongFormInitialTabKey: ReadonlyInjectionKey<Ref<EditSongFormTab
export const PlaylistKey: ReadonlyInjectionKey<Ref<Playlist>> = Symbol('Playlist')
export const PlaylistFolderKey: ReadonlyInjectionKey<Ref<PlaylistFolder>> = Symbol('PlaylistFolder')
export const UserKey: ReadonlyInjectionKey<Ref<User>> = Symbol('User')
export const ActiveScreenKey: InjectionKey<Ref<ScreenName>> = Symbol('ActiveScreen')

View file

@ -293,7 +293,7 @@ interface EqualizerPreset {
}
declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused'
declare type MainViewName =
declare type ScreenName =
| 'Home'
| 'Default'
| 'Queue'

View file

@ -10,7 +10,7 @@ export { defaultCover }
* @param view
* @param {...*} args Extra data to attach to the view.
*/
export const loadMainView = (view: MainViewName, ...args: any[]) => eventBus.emit('LOAD_MAIN_CONTENT', view, ...args)
export const loadMainView = (view: ScreenName, ...args: any[]) => eventBus.emit('ACTIVATE_SCREEN', view, ...args)
/**
* Force reloading window regardless of "Confirm before reload" setting.