feat: add upload logic
This commit is contained in:
parent
ac74380986
commit
fc13696d6f
|
@ -0,0 +1 @@
|
|||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'module' })
|
|
@ -17,10 +17,11 @@
|
|||
"postinstall": "yarn run fix-fomantic-css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@funkwhale/ui": "0.2.2",
|
||||
"@sentry/tracing": "7.47.0",
|
||||
"@sentry/vue": "7.47.0",
|
||||
"@types/jsmediatags": "3.9.6",
|
||||
"@vue/runtime-core": "3.3.11",
|
||||
"@vueuse/components": "10.6.1",
|
||||
"@vueuse/core": "10.3.0",
|
||||
"@vueuse/integrations": "10.3.0",
|
||||
"@vueuse/math": "10.3.0",
|
||||
|
@ -34,9 +35,13 @@
|
|||
"focus-trap": "7.2.0",
|
||||
"fomantic-ui-css": "2.9.3",
|
||||
"idb-keyval": "6.2.1",
|
||||
"jsmediatags": "3.9.7",
|
||||
"lodash-es": "4.17.21",
|
||||
"lru-cache": "7.14.1",
|
||||
"moment": "2.29.4",
|
||||
"music-metadata-browser": "2.5.10",
|
||||
"nanoid": "5.0.4",
|
||||
"pinia": "2.1.7",
|
||||
"showdown": "2.1.0",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"standardized-audio-context": "25.3.60",
|
||||
|
@ -57,6 +62,7 @@
|
|||
"vuex-router-sync": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "4.1.1",
|
||||
"@intlify/eslint-plugin-vue-i18n": "2.0.0",
|
||||
"@intlify/unplugin-vue-i18n": "2.0.0",
|
||||
"@types/diff": "5.0.9",
|
||||
|
@ -96,7 +102,8 @@
|
|||
"typescript": "4.9.5",
|
||||
"unplugin-vue-macros": "2.4.6",
|
||||
"utility-types": "3.10.0",
|
||||
"vite": "4.3.5",
|
||||
"vite": "5.0.4",
|
||||
"vite-plugin-node-polyfills": "0.17.0",
|
||||
"vite-plugin-pwa": "0.14.4",
|
||||
"vitest": "0.25.8",
|
||||
"vue-tsc": "1.6.5",
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, readonly, ref } from 'vue'
|
||||
import { whenever, useWebWorker } from '@vueuse/core'
|
||||
import { nanoid } from 'nanoid'
|
||||
import axios from 'axios'
|
||||
import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker'
|
||||
|
||||
interface UploadQueueEntry {
|
||||
id: string
|
||||
file: File
|
||||
progress: number
|
||||
metadata?: ICommonTagsResult
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
export const useUploadsStore = defineStore('uploads', () => {
|
||||
const { post: parseMetadata, data, worker } = useWebWorker(FileMetadataParserWorker)
|
||||
whenever(data, (data) => {
|
||||
if (data.status !== 'success') {
|
||||
const id = data.id as string
|
||||
const metadata = data.metadata as IMetadata
|
||||
const coverUrl = data.coverUrl as string
|
||||
|
||||
uploadQueue[id].metadata = metadata
|
||||
uploadQueue[id].coverUrl = coverUrl
|
||||
} else {
|
||||
logger.warn('Failed to parse metadata for file', )
|
||||
logger.warn(data.error)
|
||||
}
|
||||
})
|
||||
|
||||
const upload = async (entry: UploadQueueEntry) => {
|
||||
const body = new FormData()
|
||||
body.append('file', entry.file)
|
||||
|
||||
const uploadProgress = ref(0)
|
||||
await axios.post('https://httpbin.org/post', body, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (e) => {
|
||||
entry.progress = Math.round(e.loaded / e.total * 100)
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Handle failure with a try/catch block
|
||||
|
||||
// Move to the next upload
|
||||
currentIndex.value += 1
|
||||
}
|
||||
|
||||
const queueUpload = (file: File) => {
|
||||
let i = uploadQueue.push({
|
||||
id: nanoid(),
|
||||
file,
|
||||
progress: 0,
|
||||
metadata: undefined,
|
||||
coverUrl: undefined
|
||||
}) - 1
|
||||
|
||||
retrieveMetadata(i)
|
||||
}
|
||||
|
||||
const retrieveMetadata = async (i: number) => {
|
||||
// TODO: Handle failure with a try/catch block
|
||||
parseMetadata({
|
||||
id: i,
|
||||
file: uploadQueue[i].file
|
||||
})
|
||||
}
|
||||
|
||||
const uploadQueue: UploadQueueEntry[] = reactive([])
|
||||
const currentIndex = ref(0)
|
||||
const currentUpload = computed(() => uploadQueue[currentIndex.value])
|
||||
const isUploading = computed(() => !!currentUpload.value)
|
||||
|
||||
// Upload the file whenever it is available
|
||||
whenever(currentUpload, (entry) => upload(entry))
|
||||
|
||||
// Prevent the user from leaving the page while uploading
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (isUploading.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'The upload is still in progress. Are you sure you want to leave?'
|
||||
}
|
||||
})
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
isUploading,
|
||||
queueUpload,
|
||||
currentUpload,
|
||||
queue: readonly(uploadQueue)
|
||||
}
|
||||
})
|
|
@ -130,3 +130,8 @@ store.dispatch('auth/fetchUser')
|
|||
<shortcuts-modal v-model:show="showShortcutsModal" />
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
html, body {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,10 +4,14 @@ import store, { key } from '~/store'
|
|||
import router from '~/router'
|
||||
|
||||
import { createApp, defineAsyncComponent, h } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import useTheme from '~/composables/useTheme'
|
||||
|
||||
import Funkwhale from '@funkwhale/ui'
|
||||
import '@funkwhale/ui/style.css'
|
||||
|
||||
import '~/style/_main.scss'
|
||||
|
||||
import '~/api'
|
||||
|
@ -35,8 +39,12 @@ const app = createApp({
|
|||
}
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(store, key)
|
||||
app.use(Funkwhale)
|
||||
|
||||
const modules: Record<string | 'axios', { install?: InitModule }> = import.meta.glob('./init/*.ts', { eager: true })
|
||||
const moduleContext: InitModuleContext = {
|
||||
|
|
|
@ -7,9 +7,11 @@ import manage from './manage'
|
|||
import store from '~/store'
|
||||
import auth from './auth'
|
||||
import user from './user'
|
||||
import ui from './ui'
|
||||
import { requireLoggedIn } from '~/router/guards'
|
||||
|
||||
export default [
|
||||
...ui,
|
||||
{
|
||||
path: '/',
|
||||
name: 'index',
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/ui',
|
||||
name: 'ui',
|
||||
component: () => import('~/ui/layouts/constrained.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'upload',
|
||||
name: 'ui.upload',
|
||||
component: () => import('~/ui/pages/upload.vue'),
|
||||
}
|
||||
]
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,65 @@
|
|||
// TODO: use when Firefox issue is resolved, see: https://github.com/Borewit/music-metadata-browser/issues/948
|
||||
// import * as Metadata from 'music-metadata-browser'
|
||||
// import type { ICommonTagsResult } from 'music-metadata-browser'
|
||||
//
|
||||
// export type Tags = ICommonTagsResult
|
||||
//
|
||||
// export const getCoverUrl = async (tags: ICommonTagsResult[] | undefined): Promise<string | undefined> => {
|
||||
// if (pictures.length === 0) return undefined
|
||||
//
|
||||
// const picture = Metadata.selectCover(pictures)
|
||||
//
|
||||
// return await new Promise((resolve, reject) => {
|
||||
// const reader = Object.assign(new FileReader(), {
|
||||
// onload: () => resolve(reader.result as string),
|
||||
// onerror: () => reject(reader.error)
|
||||
// })
|
||||
//
|
||||
// reader.readAsDataURL(new File([picture.data], "", { type: picture.type }))
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// export const getTags = async (file: File) => {
|
||||
// return Metadata.parseBlob(file).then(metadata => metadata.common)
|
||||
// }
|
||||
|
||||
import * as jsmediaTags from 'jsmediatags/dist/jsmediatags.min.js'
|
||||
import type { ShortcutTags } from 'jsmediatags'
|
||||
|
||||
const REQUIRED_TAGS = ['title', 'artist', 'album']
|
||||
|
||||
export type Tags = ShortcutTags
|
||||
|
||||
export const getCoverUrl = async (tags: Tags): Promise<string | undefined> => {
|
||||
if (!tags.picture) return undefined
|
||||
const { picture } = tags
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = Object.assign(new FileReader(), {
|
||||
onload: () => resolve(reader.result as string),
|
||||
onerror: () => reject(reader.error)
|
||||
})
|
||||
|
||||
reader.readAsDataURL(new File([picture.data], "", { type: picture.type }))
|
||||
})
|
||||
}
|
||||
|
||||
export const getTags = async (file: File) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
jsmediaTags.read(file, {
|
||||
onSuccess: ({ tags }) => {
|
||||
if (tags.picture?.data) {
|
||||
tags.picture.data = new Uint8Array(tags.picture.data)
|
||||
}
|
||||
|
||||
const missingTags = REQUIRED_TAGS.filter(tag => !tags[tag])
|
||||
if (missingTags.length > 0) {
|
||||
return reject(new Error(`Missing tags: ${missingTags.join(', ')}`))
|
||||
}
|
||||
|
||||
resolve(tags)
|
||||
},
|
||||
onError: (error) => reject(error)
|
||||
})
|
||||
})
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="main pusher">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
main {
|
||||
padding: 56px 48px;
|
||||
font-size: 16px;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="main pusher">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
main {
|
||||
padding: 56px 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,472 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { whenever, computedAsync, useWebWorkerFn } from '@vueuse/core'
|
||||
import { UseTimeAgo } from '@vueuse/components'
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useUploadsStore } from '~/ui/stores/upload'
|
||||
|
||||
const filesystemStats = reactive({
|
||||
total: 10737418240,
|
||||
used: 3e9,
|
||||
})
|
||||
|
||||
const filesystemProgress = computed(() => {
|
||||
if (filesystemStats.used === 0) return 0
|
||||
return filesystemStats.used / filesystemStats.total * 100
|
||||
})
|
||||
|
||||
const bytesToHumanSize = (bytes: number) => {
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
if (bytes === 0) return '0 B'
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
if (i === 0) return `${bytes} ${sizes[i]}`
|
||||
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Music library',
|
||||
icon: 'headphones',
|
||||
description: 'Host music you listen to.',
|
||||
},
|
||||
{
|
||||
label: 'Music channel',
|
||||
icon: 'music-note-beamed',
|
||||
description: 'Publish music you make.'
|
||||
},
|
||||
{
|
||||
label: 'Podcast channel',
|
||||
icon: 'mic',
|
||||
description: 'Publish podcast you make.',
|
||||
},
|
||||
]
|
||||
|
||||
const currentTab = ref(tabs[0].label)
|
||||
|
||||
|
||||
// Modals
|
||||
const libraryOpen = ref(false)
|
||||
const libraryModalAlertOpen = ref(true)
|
||||
|
||||
// Server import
|
||||
const serverPath = ref('/srv/funkwhale/data/music')
|
||||
|
||||
// Upload
|
||||
const files = ref<File[]>([])
|
||||
|
||||
const combinedFileSize = computed(() => bytesToHumanSize(
|
||||
files.value.reduce((acc, file) => acc + file.size, 0)
|
||||
))
|
||||
|
||||
const uploads = useUploadsStore()
|
||||
const processFiles = (fileList: FileList) => {
|
||||
console.log('processFiles', fileList)
|
||||
// NOTE: Append fileList elements in reverse order so they appear in the UI in the order, user selected them
|
||||
for (const file of Array.from(fileList).reverse()) {
|
||||
files.value.push(file)
|
||||
}
|
||||
|
||||
for (const file of fileList) {
|
||||
uploads.queueUpload(file)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<h1>Upload</h1>
|
||||
<div class="flex-spacer" />
|
||||
|
||||
<div class="filesystem-stats">
|
||||
<div class="filesystem-stats--progress" :style="`--progress: ${filesystemProgress}%`" />
|
||||
<div class="flex items-center">
|
||||
{{ bytesToHumanSize(filesystemStats.total) }} total
|
||||
|
||||
<div class="filesystem-stats--label full" />
|
||||
{{ bytesToHumanSize(filesystemStats.used) }} used
|
||||
|
||||
<div class="filesystem-stats--label" />
|
||||
{{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p> Select a destination for your audio files: </p>
|
||||
|
||||
<div class="flex space-between">
|
||||
<FwCard
|
||||
v-for="tab in tabs" :key="tab.label"
|
||||
:title="tab.label"
|
||||
:class="currentTab === tab.label && 'active'"
|
||||
@click="currentTab = tab.label"
|
||||
>
|
||||
<template #image>
|
||||
<div class="image-icon">
|
||||
<Icon :icon="'bi:' + tab.icon" />
|
||||
</div>
|
||||
</template>
|
||||
{{ tab.description }}
|
||||
<div class="radio-button" />
|
||||
</FwCard>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FwButton @click="libraryOpen = true">Open library</FwButton>
|
||||
<FwModal v-model="libraryOpen" title="Upload music to library">
|
||||
<template #alert v-if="libraryModalAlertOpen">
|
||||
<FwAlert>
|
||||
Before uploading, please ensure your files are tagged properly.
|
||||
We recommend using Picard for that purpose.
|
||||
|
||||
<template #actions>
|
||||
<FwButton @click="libraryModalAlertOpen = false">Got it</FwButton>
|
||||
</template>
|
||||
</FwAlert>
|
||||
</template>
|
||||
|
||||
<FwFileInput
|
||||
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a']"
|
||||
multiple
|
||||
auto-reset
|
||||
@files="processFiles"
|
||||
/>
|
||||
|
||||
<!-- Upload path -->
|
||||
<div v-if="files && files.length > 0">
|
||||
<div class="list-header">
|
||||
<div class="file-count">
|
||||
{{ files.length }} files, {{ combinedFileSize }}
|
||||
</div>
|
||||
|
||||
<FwButton color="secondary">All</FwButton>
|
||||
<FwButton color="secondary">Upload time</FwButton>
|
||||
</div>
|
||||
|
||||
<div class="file-list">
|
||||
<div v-for="track in uploads.queue" :key="track.id" class="list-track">
|
||||
<div class="track-cover">
|
||||
<Transition mode="out-in">
|
||||
<img
|
||||
v-if="track.coverUrl"
|
||||
:src="track.coverUrl"
|
||||
/>
|
||||
<Icon v-else icon="bi:disc" />
|
||||
</Transition>
|
||||
</div>
|
||||
<Transition mode="out-in">
|
||||
<div v-if="track.tags" class="track-data">
|
||||
<div class="track-title">{{ track.tags.title }}</div>
|
||||
{{ track.tags.artist }} / {{ track.tags.album }}
|
||||
</div>
|
||||
<div v-else class="track-title">
|
||||
{{ track.file.name }}
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="upload-state">
|
||||
<FwPill :color="track.failReason ? 'red' : track.importedAt ? 'blue' : 'secondary'">
|
||||
{{
|
||||
track.failReason
|
||||
? 'failed'
|
||||
: track.importedAt
|
||||
? 'imported'
|
||||
: track.progress === 100
|
||||
? 'processing'
|
||||
: 'uploading'
|
||||
}}
|
||||
</FwPill>
|
||||
<div v-if="track.importedAt" class="track-progress">
|
||||
<UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo>
|
||||
</div>
|
||||
<div v-else class="track-progress">
|
||||
{{ bytesToHumanSize(track.file.size / 100 * track.progress) }}
|
||||
/ {{ bytesToHumanSize(track.file.size) }}
|
||||
⋅ {{ track.progress }}%
|
||||
</div>
|
||||
</div>
|
||||
<FwButton
|
||||
icon="bi:chevron-right"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
:is-loading="!track.importedAt"
|
||||
:disabled="!track.importedAt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Import path -->
|
||||
<template v-else>
|
||||
<label>Import from server directory</label>
|
||||
<div class="flex items-center">
|
||||
<FwInput
|
||||
v-model="serverPath"
|
||||
class="w-full mr-4"
|
||||
/>
|
||||
<FwButton color="secondary">
|
||||
Import
|
||||
</FwButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<FwButton @click="libraryOpen = false" color="secondary">Cancel</FwButton>
|
||||
<FwButton @click="libraryOpen = false">
|
||||
{{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
|
||||
</FwButton>
|
||||
</template>
|
||||
</FwModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 900;
|
||||
font-family: Lato, sans-serif;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
&:not(.flex-col) {
|
||||
.funkwhale.button {
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filesystem-stats {
|
||||
color: var(--fw-gray-700);
|
||||
> .flex {
|
||||
padding: 1ch;
|
||||
}
|
||||
}
|
||||
|
||||
.filesystem-stats--progress {
|
||||
height: 20px;
|
||||
border: 1px solid var(--fw-gray-600);
|
||||
border-radius: 100vw;
|
||||
padding: 4px 3px;
|
||||
}
|
||||
|
||||
.filesystem-stats--label.full::after,
|
||||
.filesystem-stats--progress::after {
|
||||
content: '';
|
||||
display: block;
|
||||
background: var(--fw-gray-600);
|
||||
border-radius: 100vw;
|
||||
min-width: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: var(--progress, 100);
|
||||
transition: max-width 0.2s ease-out;
|
||||
}
|
||||
|
||||
.filesystem-stats--label {
|
||||
height: 14px;
|
||||
border: 1px solid var(--fw-gray-600);
|
||||
border-radius: 100vw;
|
||||
padding: 2px 3px;
|
||||
width: 2em;
|
||||
margin: 0 1ch 0 3ch;
|
||||
}
|
||||
|
||||
.funkwhale.card {
|
||||
--fw-card-width: 12.5rem;
|
||||
--fw-border-radius: 1rem;
|
||||
padding: 1.3rem 2rem;
|
||||
box-shadow: 0 2px 4px 2px rgba(#000, 0.1);
|
||||
user-select: none;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
:deep(.card-content) {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-icon {
|
||||
background: var(--fw-pastel-blue-1);
|
||||
color: var(--fw-pastel-blue-3);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-size: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.funkwhale.card {
|
||||
margin-bottom: 2rem;
|
||||
transition: margin-bottom 0.2s ease;
|
||||
|
||||
.radio-button {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border: 1px solid var(--fw-gray-700);
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
margin: 0.5rem auto 0;
|
||||
transition: margin-bottom 0.2s ease;
|
||||
}
|
||||
|
||||
&.active {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.radio-button {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
background: var(--fw-blue-400);
|
||||
border: inherit;
|
||||
border-radius: inherit;
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mr-4 {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
margin: 2rem 0 0.75rem;
|
||||
display: block;
|
||||
color: var(--fw-gray-900);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 2rem 0 1rem;
|
||||
|
||||
> .file-count {
|
||||
margin-right: auto;
|
||||
color: var(--fw-gray-600);
|
||||
font-weight: 900;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .5rem 0;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--fw-gray-200);
|
||||
}
|
||||
|
||||
> .track-cover {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
background: var(--fw-gray-200);
|
||||
color: var(--fw-gray-500);
|
||||
font-size: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.v-enter-active,
|
||||
&.v-leave-active {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&.v-enter-from,
|
||||
&.v-leave-to {
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.track-data,
|
||||
.track-title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--fw-gray-960);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&.v-enter-active,
|
||||
&.v-leave-active {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&.v-enter-from {
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.v-leave-to {
|
||||
transform: translateY(-1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.track-progress {
|
||||
font-size: 0.875rem;
|
||||
color: var(--fw-gray-600);
|
||||
}
|
||||
|
||||
.upload-state {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
padding-left: 1ch;
|
||||
margin-right: 0.5rem;
|
||||
|
||||
:deep(.funkwhale.pill) {
|
||||
margin-right: -0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.funkwhale.button):not(:hover) {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
|
@ -0,0 +1,134 @@
|
|||
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { computed, reactive, readonly, ref, watchEffect, markRaw, toRaw } from 'vue'
|
||||
import { whenever, useWebWorker } from '@vueuse/core'
|
||||
import axios from 'axios'
|
||||
import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker'
|
||||
|
||||
import { getCoverUrl, getTags, type Tags } from '~/ui/composables/metadata'
|
||||
|
||||
interface UploadQueueEntry {
|
||||
id: string
|
||||
file: File
|
||||
|
||||
// Upload info
|
||||
abortController: AbortController
|
||||
progress: number
|
||||
|
||||
// Import info
|
||||
importedAt?: Date
|
||||
|
||||
// Failure info
|
||||
failReason?: 'missing-tags' | 'upload-failed'
|
||||
error?: Error
|
||||
|
||||
// Metadata
|
||||
tags?: Tags
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
export const useUploadsStore = defineStore('uploads', () => {
|
||||
// Tag extraction with a Web Worker
|
||||
const { post: retrieveMetadata, data: workerData, worker } = useWebWorker(FileMetadataParserWorker)
|
||||
whenever(workerData, (reactiveData) => {
|
||||
const data = toRaw(reactiveData)
|
||||
if (data.status === 'success') {
|
||||
const id = data.id as number
|
||||
const tags = data.tags as Tags
|
||||
const coverUrl = data.coverUrl as string
|
||||
|
||||
uploadQueue[id].tags = markRaw(tags)
|
||||
uploadQueue[id].coverUrl = coverUrl
|
||||
} else {
|
||||
const id = data.id as number
|
||||
const entry = uploadQueue[id]
|
||||
|
||||
entry.error = data.error
|
||||
entry.failReason = 'missing-tags'
|
||||
entry.importedAt = new Date()
|
||||
entry.abortController.abort()
|
||||
|
||||
console.warn(`Failed to parse metadata for file ${entry.file.name}:`, data.error)
|
||||
}
|
||||
})
|
||||
|
||||
const upload = async (entry: UploadQueueEntry) => {
|
||||
const body = new FormData()
|
||||
body.append('file', entry.file)
|
||||
|
||||
const uploadProgress = ref(0)
|
||||
|
||||
await axios.post('https://httpbin.org/post', body, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
signal: entry.abortController.signal,
|
||||
onUploadProgress: (e) => {
|
||||
entry.progress = Math.floor(e.loaded / e.total * 100)
|
||||
|
||||
if (entry.progress === 100) {
|
||||
console.log(`[${entry.id}] upload complete!`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Handle failure with a try/catch block
|
||||
|
||||
console.log(`[${entry.id}] import complete!`)
|
||||
entry.importedAt = new Date()
|
||||
}
|
||||
|
||||
const queueUpload = async (file: File) => {
|
||||
let id = uploadQueue.length
|
||||
uploadQueue.push({
|
||||
id,
|
||||
file,
|
||||
progress: 0,
|
||||
abortController: new AbortController()
|
||||
})
|
||||
|
||||
console.log('sending message to worker', id)
|
||||
retrieveMetadata({ id, file })
|
||||
}
|
||||
|
||||
const uploadQueue: UploadQueueEntry[] = reactive([])
|
||||
const currentIndex = ref(0)
|
||||
const currentUpload = computed(() => uploadQueue[currentIndex.value])
|
||||
const isUploading = computed(() => !!currentUpload.value)
|
||||
|
||||
// Upload the file whenever it is available
|
||||
whenever(currentUpload, (entry) => upload(entry).catch((error) => {
|
||||
// The tags were missing, so we have cancelled the upload
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return
|
||||
}
|
||||
|
||||
entry.error = error
|
||||
entry.failReason = 'upload-failed'
|
||||
entry.importedAt = new Date()
|
||||
console.error(error)
|
||||
}).finally(() => {
|
||||
// Move to the next upload despite failing
|
||||
currentIndex.value += 1
|
||||
}))
|
||||
|
||||
// Prevent the user from leaving the page while uploading
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (isUploading.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'The upload is still in progress. Are you sure you want to leave?'
|
||||
}
|
||||
})
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
isUploading,
|
||||
queueUpload,
|
||||
currentUpload,
|
||||
queue: readonly(uploadQueue)
|
||||
}
|
||||
})
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useUploadsStore, import.meta.hot))
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/// <reference lib="webworker" />
|
||||
|
||||
import { getCoverUrl, getTags } from '~/ui/composables/metadata'
|
||||
|
||||
|
||||
const parse = async (id: number, file: File) => {
|
||||
try {
|
||||
console.log(`[${id}] parsing...`)
|
||||
const tags = await getTags(file)
|
||||
console.log(`[${id}] tags:`, tags)
|
||||
const coverUrl = await getCoverUrl(tags)
|
||||
|
||||
postMessage({
|
||||
id,
|
||||
status: 'success',
|
||||
tags,
|
||||
coverUrl
|
||||
})
|
||||
} catch (error) {
|
||||
postMessage({
|
||||
id,
|
||||
status: 'failure',
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const queue = []
|
||||
let queuePromise = Promise.resolve()
|
||||
addEventListener('message', async (event) => {
|
||||
const id = event.data.id as number
|
||||
const file = event.data.file as File
|
||||
parse(id, file)
|
||||
})
|
|
@ -8,6 +8,7 @@ import manifest from './pwa-manifest.json'
|
|||
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import VueMacros from 'unplugin-vue-macros/vite'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
const port = +(process.env.VUE_PORT ?? 8080)
|
||||
|
||||
|
@ -43,7 +44,11 @@ export default defineConfig(({ mode }) => ({
|
|||
navigateFallback: 'index.html'
|
||||
},
|
||||
manifest
|
||||
})
|
||||
}),
|
||||
|
||||
// https://github.com/davidmyersdev/vite-plugin-node-polyfills
|
||||
// see: https://github.com/Borewit/music-metadata-browser/issues/836
|
||||
nodePolyfills()
|
||||
],
|
||||
server: {
|
||||
port
|
||||
|
|
1160
front/yarn.lock
1160
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue