Move Waveform editor to Vue 3 style.

This commit is contained in:
Buster Neece 2022-12-29 09:33:05 -06:00
parent 3ed6a966b1
commit 59cbfbfc66
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
3 changed files with 384 additions and 404 deletions

View File

@ -8,8 +8,8 @@
</div>
</b-form-group>
</b-row>
<b-row class="mt-3">
<b-col md="8">
<b-row class="mt-3 align-items-center">
<b-col md="7">
<div class="d-flex">
<div class="flex-shrink-0">
<label for="waveform-zoom">
@ -17,26 +17,27 @@
</label>
</div>
<div class="flex-fill mx-3">
<b-form-input id="waveform-zoom" v-model="zoom" type="range" min="0" max="256" class="w-100"></b-form-input>
<b-form-input id="waveform-zoom" v-model.number="zoom" type="range" min="0" max="256"
class="w-100"></b-form-input>
</div>
</div>
</b-col>
<b-col md="4">
<b-col md="5">
<div class="inline-volume-controls d-flex align-items-center">
<div class="flex-shrink-0">
<a class="btn btn-sm btn-outline-inverse py-0 px-3" href="#" @click.prevent="volume = 0">
<a class="btn btn-sm btn-outline-inverse" href="#" @click.prevent="volume = 0"
:title="$gettext('Mute')">
<icon icon="volume_mute"></icon>
{{ $gettext('Mute') }}
</a>
</div>
<div class="flex-fill mx-1">
<input type="range" :title="langVolume" class="player-volume-range custom-range w-100" min="0" max="100"
step="1" v-model="volume">
<input type="range" :title="$gettext('Volume')" class="player-volume-range custom-range w-100"
min="0" max="100" step="1" v-model.number="volume">
</div>
<div class="flex-shrink-0">
<a class="btn btn-sm btn-outline-inverse py-0 px-3" href="#" @click.prevent="volume = 100">
<a class="btn btn-sm btn-outline-inverse" href="#" @click.prevent="volume = 100"
:title="$gettext('Full Volume')">
<icon icon="volume_up"></icon>
{{ $gettext('Full Volume') }}
</a>
</div>
</div>
@ -45,128 +46,119 @@
</b-form-group>
</template>
<script>
import WaveSurfer from 'wavesurfer.js';
<script setup>
import WS from 'wavesurfer.js';
import timeline from 'wavesurfer.js/dist/plugin/wavesurfer.timeline.js';
import regions from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js';
import getLogarithmicVolume from '~/functions/getLogarithmicVolume.js';
import store from 'store';
import Icon from './Icon';
import {useStorage} from "@vueuse/core";
import {onMounted, onUnmounted, ref, watch} from "vue";
import {useAxios} from "~/vendor/axios";
export default {
name: 'Waveform',
components: { Icon },
props: {
audioUrl: String,
waveformUrl: String
},
data () {
return {
wavesurfer: null,
zoom: 0,
volume: 0
};
},
mounted () {
this.wavesurfer = WaveSurfer.create({
backend: 'MediaElement',
container: '#waveform',
waveColor: '#2196f3',
progressColor: '#4081CF',
plugins: [
timeline.create({
container: '#waveform-timeline',
primaryColor: '#222',
secondaryColor: '#888',
primaryFontColor: '#222',
secondaryFontColor: '#888'
}),
regions.create({
regions: []
})
]
});
const props = defineProps({
audioUrl: String,
waveformUrl: String
});
this.wavesurfer.on('ready', () => {
this.$emit('ready');
});
const emit = defineEmits(['ready']);
this.axios.get(this.waveformUrl).then((resp) => {
let waveform = resp.data;
if (waveform.data) {
this.wavesurfer.load(this.audioUrl, waveform.data);
} else {
this.wavesurfer.load(this.audioUrl);
}
}).catch((err) => {
console.error(err);
this.wavesurfer.load(this.audioUrl);
});
let wavesurfer = null;
// Check webstorage for existing volume preference.
if (store.enabled && store.get('player_volume') !== undefined) {
this.volume = store.get('player_volume', 55);
const volume = useStorage('player_volume', 55);
const zoom = ref(0);
watch(zoom, (val) => {
wavesurfer?.zoom(val);
});
watch(volume, (val) => {
wavesurfer?.setVolume(getLogarithmicVolume(val));
});
const {axios} = useAxios();
onMounted(() => {
wavesurfer = WS.create({
backend: 'MediaElement',
container: '#waveform',
waveColor: '#2196f3',
progressColor: '#4081CF',
plugins: [
timeline.create({
container: '#waveform-timeline',
primaryColor: '#222',
secondaryColor: '#888',
primaryFontColor: '#222',
secondaryFontColor: '#888'
}),
regions.create({
regions: []
})
]
});
wavesurfer.on('ready', () => {
wavesurfer.setVolume(getLogarithmicVolume(volume.value));
emit('ready');
});
axios.get(props.waveformUrl).then((resp) => {
let waveformJson = resp?.data?.data ?? null;
if (waveformJson) {
wavesurfer.load(props.audioUrl, waveformJson);
} else {
wavesurfer.load(props.audioUrl);
}
},
computed: {
langVolume () {
return this.$gettext('Volume');
}
},
methods: {
play () {
if (this.wavesurfer) {
this.wavesurfer.play();
}
},
stop () {
if (this.wavesurfer) {
this.wavesurfer.pause();
}
},
getCurrentTime () {
if (this.wavesurfer) {
return this.wavesurfer.getCurrentTime();
}
},
getDuration () {
if (this.wavesurfer) {
return this.wavesurfer.getDuration();
}
},
addRegion (start, end, color) {
if (this.wavesurfer) {
this.wavesurfer.addRegion(
{
start: start,
end: end,
resize: false,
drag: false,
color: color
}
);
}
},
clearRegions () {
if (this.wavesurfer) {
this.wavesurfer.clearRegions();
}
}
},
watch: {
zoom: function (val) {
this.wavesurfer.zoom(Number(val));
},
volume: function (volume) {
this.wavesurfer.setVolume(getLogarithmicVolume(volume));
}).catch((err) => {
console.error(err);
wavesurfer.load(props.audioUrl);
});
});
if (store.enabled) {
store.set('player_volume', volume);
}
}
},
beforeDestroy () {
this.wavesurfer = null;
}
onUnmounted(() => {
wavesurfer = null;
});
const play = () => {
wavesurfer?.play();
};
const stop = () => {
wavesurfer?.pause();
};
const getCurrentTime = () => {
return wavesurfer?.getCurrentTime();
};
const getDuration = () => {
return wavesurfer?.getDuration();
}
const addRegion = (start, end, color) => {
wavesurfer?.addRegion(
{
start: start,
end: end,
resize: false,
drag: false,
color: color
}
);
};
const clearRegions = () => {
wavesurfer?.clearRegions();
}
defineExpose({
play,
stop,
getCurrentTime,
getDuration,
addRegion,
clearRegions
})
</script>

View File

@ -1,6 +1,7 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="v$.form.$invalid"
@submit="doEdit" @hidden="clearContents">
<modal-form ref="modal" :loading="loading" :title="$gettext('Edit Media')" :error="error"
:disable-save-button="v$.$invalid"
@submit="doEdit" @hidden="resetForm">
<b-tabs content-class="mt-3" pills>
<b-tab active>
@ -8,14 +9,14 @@
{{ $gettext('Basic Information') }}
</template>
<media-form-basic-info :form="v$.form"></media-form-basic-info>
<media-form-basic-info :form="v$"></media-form-basic-info>
</b-tab>
<b-tab>
<template #title>
{{ $gettext('Playlists') }}
</template>
<media-form-playlists :form="v$.form" :playlists="playlists"></media-form-playlists>
<media-form-playlists :form="v$" :playlists="playlists"></media-form-playlists>
</b-tab>
<b-tab lazy>
<template #title>
@ -30,7 +31,7 @@
{{ $gettext('Custom Fields') }}
</template>
<media-form-custom-fields :form="v$.form" :custom-fields="customFields"></media-form-custom-fields>
<media-form-custom-fields :form="v$" :custom-fields="customFields"></media-form-custom-fields>
</b-tab>
<b-tab lazy>
@ -47,14 +48,15 @@
{{ $gettext('Advanced') }}
</template>
<media-form-advanced-settings :form="v$.form" :song-length="songLength"></media-form-advanced-settings>
<media-form-advanced-settings :form="v$" :song-length="songLength"></media-form-advanced-settings>
</b-tab>
</b-tabs>
</modal-form>
</template>
<script>
<script setup>
import {required} from '@vuelidate/validators';
import _ from 'lodash';
import {defaultTo, forEach, map} from 'lodash';
import MediaFormBasicInfo from './Form/BasicInfo';
import MediaFormAlbumArt from './Form/AlbumArt';
import MediaFormCustomFields from './Form/CustomFields';
@ -62,173 +64,161 @@ import MediaFormAdvancedSettings from './Form/AdvancedSettings';
import MediaFormPlaylists from './Form/Playlists';
import MediaFormWaveformEditor from './Form/WaveformEditor';
import ModalForm from "~/components/Common/ModalForm";
import useVuelidate from "@vuelidate/core";
import {ref} from "vue";
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
import {useAxios} from "~/vendor/axios";
import {useNotify} from "~/vendor/bootstrapVue";
export default {
name: 'EditModal',
components: {
ModalForm,
MediaFormPlaylists,
MediaFormWaveformEditor,
MediaFormAdvancedSettings,
MediaFormCustomFields,
MediaFormAlbumArt,
MediaFormBasicInfo
},
setup() {
return {v$: useVuelidate()}
},
props: {
customFields: Array,
playlists: Array
},
data() {
return {
loading: true,
recordUrl: null,
error: null,
albumArtUrl: null,
waveformUrl: null,
audioUrl: null,
songLength: null,
form: {}
};
},
validations() {
let validations = {
form: {
path: {
required
},
title: {},
artist: {},
album: {},
genre: {},
lyrics: {},
isrc: {},
art: {},
amplify: {},
fade_overlap: {},
fade_in: {},
fade_out: {},
cue_in: {},
cue_out: {},
playlists: {},
custom_fields: {}
}
const props = defineProps({
customFields: Array,
playlists: Array
});
const emit = defineEmits(['relist']);
const loading = ref(true);
const error = ref(null);
const recordUrl = ref('');
const albumArtUrl = ref('');
const waveformUrl = ref('');
const audioUrl = ref('');
const songLength = ref(0);
const buildForm = () => {
let blankForm = {
path: null,
title: null,
artist: null,
album: null,
genre: null,
lyrics: null,
isrc: null,
amplify: null,
fade_overlap: null,
fade_in: null,
fade_out: null,
cue_in: null,
cue_out: null,
playlists: [],
custom_fields: {}
};
let validations = {
path: {required},
title: {},
artist: {},
album: {},
genre: {},
lyrics: {},
isrc: {},
art: {},
amplify: {},
fade_overlap: {},
fade_in: {},
fade_out: {},
cue_in: {},
cue_out: {},
playlists: {},
custom_fields: {}
};
forEach(props.customFields.slice(), (field) => {
validations.custom_fields[field.short_name] = {};
blankForm.custom_fields[field.short_name] = null;
});
return {blankForm, validations};
};
const {blankForm, validations} = buildForm();
const {form, resetForm: resetBaseForm, v$} = useVuelidateOnForm(validations, blankForm);
const resetForm = () => {
resetBaseForm();
loading.value = false;
error.value = null;
albumArtUrl.value = '';
waveformUrl.value = '';
recordUrl.value = '';
audioUrl.value = '';
};
const modal = ref(); // BModal
const close = () => {
modal.value?.hide();
};
const {axios} = useAxios();
const open = (newRecordUrl, newAlbumArtUrl, newAudioUrl, newWaveformUrl) => {
resetForm();
loading.value = true;
recordUrl.value = newRecordUrl;
albumArtUrl.value = newAlbumArtUrl;
audioUrl.value = newAudioUrl;
waveformUrl.value = newWaveformUrl;
modal.value?.show();
axios.get(newRecordUrl).then((resp) => {
let d = resp.data;
songLength.value = d.length_text;
let newForm = {
path: d.path,
title: d.title,
artist: d.artist,
album: d.album,
genre: d.genre,
lyrics: d.lyrics,
isrc: d.isrc,
amplify: d.amplify,
fade_overlap: d.fade_overlap,
fade_in: d.fade_in,
fade_out: d.fade_out,
cue_in: d.cue_in,
cue_out: d.cue_out,
playlists: map(d.playlists, 'id'),
custom_fields: {}
};
_.forEach(this.customFields.slice(), (field) => {
validations.form.custom_fields[field.short_name] = {};
forEach(props.customFields.slice(), (field) => {
newForm.custom_fields[field.short_name] = defaultTo(d.custom_fields[field.short_name], null);
});
return validations;
},
computed: {
langTitle() {
return this.$gettext('Edit Media');
}
},
methods: {
resetForm() {
this.loading = false;
this.error = null;
this.albumArtUrl = null;
this.waveformUrl = null;
this.recordUrl = null;
this.audioUrl = null;
let customFields = {};
_.forEach(this.customFields.slice(), (field) => {
customFields[field.short_name] = null;
});
this.form = {
path: null,
title: null,
artist: null,
album: null,
genre: null,
lyrics: null,
isrc: null,
amplify: null,
fade_overlap: null,
fade_in: null,
fade_out: null,
cue_in: null,
cue_out: null,
playlists: [],
custom_fields: customFields
};
},
open(recordUrl, albumArtUrl, audioUrl, waveformUrl) {
this.resetForm();
this.loading = true;
this.error = null;
this.albumArtUrl = albumArtUrl;
this.waveformUrl = waveformUrl;
this.recordUrl = recordUrl;
this.audioUrl = audioUrl;
this.$refs.modal.show();
this.axios.get(recordUrl).then((resp) => {
let d = resp.data;
this.songLength = d.length_text;
this.form = {
path: d.path,
title: d.title,
artist: d.artist,
album: d.album,
genre: d.genre,
lyrics: d.lyrics,
isrc: d.isrc,
amplify: d.amplify,
fade_overlap: d.fade_overlap,
fade_in: d.fade_in,
fade_out: d.fade_out,
cue_in: d.cue_in,
cue_out: d.cue_out,
playlists: _.map(d.playlists, 'id'),
custom_fields: {}
};
_.forEach(this.customFields.slice(), (field) => {
this.form.custom_fields[field.short_name] = _.defaultTo(d.custom_fields[field.short_name], null);
});
this.loading = false;
}).catch(() => {
this.close();
});
},
close() {
this.$refs.modal.hide();
},
clearContents() {
this.resetForm();
this.v$.$reset();
},
doEdit() {
this.v$.$touch();
if (this.v$.$errors.length > 0) {
return;
}
this.error = null;
this.axios.put(this.recordUrl, this.form).then(() => {
this.$notifySuccess();
this.$emit('relist');
this.close();
}).catch((error) => {
this.error = error.response.data.message;
});
}
}
form.value = newForm;
}).catch(() => {
close();
}).finally(() => {
loading.value = false;
});
};
const {notifySuccess} = useNotify();
const doEdit = () => {
v$.value.$touch();
if (v$.value.$errors.length > 0) {
return;
}
error.value = null;
axios.put(recordUrl.value, form.value).then(() => {
notifySuccess();
emit('relist');
close();
}).catch((error) => {
error.value = error.response.data.message;
});
};
defineExpose({
open
});
</script>

View File

@ -6,133 +6,131 @@
</p>
<b-form-group>
<waveform ref="waveform" :audio-url="audioUrl" :waveform-url="waveformUrl"
@ready="updateRegions"></waveform>
<waveform-component ref="waveform" :audio-url="audioUrl" :waveform-url="waveformUrl"
@ready="updateRegions"></waveform-component>
</b-form-group>
<b-form-group>
<b-button-group>
<b-button variant="light" @click="playAudio">
<icon icon="play_arrow"></icon>
<span class="sr-only">{{ $gettext('Play') }}</span>
</b-button>
<b-button variant="dark" @click="stopAudio">
<icon icon="stop"></icon>
<span class="sr-only">{{ $gettext('Stop') }}</span>
</b-button>
</b-button-group>
<b-button-group>
<b-button variant="primary" @click="setCueIn">
{{ $gettext('Set Cue In') }}
</b-button>
<div class="buttons">
<b-button-group size="sm">
<b-button variant="light" @click="playAudio" :title="$gettext('Play')" size="sm">
<icon icon="play_arrow"></icon>
</b-button>
<b-button variant="dark" @click="stopAudio" :title="$gettext('Stop')" size="sm">
<icon icon="stop"></icon>
</b-button>
</b-button-group>
<b-button-group size="sm">
<b-button variant="primary" @click="setCueIn" size="sm">
{{ $gettext('Set Cue In') }}
</b-button>
<b-button variant="primary" @click="setCueOut" size="sm">
{{ $gettext('Set Cue Out') }}
</b-button>
</b-button-group>
<b-button-group size="sm">
<b-button variant="warning" @click="setFadeOverlap" size="sm">
{{ $gettext('Set Overlap') }}
</b-button>
</b-button-group>
<b-button-group size="sm">
<b-button variant="danger" @click="setFadeIn" size="sm">
{{ $gettext('Set Fade In') }}
</b-button>
<b-button variant="primary" @click="setCueOut">
{{ $gettext('Set Cue Out') }}
</b-button>
</b-button-group>
<b-button-group>
<b-button variant="warning" @click="setFadeOverlap">
{{ $gettext('Set Overlap') }}
</b-button>
</b-button-group>
<b-button-group>
<b-button variant="danger" @click="setFadeIn">
{{ $gettext('Set Fade In') }}
</b-button>
<b-button variant="danger" @click="setFadeOut">
{{ $gettext('Set Fade Out') }}
</b-button>
</b-button-group>
<b-button variant="danger" @click="setFadeOut" size="sm">
{{ $gettext('Set Fade Out') }}
</b-button>
</b-button-group>
</div>
</b-form-group>
</template>
<script>
import Waveform from '~/components/Common/Waveform';
<script setup>
import WaveformComponent from '~/components/Common/Waveform';
import Icon from '~/components/Common/Icon';
import {ref} from "vue";
export default {
name: 'MediaFormWaveformEditor',
components: {Icon, Waveform},
props: {
form: Object,
audioUrl: String,
waveformUrl: String
},
methods: {
playAudio() {
this.$refs.waveform.play();
},
stopAudio() {
this.$refs.waveform.stop();
},
setCueIn() {
let currentTime = this.$refs.waveform.getCurrentTime();
const props = defineProps({
form: Object,
audioUrl: String,
waveformUrl: String
});
this.form.cue_in = Math.round((currentTime) * 10) / 10;
const waveform = ref(); // Waveform
this.updateRegions();
},
setCueOut() {
let currentTime = this.$refs.waveform.getCurrentTime();
const playAudio = () => {
waveform.value?.play();
};
this.form.cue_out = Math.round((currentTime) * 10) / 10;
const stopAudio = () => {
waveform.value?.stop();
};
this.updateRegions();
},
setFadeOverlap() {
let duration = this.$refs.waveform.getDuration();
let cue_out = this.form.cue_out || duration;
let currentTime = this.$refs.waveform.getCurrentTime();
const updateRegions = () => {
let duration = waveform.value?.getDuration();
this.form.fade_overlap = Math.round((cue_out - currentTime) * 10) / 10;
let cue_in = props.form.cue_in ?? 0;
let cue_out = props.form.cue_out ?? duration;
let fade_overlap = props.form.fade_overlap ?? 0;
let fade_in = props.form.fade_in ?? 0;
let fade_out = props.form.fade_out ?? 0;
this.updateRegions();
},
setFadeIn() {
let currentTime = this.$refs.waveform.getCurrentTime();
let cue_in = this.form.cue_in || 0;
waveform.value?.clearRegions();
this.form.fade_in = Math.round((currentTime - cue_in) * 10) / 10;
// Create cue region
waveform.value?.addRegion(cue_in, cue_out, 'hsla(207,90%,54%,0.4)');
this.updateRegions();
},
setFadeOut() {
let currentTime = this.$refs.waveform.getCurrentTime();
let duration = this.$refs.waveform.getDuration();
let cue_out = this.form.cue_out || duration;
// Create overlap region
if (fade_overlap > cue_in) {
waveform.value?.addRegion(cue_out - fade_overlap, cue_out, 'hsla(29,100%,48%,0.4)');
}
this.form.fade_out = Math.round((cue_out - currentTime) * 10) / 10;
this.updateRegions();
},
updateRegions() {
let duration = this.$refs.waveform.getDuration();
let cue_in = this.form.cue_in || 0;
let cue_out = this.form.cue_out || duration;
let fade_overlap = this.form.fade_overlap;
let fade_in = this.form.fade_in;
let fade_out = this.form.fade_out;
this.$refs.waveform.clearRegions();
// Create cue region
this.$refs.waveform.addRegion(cue_in, cue_out, 'hsla(207,90%,54%,0.4)');
// Create overlap region
if (fade_overlap > cue_in) {
this.$refs.waveform.addRegion(cue_out - fade_overlap, cue_out, 'hsla(29,100%,48%,0.4)');
}
// Create fade regions
if (fade_in) {
this.$refs.waveform.addRegion(cue_in, fade_in + cue_in, 'hsla(351,100%,48%,0.4)');
}
if (fade_out) {
this.$refs.waveform.addRegion(cue_out - fade_out, cue_out, 'hsla(351,100%,48%,0.4)');
}
}
// Create fade regions
if (fade_in) {
waveform.value?.addRegion(cue_in, fade_in + cue_in, 'hsla(351,100%,48%,0.4)');
}
if (fade_out) {
waveform.value?.addRegion(cue_out - fade_out, cue_out, 'hsla(351,100%,48%,0.4)');
}
};
const setCueIn = () => {
let currentTime = waveform.value?.getCurrentTime();
props.form.cue_in = Math.round((currentTime) * 10) / 10;
updateRegions();
};
const setCueOut = () => {
let currentTime = waveform.value?.getCurrentTime();
props.form.cue_out = Math.round((currentTime) * 10) / 10;
updateRegions();
};
const setFadeOverlap = () => {
let duration = waveform.value?.getDuration();
let currentTime = waveform.value?.getCurrentTime();
let cue_out = form.value?.cue_out ?? duration;
props.form.fade_overlap = Math.round((cue_out - currentTime) * 10) / 10;
updateRegions();
};
const setFadeIn = () => {
let currentTime = waveform.value?.getCurrentTime();
let cue_in = form.value?.cue_in ?? 0;
props.form.fade_in = Math.round((currentTime - cue_in) * 10) / 10;
updateRegions();
}
const setFadeOut = () => {
let currentTime = waveform.value?.getCurrentTime();
let duration = waveform.value?.getDuration();
let cue_out = form.value?.cue_out ?? duration;
props.form.fade_out = Math.round((cue_out - currentTime) * 10) / 10;
updateRegions();
};
</script>