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> </div>
</b-form-group> </b-form-group>
</b-row> </b-row>
<b-row class="mt-3"> <b-row class="mt-3 align-items-center">
<b-col md="8"> <b-col md="7">
<div class="d-flex"> <div class="d-flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<label for="waveform-zoom"> <label for="waveform-zoom">
@ -17,26 +17,27 @@
</label> </label>
</div> </div>
<div class="flex-fill mx-3"> <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>
</div> </div>
</b-col> </b-col>
<b-col md="4"> <b-col md="5">
<div class="inline-volume-controls d-flex align-items-center"> <div class="inline-volume-controls d-flex align-items-center">
<div class="flex-shrink-0"> <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> <icon icon="volume_mute"></icon>
{{ $gettext('Mute') }}
</a> </a>
</div> </div>
<div class="flex-fill mx-1"> <div class="flex-fill mx-1">
<input type="range" :title="langVolume" class="player-volume-range custom-range w-100" min="0" max="100" <input type="range" :title="$gettext('Volume')" class="player-volume-range custom-range w-100"
step="1" v-model="volume"> min="0" max="100" step="1" v-model.number="volume">
</div> </div>
<div class="flex-shrink-0"> <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> <icon icon="volume_up"></icon>
{{ $gettext('Full Volume') }}
</a> </a>
</div> </div>
</div> </div>
@ -45,128 +46,119 @@
</b-form-group> </b-form-group>
</template> </template>
<script> <script setup>
import WaveSurfer from 'wavesurfer.js'; import WS from 'wavesurfer.js';
import timeline from 'wavesurfer.js/dist/plugin/wavesurfer.timeline.js'; import timeline from 'wavesurfer.js/dist/plugin/wavesurfer.timeline.js';
import regions from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js'; import regions from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js';
import getLogarithmicVolume from '~/functions/getLogarithmicVolume.js'; import getLogarithmicVolume from '~/functions/getLogarithmicVolume.js';
import store from 'store';
import Icon from './Icon'; import Icon from './Icon';
import {useStorage} from "@vueuse/core";
import {onMounted, onUnmounted, ref, watch} from "vue";
import {useAxios} from "~/vendor/axios";
export default { const props = defineProps({
name: 'Waveform', audioUrl: String,
components: { Icon }, waveformUrl: String
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: []
})
]
});
this.wavesurfer.on('ready', () => { const emit = defineEmits(['ready']);
this.$emit('ready');
});
this.axios.get(this.waveformUrl).then((resp) => { let wavesurfer = null;
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);
});
// Check webstorage for existing volume preference. const volume = useStorage('player_volume', 55);
if (store.enabled && store.get('player_volume') !== undefined) { const zoom = ref(0);
this.volume = store.get('player_volume', 55);
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);
} }
}, }).catch((err) => {
computed: { console.error(err);
langVolume () { wavesurfer.load(props.audioUrl);
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));
if (store.enabled) { onUnmounted(() => {
store.set('player_volume', volume); wavesurfer = null;
} });
}
}, const play = () => {
beforeDestroy () { wavesurfer?.play();
this.wavesurfer = null;
}
}; };
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> </script>

View File

@ -1,6 +1,7 @@
<template> <template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="v$.form.$invalid" <modal-form ref="modal" :loading="loading" :title="$gettext('Edit Media')" :error="error"
@submit="doEdit" @hidden="clearContents"> :disable-save-button="v$.$invalid"
@submit="doEdit" @hidden="resetForm">
<b-tabs content-class="mt-3" pills> <b-tabs content-class="mt-3" pills>
<b-tab active> <b-tab active>
@ -8,14 +9,14 @@
{{ $gettext('Basic Information') }} {{ $gettext('Basic Information') }}
</template> </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>
<b-tab> <b-tab>
<template #title> <template #title>
{{ $gettext('Playlists') }} {{ $gettext('Playlists') }}
</template> </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>
<b-tab lazy> <b-tab lazy>
<template #title> <template #title>
@ -30,7 +31,7 @@
{{ $gettext('Custom Fields') }} {{ $gettext('Custom Fields') }}
</template> </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>
<b-tab lazy> <b-tab lazy>
@ -47,14 +48,15 @@
{{ $gettext('Advanced') }} {{ $gettext('Advanced') }}
</template> </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-tab>
</b-tabs> </b-tabs>
</modal-form> </modal-form>
</template> </template>
<script>
<script setup>
import {required} from '@vuelidate/validators'; import {required} from '@vuelidate/validators';
import _ from 'lodash'; import {defaultTo, forEach, map} from 'lodash';
import MediaFormBasicInfo from './Form/BasicInfo'; import MediaFormBasicInfo from './Form/BasicInfo';
import MediaFormAlbumArt from './Form/AlbumArt'; import MediaFormAlbumArt from './Form/AlbumArt';
import MediaFormCustomFields from './Form/CustomFields'; import MediaFormCustomFields from './Form/CustomFields';
@ -62,173 +64,161 @@ import MediaFormAdvancedSettings from './Form/AdvancedSettings';
import MediaFormPlaylists from './Form/Playlists'; import MediaFormPlaylists from './Form/Playlists';
import MediaFormWaveformEditor from './Form/WaveformEditor'; import MediaFormWaveformEditor from './Form/WaveformEditor';
import ModalForm from "~/components/Common/ModalForm"; 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 { const props = defineProps({
name: 'EditModal', customFields: Array,
components: { playlists: Array
ModalForm, });
MediaFormPlaylists,
MediaFormWaveformEditor, const emit = defineEmits(['relist']);
MediaFormAdvancedSettings,
MediaFormCustomFields, const loading = ref(true);
MediaFormAlbumArt, const error = ref(null);
MediaFormBasicInfo const recordUrl = ref('');
}, const albumArtUrl = ref('');
setup() { const waveformUrl = ref('');
return {v$: useVuelidate()} const audioUrl = ref('');
}, const songLength = ref(0);
props: {
customFields: Array, const buildForm = () => {
playlists: Array let blankForm = {
}, path: null,
data() { title: null,
return { artist: null,
loading: true, album: null,
recordUrl: null, genre: null,
error: null, lyrics: null,
albumArtUrl: null, isrc: null,
waveformUrl: null, amplify: null,
audioUrl: null, fade_overlap: null,
songLength: null, fade_in: null,
form: {} fade_out: null,
}; cue_in: null,
}, cue_out: null,
validations() { playlists: [],
let validations = { custom_fields: {}
form: { };
path: {
required let validations = {
}, path: {required},
title: {}, title: {},
artist: {}, artist: {},
album: {}, album: {},
genre: {}, genre: {},
lyrics: {}, lyrics: {},
isrc: {}, isrc: {},
art: {}, art: {},
amplify: {}, amplify: {},
fade_overlap: {}, fade_overlap: {},
fade_in: {}, fade_in: {},
fade_out: {}, fade_out: {},
cue_in: {}, cue_in: {},
cue_out: {}, cue_out: {},
playlists: {}, playlists: {},
custom_fields: {} 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) => { forEach(props.customFields.slice(), (field) => {
validations.form.custom_fields[field.short_name] = {}; newForm.custom_fields[field.short_name] = defaultTo(d.custom_fields[field.short_name], null);
}); });
return validations; form.value = newForm;
}, }).catch(() => {
computed: { close();
langTitle() { }).finally(() => {
return this.$gettext('Edit Media'); loading.value = false;
} });
},
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;
});
}
}
}; };
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> </script>

View File

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