koel/resources/assets/js/components/song/SongEditForm.vue

310 lines
9.8 KiB
Vue
Raw Normal View History

2022-04-15 14:24:30 +00:00
<template>
2022-04-21 09:38:24 +00:00
<div class="edit-song" data-testid="edit-song-form" tabindex="0" @keydown.esc="maybeClose">
2022-04-15 17:00:08 +00:00
<SoundBar v-if="loading"/>
2022-04-15 14:24:30 +00:00
<form v-else @submit.prevent="submit">
<header>
2022-04-21 09:38:24 +00:00
<img :src="coverUrl" alt="Album's cover" height="96" width="96">
<div class="meta">
2022-04-15 14:24:30 +00:00
<h1 :class="{ mixed: !editingOnlyOneSong }">{{ displayedTitle }}</h1>
<h2 :class="{ mixed: !allSongsAreFromSameArtist && !formData.artistName }">{{ displayedArtistName }}</h2>
<h2 :class="{ mixed: !allSongsAreInSameAlbum && !formData.albumName }">{{ displayedAlbumName }}</h2>
2022-04-21 09:38:24 +00:00
</div>
2022-04-15 14:24:30 +00:00
</header>
<div class="tabs">
<div class="clear" role="tablist">
<button
:aria-selected="currentView === 'details'"
@click.prevent="currentView = 'details'"
aria-controls="editSongPanelDetails"
id="editSongTabDetails"
role="tab"
>
Details
</button>
<button
@click.prevent="currentView = 'lyrics'"
v-if="editingOnlyOneSong"
:aria-selected="currentView === 'lyrics'"
aria-controls="editSongPanelLyrics"
id="editSongTabLyrics"
role="tab"
data-testid="edit-song-lyrics-tab"
>
Lyrics
</button>
</div>
<div class="panes">
<div
2022-04-21 09:38:24 +00:00
v-show="currentView === 'details'"
2022-04-15 14:24:30 +00:00
id="editSongPanelDetails"
2022-04-21 09:38:24 +00:00
aria-labelledby="editSongTabDetails"
2022-04-15 14:24:30 +00:00
role="tabpanel"
tabindex="0"
>
<div class="form-row" v-if="editingOnlyOneSong">
<label>Title</label>
2022-04-21 09:38:24 +00:00
<input v-model="formData.title" v-koel-focus name="title" title="Title" type="text">
2022-04-15 14:24:30 +00:00
</div>
<div class="form-row">
<label>Artist</label>
2022-04-21 09:38:24 +00:00
<input v-model="formData.artistName" :placeholder="artistNamePlaceholder" list="artistNames" type="text">
<datalist id="artistNames">
<option v-for="name in artistNames" :key="name" :value="name"></option>
</datalist>
2022-04-15 14:24:30 +00:00
</div>
<div class="form-row">
<label>Album</label>
2022-04-21 09:38:24 +00:00
<input v-model="formData.albumName" :placeholder="albumNamePlaceholder" list="albumNames" type="text">
<datalist id="albumNames">
<option v-for="name in albumNames" :key="name" :value="name"></option>
</datalist>
2022-04-15 14:24:30 +00:00
</div>
<div class="form-row">
<label class="small">
<input
2022-04-21 09:38:24 +00:00
ref="compilationStateCheckbox"
2022-04-15 14:24:30 +00:00
name="is_compilation"
2022-04-21 09:38:24 +00:00
type="checkbox"
2022-04-15 14:24:30 +00:00
@change="changeCompilationState"
/>
Album is a compilation of songs by various artists
</label>
</div>
2022-04-21 09:38:24 +00:00
<div v-if="editingOnlyOneSong" class="form-row">
2022-04-15 14:24:30 +00:00
<label>Track</label>
2022-04-21 09:38:24 +00:00
<input v-model="formData.track" name="track" pattern="\d*" title="Empty or a number" type="text">
2022-04-15 14:24:30 +00:00
</div>
</div>
<div
2022-04-21 09:38:24 +00:00
v-if="editingOnlyOneSong"
v-show="currentView === 'lyrics'"
2022-04-15 14:24:30 +00:00
id="editSongPanelLyrics"
2022-04-21 09:38:24 +00:00
aria-labelledby="editSongTabLyrics"
2022-04-15 14:24:30 +00:00
role="tabpanel"
tabindex="0"
>
<div class="form-row">
2022-04-21 09:38:24 +00:00
<textarea v-model="formData.lyrics" v-koel-focus name="lyrics" title="Lyrics"></textarea>
2022-04-15 14:24:30 +00:00
</div>
</div>
</div>
</div>
<footer>
2022-04-15 17:00:08 +00:00
<Btn type="submit">Update</Btn>
2022-04-21 09:38:24 +00:00
<Btn class="btn-cancel" white @click.prevent="maybeClose">Cancel</Btn>
2022-04-15 14:24:30 +00:00
</footer>
</form>
</div>
</template>
2022-04-15 17:00:08 +00:00
<script lang="ts" setup>
2022-04-21 09:38:24 +00:00
import { computed, defineAsyncComponent, nextTick, reactive, ref, toRef, toRefs } from 'vue'
2022-04-15 17:00:08 +00:00
import { isEqual, union } from 'lodash'
2022-04-15 14:24:30 +00:00
2022-04-21 09:38:24 +00:00
import { alerts, arrayify, br2nl, getDefaultCover } from '@/utils'
2022-04-15 14:24:30 +00:00
import { songInfo } from '@/services/info'
2022-04-15 17:00:08 +00:00
import { albumStore, artistStore, songStore } from '@/stores'
2022-04-15 14:24:30 +00:00
interface EditFormData {
title: string
albumName: string
artistName: string
lyrics: string
track: number | null
compilationState: number
}
2022-04-15 17:00:08 +00:00
type TabName = 'details' | 'lyrics'
2022-04-15 14:24:30 +00:00
const COMPILATION_STATES = {
NONE: 0, // No songs belong to a compilation album
ALL: 1, // All songs belong to compilation album(s)
2022-04-21 09:38:24 +00:00
MIXED: 2 // Some songs belong to compilation album(s)
2022-04-15 14:24:30 +00:00
}
2022-04-21 18:39:18 +00:00
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
2022-04-15 17:00:08 +00:00
const SoundBar = defineAsyncComponent(() => import('@/components/ui/sound-bar.vue'))
2022-04-15 14:24:30 +00:00
2022-04-21 09:38:24 +00:00
const props = withDefaults(defineProps<{ songs: Song[], initialTab: TabName }>(), { initialTab: 'details' })
2022-04-15 17:00:08 +00:00
const { songs, initialTab } = toRefs(props)
2022-04-15 14:24:30 +00:00
2022-04-21 09:38:24 +00:00
const compilationStateCheckbox = ref<HTMLInputElement>()
2022-04-15 17:00:08 +00:00
const mutatedSongs = ref<Song[]>([])
2022-04-21 09:38:24 +00:00
const currentView = ref<TabName>()
2022-04-15 17:00:08 +00:00
const loading = ref(true)
2022-04-21 09:38:24 +00:00
const artists = toRef(artistStore.state, 'artists')
const artistNames = computed(() => new Set(artists.value.map(artist => artist.name)))
2022-04-15 14:24:30 +00:00
2022-04-21 09:38:24 +00:00
const albums = toRef(albumStore.state, 'albums')
const albumNames = computed(() => new Set(albums.value.map(album => album.name)))
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
/**
* 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
})
2022-04-15 14:24:30 +00:00
2022-04-21 09:38:24 +00:00
let initialFormData = {}
2022-04-15 17:00:08 +00:00
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())
2022-04-21 09:38:24 +00:00
const artistNamePlaceholder = computed(() => editingOnlyOneSong.value ? 'Unknown Artist' : 'Leave unchanged')
const albumNamePlaceholder = computed(() => editingOnlyOneSong.value ? 'Unknown Album' : 'Leave unchanged')
2022-04-15 17:00:08 +00:00
const compilationState = computed(() => {
const albums = mutatedSongs.value.reduce((acc: Album[], song): Album[] => union(acc, [song.album]), [])
const compiledAlbums = albums.filter(album => album.is_compilation)
if (!compiledAlbums.length) {
formData.compilationState = COMPILATION_STATES.NONE
} else if (compiledAlbums.length === albums.length) {
formData.compilationState = COMPILATION_STATES.ALL
} else {
2022-04-21 09:38:24 +00:00
formData.compilationState = COMPILATION_STATES.MIXED
2022-04-15 17:00:08 +00:00
}
return formData.compilationState
})
const displayedTitle = computed(() => {
return editingOnlyOneSong.value ? formData.title : `${mutatedSongs.value.length} songs selected`
})
const displayedArtistName = computed(() => {
return allSongsAreFromSameArtist.value || formData.artistName ? formData.artistName : 'Mixed Artists'
})
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
const displayedAlbumName = computed(() => {
return allSongsAreInSameAlbum.value || formData.albumName ? formData.albumName : 'Mixed Albums'
})
2022-04-21 09:38:24 +00:00
const isPristine = computed(() => isEqual(formData, initialFormData))
2022-04-15 17:00:08 +00:00
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()
2022-04-21 09:38:24 +00:00
const checkbox = compilationStateCheckbox.value!
checkbox.checked = compilationState.value === COMPILATION_STATES.ALL
checkbox.indeterminate = compilationState.value === COMPILATION_STATES.MIXED
2022-04-15 17:00:08 +00:00
}
const open = async () => {
2022-04-20 09:37:22 +00:00
mutatedSongs.value = arrayify(songs.value)
2022-04-21 09:38:24 +00:00
currentView.value = initialTab.value
2022-04-15 17:00:08 +00:00
const firstSong = mutatedSongs.value[0]
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
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 (!firstSong.infoRetrieved) {
loading.value = true
2022-04-15 14:24:30 +00:00
try {
2022-04-15 17:00:08 +00:00
await songInfo.fetch(firstSong)
formData.lyrics = br2nl(firstSong.lyrics)
formData.track = firstSong.track || null
} catch (e) {
console.error(e)
2022-04-15 14:24:30 +00:00
} finally {
2022-04-15 17:00:08 +00:00
loading.value = false
await initCompilationStateCheckbox()
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
} else {
loading.value = false
formData.lyrics = br2nl(firstSong.lyrics)
formData.track = firstSong.track || null
await initCompilationStateCheckbox()
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
} else {
formData.albumName = allSongsAreInSameAlbum.value ? firstSong.album.name : ''
formData.artistName = allSongsAreFromSameArtist.value ? firstSong.artist.name : ''
loading.value = false
await initCompilationStateCheckbox()
}
2022-04-21 09:38:24 +00:00
initialFormData = Object.assign(formData)
2022-04-15 17:00:08 +00:00
}
/**
* 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 = () => {
2022-04-21 09:38:24 +00:00
formData.compilationState = compilationStateCheckbox.value!.checked ? COMPILATION_STATES.ALL : COMPILATION_STATES.NONE
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
const emit = defineEmits(['close'])
const close = () => emit('close')
const maybeClose = () => {
if (isPristine.value) {
close()
return
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
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()
2022-04-15 14:24:30 +00:00
</script>
<style lang="scss" scoped>
form {
.tabs {
padding: 0;
}
> header {
img {
flex: 0 0 96px;
}
.meta {
flex: 1;
padding-left: 1rem;
.mixed {
opacity: .5;
}
}
}
}
</style>