diff --git a/package.json b/package.json index 3e8939b1a144039470149380c267b46fda7d6282..ef5ea4772bc8c745271bf2b558c112b983110526 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "md5": "^2.3.0", "mini-css-extract-plugin": "1.3.1", "mitt": "2.1.0", + "mp3tag.js": "3.7.1", "multiselect": "0.9.12", "pdfjs-dist": "^2.6.347", "portal-vue": "^2.1.7", diff --git a/resources/assets/stylesheets/scss/courseware/blocks/audio.scss b/resources/assets/stylesheets/scss/courseware/blocks/audio.scss index c5c530ed72607dd1cfbf161351fa01b34f246f6a..2ea944b3f24189704a348425ddfddb4a2ad48694 100644 --- a/resources/assets/stylesheets/scss/courseware/blocks/audio.scss +++ b/resources/assets/stylesheets/scss/courseware/blocks/audio.scss @@ -1,231 +1,205 @@ -@use '../../../mixins.scss' as *; - -$media-buttons: ( - play: play, - stop: stop, - pause: pause, - prev: arr_eol-left, - next: arr_eol-right -); - .cw-block-audio { .cw-audio-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; border: solid thin var(--content-color-40); - padding-top: 1em; - } - .cw-audio-controls { - text-align: right; - padding: 0 0.5em; - } - .cw-audio-range { - margin: 0 5px 10px 0; - &::-moz-focus-outer { - border: 0; - } - &.ui-widget-content { - background-color: var(--base-color); - } - .ui-widget-header { - background-color: var(--dark-gray-color-5); - } - .ui-slider-handle { - border-radius: 20px; - width: 1em; - height: 1.7em; - top: -0.5em; - background-color: var(--dark-gray-color-20); - border-color: var(--content-color-40); - cursor: pointer; - margin-left: -2px; - } + padding: 36px; + gap: 64px; } - .cw-audio-button { - border: solid thin var(--content-color-40); - background-color: var(--white); - background-repeat: no-repeat; - background-position: center center; - background-size: 24px; - min-height: 27px; - line-height: 130%; - padding: 5px 15px 5px 30px; - cursor: pointer; - font-size: 14px; - box-sizing: border-box; - text-align: center; - text-decoration: none; - vertical-align: bottom; - white-space: nowrap; - min-width: unset; - margin: 5px; - height: 46px; - width: 46px; - display: inline-block; - - &:hover { - background-color: var(--base-color); - } - @each $button, $icon in $media-buttons { - &.cw-audio-#{$button}button { - @include background-icon($icon, clickable, 24); - &:hover { - @include background-icon($icon, info-alt, 24); - } - } + .cw-audio-recorder, + .cw-audio-player { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 36px; + flex-grow: 100; + + &.with-playlist { + flex-direction: column; } - } - .cw-audio-time { - position: relative; - top: -1em; - color: var(--base-gray); - } + .cw-audio-cover { + margin: 0 auto; + display: flex; + flex-direction: row; - .cw-audio-range { - display: block; - margin: 0 auto 1.5em; - -webkit-appearance: none; - position: relative; - overflow: hidden; - height: 18px; - width: 100%; - cursor: pointer; - border-radius: 0; - } + &.with-edit-button { + position: relative; + right: -8px; + } - .cw-audio-range::-webkit-slider-runnable-track { - background: var(--dark-gray-color-20); - } + .cover { + width: 256px; + height: 256px; + object-fit: cover; + } - .cw-audio-range::-webkit-slider-thumb { - -webkit-appearance: none; - width: 9px; /* 1 */ - height: 18px; - background: var(--white); - box-shadow: -100vw 0 0 100vw var(--base-color); - border: solid thin var(--content-color-40); - } + .default-cover { + padding: 64px; + border: solid thin var(--content-color-40); + } - .cw-audio-range::-moz-range-track { - height: 18px; - background: var(--dark-gray-color-10); - } + &.loading { + img { + visibility: hidden; + } + } - .cw-audio-range::-moz-range-thumb { - background: var(--white); - height: 18px; - width: 9px; - border: solid thin var(--content-color-40); - border-radius: 0 !important; - box-shadow: -100vw 0 0 100vw var(--base-color); - box-sizing: border-box; - } + button { + width: 16px; + height: 16px; + cursor: pointer; + padding: 0 8px; + background-color: transparent; + border: none; + } + } - .cw-audio-range::-ms-fill-lower { - background: var(--base-color); - } + .cw-audio-controls-wrapper { + flex-grow: 1; + min-width: 256px; + text-align: center; + display: flex; + flex-direction: column; + + .cw-audio-current-track { + flex-grow: 1; + max-width: 270px; + min-height: 60px; + margin: 0 auto; + + h2, + h3 { + margin-top: 0; + } + } - .cw-audio-range::-ms-thumb { - background: var(--white); - border: solid thin var(--content-color-40); - height: 18px; - width: 9px; - box-sizing: border-box; - } + .cw-audio-controls { + .cw-audio-progress { + .cw-audio-range { + width: 100%; + -webkit-appearance: none; + appearance: none; + cursor: pointer; + outline: none; + height: 2px; + background: var(--content-color-40); + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + height: 16px; + width: 16px; + background-color: var(--base-color); + border-radius: 50%; + border: none; + } - .cw-audio-range::-ms-ticks-after { - display: none; - } + &::-moz-range-thumb { + height: 16px; + width: 16px; + background-color: var(--base-color); + border-radius: 50%; + border: none; + } + } - .cw-audio-range::-ms-ticks-before { - display: none; - } + .cw-audio-time { + display: flex; + flex-direction: row; + justify-content: space-between; + } + } - .cw-audio-range::-ms-track { - background: var(--dark-gray-color-20); - color: transparent; - height: 18px; - border: none; + .cw-recorder-visualization { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 2px; + height: 28px; + margin-bottom: 1em; + padding-bottom: 4px; + border-bottom: solid 2px var(--content-color-40); + + .cw-recorder-visualization-bar { + min-height: 4px; + width: calc(100% / 32); + background-color: var(--base-color); + + &.idle-bar { + height: 4px !important; + } + } + } + .cw-audio-buttons { + display: flex; + flex-direction: row; + justify-content: space-evenly; + + button { + cursor: pointer; + border: none; + background: transparent; + } + } + } + } } - .cw-audio-range::-ms-tooltip { - display: none; - } .cw-audio-playlist-wrapper { - margin-top: -1em; - padding-top: 1em; - border: solid thin var(--content-color-40); - border-top: none; - - &.empty { - border: none; - } + flex-grow: 1; + min-width: 270px; + max-height: 450px; + overflow-y: auto; .cw-audio-playlist { padding-left: 0; + margin-top: -1em; list-style: none; cursor: pointer; - &.with-recorder { - border-bottom: solid thin var(--content-color-40); - } - li { - margin: 0 1em; &:not(:last-child) { border-bottom: solid thin var(--dark-gray-color-30); } .cw-playlist-item { display: block; - @include background-icon(file-audio2, clickable, 24); - background-repeat: no-repeat; - background-position: 1em center; - - margin: 1em 0; - padding: 1em; - padding-left: 4em; - color: var(--base-color); - &:hover { - color: var(--active-color); - } - &.current-item { - @include background-icon(play, clickable, 24); - font-weight: 700; - &.is-playing { - @include background-icon(pause, clickable, 24); - } + padding: 1em 0; + margin: 0; + img { + vertical-align: middle; } } } } - .cw-audio-playlist-recorder { - padding: 1em; - } } - - .cw-audio-current-track { - @include background-icon(file-audio2, info, 96); - background-position: top center; - background-repeat: no-repeat; - width: 100%; - min-height: 140px; - margin: 1em 0 2em 0; - p { - text-align: center; - padding-top: 106px; +} +.edit-mp3-cover-wrapper { + display: flex; + flex-direction: row; + margin-bottom: 1em; + + .edit-mp3-cover { + width: 128px; + height: 128px; + object-fit: cover; + + &.default-cover { + padding: 32px; + border: solid thin var(--content-color-40); } } - .cw-audio-empty { - @include background-icon(file, info, 96); - border: solid thin var(--content-color-40); - background-position: center 1em; - background-repeat: no-repeat; - min-height: 140px; - padding: 1em; - p { - text-align: center; - padding-top: 106px; - } + + .remove-cover { + background-color: transparent; + border: none; + height: 16px; + width: 16px; + padding: 0 8px; + cursor: pointer; } } diff --git a/resources/assets/stylesheets/scss/courseware/blocks/default-block.scss b/resources/assets/stylesheets/scss/courseware/blocks/default-block.scss index 24c57511cfd6b86bbab9226f09b43e2e21f3bd80..21e583e50a881a673e939b1e88d410a726aa1be7 100644 --- a/resources/assets/stylesheets/scss/courseware/blocks/default-block.scss +++ b/resources/assets/stylesheets/scss/courseware/blocks/default-block.scss @@ -131,4 +131,23 @@ text-align: center; padding-top: 106px; } +} + +.cw-call-to-action { + border: solid thin var(--content-color-40); + border-top: none; + + button { + width: 100%; + background-color: var(--activity-color-20); + border: none; + text-align: left; + padding: 1em; + cursor: pointer; + + img { + margin: 0 1em; + vertical-align: middle; + } + } } \ No newline at end of file diff --git a/resources/vue/components/courseware/blocks/CoursewareAudioBlock.vue b/resources/vue/components/courseware/blocks/CoursewareAudioBlock.vue index e346d2096028241dd41aca812dbc23e3561a774f..9ca52b04dfaa6c514f304c11174dbc861a52e6f1 100644 --- a/resources/vue/components/courseware/blocks/CoursewareAudioBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareAudioBlock.vue @@ -19,106 +19,260 @@ @durationchange="setDuration" @ended="onEndedListener" /> - <div v-if="!emptyAudio" class="cw-audio-container"> - <div class="cw-audio-current-track"> - <p>{{ activeTrackName }}</p> + <div class="cw-audio-container"> + <div + v-if="!userRecorderEnabled" + class="cw-audio-player" + :class="{ 'with-playlist': playlistEnabled }" + > + <div class="cw-audio-cover" :class="{ loading: loadingCover, 'with-edit-button': canEditFile }"> + <img v-if="cover" :src="cover" class="cover" /> + <studip-icon + v-else + :shape="emptyAudio ? 'file' : 'file-audio'" + :size="128" + role="info" + class="default-cover" + /> + <button v-if="canEditFile" :title="$gettext('Bearbeiten')" @click="displayEditMP3"> + <studip-icon shape="edit" /> + </button> + </div> + <div class="cw-audio-controls-wrapper"> + <div class="cw-audio-current-track"> + <h2>{{ trackTitle }}</h2> + <h3>{{ trackArtist }}</h3> + </div> + <div class="cw-audio-controls"> + <div class="cw-audio-progress"> + <template v-if="!emptyAudio"> + <input + class="cw-audio-range" + ref="range" + type="range" + :value="currentSeconds" + min="0" + :max="Math.round(durationSeconds)" + @input="rangeAction" + /> + <p class="cw-audio-time"> + <span>{{ currentTime }}</span> + <span>{{ durationTime }}</span> + </p> + </template> + <hr v-else /> + </div> + <div class="cw-audio-buttons"> + <button :title="$gettext('Zurück')" :disabled="!hasPlaylist" @click="prevAudio"> + <studip-icon + shape="arr_eol-left" + :role="hasPlaylist ? 'clickable' : 'inactive'" + :size="24" + /> + </button> + <button + v-if="!playing" + :title="$gettext('Abspielen')" + :disabled="emptyAudio" + @click="playAudio" + > + <studip-icon + shape="play" + :role="emptyAudio ? 'inactive' : 'clickable'" + :size="48" + /> + </button> + <button v-else :title="$gettext('Pause')" @click="pauseAudio"> + <studip-icon shape="pause" :size="48" /> + </button> + <button :title="$gettext('Weiter')" :disabled="!hasPlaylist" @click="nextAudio"> + <studip-icon + shape="arr_eol-right" + :role="hasPlaylist ? 'clickable' : 'inactive'" + :size="24" + /> + </button> + </div> + </div> + </div> </div> - <div class="cw-audio-controls"> - <input - class="cw-audio-range" - ref="range" - type="range" - :value="currentSeconds" - min="0" - :max="Math.round(durationSeconds)" - @input="rangeAction" - /> - <span class="cw-audio-time">{{ currentTime }} {{ durationTime ? '/ ' + durationTime : '' }}</span> - - <button v-if="hasPlaylist" class="cw-audio-button cw-audio-prevbutton" :title="$gettext('Zurück')" @click="prevAudio" /> - <button v-if="!playing" class="cw-audio-button cw-audio-playbutton" :title="$gettext('Abspielen')" @click="playAudio" /> - <button v-if="playing" class="cw-audio-button cw-audio-pausebutton" :title="$gettext('Pause')" @click="pauseAudio" /> - <button v-if="hasPlaylist" class="cw-audio-button cw-audio-nextbutton" :title="$gettext('Weiter')" @click="nextAudio" /> - <button class="cw-audio-button cw-audio-stopbutton" :title="$gettext('Anhalten')" @click="stopAudio" /> + <div v-else class="cw-audio-recorder with-playlist"> + <div class="cw-audio-cover"> + <studip-icon + shape="microphone" + :size="128" + :role="isRecording ? 'status-red' : 'info'" + class="default-cover" + /> + </div> + <div class="cw-audio-controls-wrapper"> + <div class="cw-audio-current-track"> + <h2>{{ $gettext('Aufnahme') }}</h2> + <h3 v-if="isRecording">{{ $gettext('Aufnahme läuft') }}: {{ seconds2time(timer) }}</h3> + <h3 v-if="newRecording && !isRecording">{{ seconds2time(timer) }}</h3> + </div> + <div class="cw-audio-controls"> + <div class="cw-recorder-visualization"> + <div + v-for="(value, key) in recorderFrequencyData" + :key="'bar' + key" + :ref="'bar' + key" + class="cw-recorder-visualization-bar" + :class="{ 'idle-bar': !isRecording }" + ></div> + </div> + <div class="cw-audio-buttons"> + <button + v-if="newRecording && !isRecording" + :title="$gettext('Aufnahme löschen')" + @click="resetRecorder" + > + <studip-icon shape="trash" :size="24" /> + </button> + <button + v-if="!isRecording && !newRecording" + :title="$gettext('Neue Aufnahme starten')" + @click="startRecording" + > + <studip-icon shape="span-full" :size="48" role="status-red" /> + </button> + <button + v-if="isRecording" + :title="$gettext('Aufnahme beenden')" + @click="stopRecording" + > + <studip-icon shape="stop" :size="48" /> + </button> + <button + v-if="newRecording && !isRecording" + :title="$gettext('Aufnahme speichern')" + @click="storeRecording" + > + <studip-icon shape="download" :size="48" /> + </button> + <button + v-if="newRecording && !isRecording" + :title="$gettext('Aufnahme wiederholen')" + @click="startRecording" + > + <studip-icon shape="span-full" :size="24" role="status-red" /> + </button> + </div> + </div> + </div> </div> - </div> - <div v-if="emptyAudio" class="cw-audio-empty"> - <p>{{ $gettext('Es ist keine Audio-Datei verfügbar') }}</p> - </div> - <div v-show="currentSource === 'studip_folder'" class="cw-audio-playlist-wrapper" :class="[!showRecorder && emptyAudio ? 'empty' : '']"> - <ul v-show="hasPlaylist" class="cw-audio-playlist" :class="[showRecorder ? 'with-recorder' : '']"> - <li v-for="(file, index) in files" :key="file.id"> - <a - :aria-current="(index === currentPlaylistItem) ? 'true' : 'false'" - :class="{ - 'is-playing': index === currentPlaylistItem && playing, - 'current-item': index === currentPlaylistItem, - }" - :title="$gettext('Audiodatei:') + ' ' + file.name" - href="#" - class="cw-playlist-item" - @click.prevent="setCurrentPlaylistItem(index)" - > - {{ file.name }} - </a> - </li> - </ul> - <div v-if="showRecorder && canGetMediaDevices" class="cw-audio-playlist-recorder"> - <button - v-show="!userRecorderEnabled" - class="button" - :disabled="!folderSelected || folderLoadError" - :title="enableRecorderTitle" - @click="enableRecorder" - > - {{ $gettext('Aufnahme aktivieren') }} - </button> - <button - v-show="userRecorderEnabled && !isRecording && !newRecording" - class="button" - @click="startRecording" - > - {{ $gettext('Aufnahme starten') }} - </button> - <button - v-show="newRecording && !isRecording" - class="button" - @click="startRecording" - > - {{ $gettext('Aufnahme wiederholen') }} - </button> - <button - v-show="isRecording" - class="button" - @click="stopRecording" - > - {{ $gettext('Aufnahme beenden') }} - </button> - <button - v-show="newRecording && !isRecording" - class="button" - @click="resetRecorder" - > - {{ $gettext('Aufnahme löschen') }} - </button> - <button - v-show="newRecording && !isRecording" - class="button" - @click="storeRecording" - > - {{ $gettext('Aufnahme speichern') }} - </button> - <span v-show="isRecording"> - {{ $gettext('Aufnahme läuft') }}: {{seconds2time(timer)}} - </span> + <div v-show="playlistEnabled" class="cw-audio-playlist-wrapper"> + <ul class="cw-audio-playlist" :class="[showRecorder ? 'with-recorder' : '']"> + <li v-for="(file, index) in files" :key="file.id"> + <a + :aria-current="index === currentPlaylistItem ? 'true' : 'false'" + :title="$gettext('Audiodatei:') + ' ' + file.name" + href="#" + class="cw-playlist-item" + @click.prevent="setCurrentPlaylistItem(index)" + > + <studip-icon + :shape=" + index === currentPlaylistItem && !userRecorderEnabled + ? playing + ? 'pause' + : 'play' + : 'file-audio2' + " + /> + {{ file.name }} + </a> + </li> + <li v-if="emptyAudio"> + <p class="cw-playlist-item"> + <studip-icon shape="file" role="info" /> + {{ $gettext('Ordner enthält keine Audio-Dateien') }} + </p> + </li> + </ul> </div> </div> + <div v-if="showRecorder && canGetMediaDevices" class="cw-call-to-action"> + <button + v-if="!userRecorderEnabled" + :title="enableRecorderTitle" + @click.prevent="enableRecorder" + > + <studip-icon shape="microphone" :size="48"/> + {{ $gettext('Aufnahme aktivieren') }} + </button> + <button v-else @click.prevent="resetRecorder"> + <studip-icon shape="decline" :size="48"/> + {{ $gettext('Aufnahme abbrechen') }} + </button> + </div> + <studip-dialog + v-if="showEditMP3" + :title="$gettext('MP3 Metadaten bearbeiten')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Abbrechen')" + closeClass="cancel" + @close="closeEditMP3" + @confirm="updateMP3" + height="550" + width="450" + > + <template v-slot:dialogContent> + <div class="edit-mp3-cover-wrapper"> + <img v-if="newCoverUrl" :src="newCoverUrl" class="edit-mp3-cover" /> + <template v-else> + <template v-if="cover && !deleteCover"> + <img :src="cover" class="edit-mp3-cover" /> + <button + v-if="cover" + class="remove-cover" + :title="$gettext('Cover entfernen')" + @click="removeCover" + > + <studip-icon shape="trash" /> + </button> + </template> + <studip-icon + v-if="cover === '' || deleteCover" + shape="file-audio" + :size="64" + role="info" + class="edit-mp3-cover default-cover" + /> + </template> + </div> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Cover') }} + <template v-if="!deleteCover"> + <input + class="cw-file-input" + type="file" + ref="newCover" + accept="image/jpeg" + @change="updateCover" + /> + </template> + <input v-else type="text" disabled :placeholder="$gettext('Cover wird entfernt')" /> + </label> + <label> + {{ $gettext('Titel') }} + <input type="text" v-model="currentMP3Title" /> + </label> + <label> + {{ $gettext('Künstler') }} + <input type="text" v-model="currentMP3Artist" /> + </label> + </form> + </template> + </studip-dialog> </template> <template v-if="canEdit" #edit> <form class="default" @submit.prevent=""> <label> {{ $gettext('Überschrift') }} - <input type="text" v-model="currentTitle" /> + <input type="text" v-model="currentTitle" :placeholder="$gettext('optional')" /> </label> <label> {{ $gettext('Quelle') }} @@ -168,6 +322,7 @@ import BlockComponents from './block-components.js'; import blockMixin from '@/vue/mixins/courseware/block.js'; import { mapActions, mapGetters } from 'vuex'; +import MP3Tag from 'mp3tag.js'; export default { name: 'courseware-audio-block', @@ -197,7 +352,24 @@ export default { timer: 0, isRecording: false, newRecording: false, - folderLoadError: false + folderLoadError: false, + recorderAudioCtx: null, + recorderAnalyser: null, + recorderSource: null, + recorderBufferLength: 0, + recorderTimeData: null, + recorderFrequencyData: null, + + mp3tag: null, + loadingCover: false, + volume: 100, + + showEditMP3: false, + currentMP3Title: '', + currentMP3Artist: '', + imageBytes: null, + newCoverUrl: '', + deleteCover: false, }; }, computed: { @@ -207,26 +379,33 @@ export default { urlHelper: 'urlHelper', userId: 'userId', usersById: 'users/byId', - relatedTermOfUse: 'terms-of-use/related' + relatedTermOfUse: 'terms-of-use/related', }), files() { const files = this.relatedFileRefs({ parent: { type: 'folders', id: this.currentFolderId }, - relationship: 'file-refs' + relationship: 'file-refs', }) ?? []; return files .filter((file) => { - if (this.relatedTermOfUse({parent: file, relationship: 'terms-of-use'}).attributes['download-condition'] !== 0) { + if ( + this.relatedTermOfUse({ parent: file, relationship: 'terms-of-use' }).attributes[ + 'download-condition' + ] !== 0 + ) { return false; - } - if (! file.attributes['mime-type'].includes('audio')) { + } + if (!file.attributes['mime-type'].includes('audio')) { return false; } return true; }) + .sort((a, b) => { + return new Date(a.attributes.mkdate) - new Date(b.attributes.mkdate); + }) .map(({ id, attributes }) => { return { id, @@ -236,7 +415,8 @@ export default { { type: 0, file_id: id, file_name: attributes.name }, true ), - mime_type: attributes['mime-type'] + mime_type: attributes['mime-type'], + isRecording: attributes.description === 'CoursewareRecording', }; }); }, @@ -271,25 +451,36 @@ export default { return this.block?.attributes?.payload?.recorder_enabled; }, showRecorder() { - return this.currentRecorderEnabled && this.currentSource === 'studip_folder'; + return this.currentRecorderEnabled && this.playlistEnabled; }, hasPlaylist() { - return this.files.length > 0 && this.currentSource === 'studip_folder'; + return this.files.length > 0 && this.playlistEnabled; + }, + playlistEnabled() { + return this.currentSource === 'studip_folder'; }, canGetMediaDevices() { return navigator.mediaDevices !== undefined; }, - currentURL() { + activeFile() { if (this.currentSource === 'studip_file') { - return this.currentFile.download_url; + return this.currentFile; } - if (this.currentSource === 'studip_folder') { + if (this.playlistEnabled) { if (this.files.length > 0) { - return this.files[this.currentPlaylistItem].download_url; - } else { - return ''; + return this.files[this.currentPlaylistItem]; } } + + return null; + }, + fileIsRecording() { + return this.activeFile?.isRecording ?? false; + }, + currentURL() { + if (this.activeFile) { + return this.activeFile.download_url; + } if (this.currentSource === 'web') { return this.currentWebUrl; } @@ -297,15 +488,8 @@ export default { return ''; }, activeTrackName() { - if (this.currentSource === 'studip_file') { - return this.currentFile.name; - } - if (this.currentSource === 'studip_folder') { - if (this.files.length > 0) { - return this.files[this.currentPlaylistItem].name; - } else { - return ''; - } + if (this.activeFile) { + return this.activeFile.name; } if (this.currentSource === 'web') { return this.currentWebUrl; @@ -313,6 +497,19 @@ export default { return ''; }, + trackTitle() { + if (this.emptyAudio) { + return this.$gettext('Es ist keine Audio-Datei verfügbar'); + } + if (this.tags && this.tags.title !== '') { + return this.tags.title; + } + + return this.activeTrackName; + }, + trackArtist() { + return this.tags?.artist ?? ''; + }, emptyAudio() { if (this.currentSource === 'studip_folder' && this.currentFolderId !== '' && this.files.length > 0) { return false; @@ -335,21 +532,69 @@ export default { } return this.$gettext('Aktiviert die Aufnahmefunktion'); - } + }, + tags() { + return this.mp3tag?.tags ?? {}; + }, + hasMP3Tags() { + return Object.keys(this.tags).length > 0; + }, + cover() { + const image = this.tags?.v2?.APIC?.[0]; + if (image) { + return this.imageURL(image.data, image.format); + } + + if (this.fileIsRecording) { + const ownerId = this.activeFileRef?.relationships?.owner?.data?.id; + if (ownerId) { + const owner = this.usersById({ id: ownerId }); + return owner?.meta?.avatar?.normal ?? ''; + } + } + + return ''; + }, + activeFileRef() { + return this.fileRefById({ id: this.activeFile.id }); + }, + canEditFile() { + return this.hasMP3Tags && this.activeFileRef.attributes['is-editable']; + }, }, - mounted() { + async mounted() { this.initCurrentData(); }, methods: { ...mapActions({ loadFileRef: 'file-refs/loadById', loadRelatedFileRefs: 'file-refs/loadRelated', + updateFileRefs: 'file-refs/update', updateBlock: 'updateBlockInContainer', companionWarning: 'companionWarning', companionSuccess: 'companionSuccess', companionError: 'companionError', createFile: 'createFile', + updateFileContent: 'updateFileContent', + loadUser: 'users/loadById', }), + + toDataURL(url) { + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.onload = function () { + var reader = new FileReader(); + reader.onloadend = function () { + resolve(reader.result); + }; + reader.readAsArrayBuffer(xhr.response); + }; + xhr.open('GET', url); + xhr.responseType = 'blob'; + xhr.send(); + }); + }, + initCurrentData() { this.currentTitle = this.title; this.currentSource = this.source; @@ -370,9 +615,9 @@ export default { await this.loadRelatedFileRefs({ parent: { type: 'folders', id: this.currentFolderId }, relationship: 'file-refs', - options: { include: 'terms-of-use' } + options: { include: 'terms-of-use' }, }); - } catch(error) { + } catch (error) { this.folderLoadError = true; } }, @@ -388,7 +633,7 @@ export default { if (this.currentSource === 'studip_file') { if (this.currentFileId === '') { this.companionWarning({ - info: this.$gettext('Bitte wählen Sie eine Datei aus.') + info: this.$gettext('Bitte wählen Sie eine Datei aus.'), }); return false; } @@ -398,7 +643,7 @@ export default { } else if (this.currentSource === 'studip_folder') { if (this.currentFolderId === '') { this.companionWarning({ - info: this.$gettext('Bitte wählen Sie einen Ordner aus.') + info: this.$gettext('Bitte wählen Sie einen Ordner aus.'), }); return false; } @@ -419,8 +664,11 @@ export default { this.$refs.audio.currentTime = this.$refs.range.value; } }, + setVolume() { + this.$refs.audio.volume = this.volume / 100; + }, setDuration() { - let duration = this.$refs.audio.duration + let duration = this.$refs.audio.duration; if (!isNaN(duration) && isFinite(duration)) { this.durationSeconds = duration; } else { @@ -441,9 +689,9 @@ export default { this.playing = true; } else { this.companionError({ - info: this.$gettext('Ihr Browser unterstützt dieses Audioformat leider nicht.') + info: this.$gettext('Ihr Browser unterstützt dieses Audioformat leider nicht.'), }); - if(this.hasPlaylist) { + if (this.hasPlaylist) { this.nextAudio(); } } @@ -461,7 +709,7 @@ export default { }, onEndedListener() { this.stopAudio(); - if(this.hasPlaylist) { + if (this.hasPlaylist) { this.nextAudio(); } }, @@ -486,6 +734,7 @@ export default { return time; }, setCurrentPlaylistItem(index) { + this.userRecorderEnabled = false; if (this.currentPlaylistItem === index) { if (this.playing) { this.pauseAudio(); @@ -494,7 +743,7 @@ export default { } } else { this.currentPlaylistItem = index; - this.$nextTick(()=> { + this.$nextTick(() => { this.playAudio(); }); } @@ -506,7 +755,7 @@ export default { } else { this.currentPlaylistItem = this.files.length - 1; } - this.$nextTick(()=> { + this.$nextTick(() => { this.playAudio(); }); }, @@ -514,7 +763,7 @@ export default { this.stopAudio(); if (this.currentPlaylistItem < this.files.length - 1) { this.currentPlaylistItem = this.currentPlaylistItem + 1; - this.$nextTick(()=> { + this.$nextTick(() => { this.playAudio(); }); } @@ -534,26 +783,59 @@ export default { { type: 0, file_id: fileRef.id, file_name: fileRef.attributes.name }, true ), - mime_type: fileRef.attributes['mime-type'] + mime_type: fileRef.attributes['mime-type'], }); } }, + async loadTags() { + this.mp3tag = null; + let view = this; + let response = await fetch(this.currentURL); + let data = await response.blob(); + let file = new File([data], this.activeTrackName); + + let reader = new FileReader(); + reader.onload = function () { + const buffer = this.result; + view.mp3tag = new MP3Tag(buffer); + view.mp3tag.read(); + }; + + reader.readAsArrayBuffer(file); + }, + imageURL(bytes, format) { + let encoded = ''; + bytes.forEach(function (byte) { + encoded += String.fromCharCode(byte); + }); + + return `data:${format};base64,${btoa(encoded)}`; + }, enableRecorder() { if (!this.folderSelected || this.folderLoadError) { return false; } let view = this; - navigator.mediaDevices.getUserMedia({ audio: true }) - .then(function(stream) { - view.recorder = new MediaRecorder(stream, {type: 'audio/webm; codecs:vp9' }); + navigator.mediaDevices + .getUserMedia({ audio: true }) + .then(function (stream) { + view.recorder = new MediaRecorder(stream, { type: 'audio/webm; codecs:vp9' }); view.userRecorderEnabled = true; - view.recorder.ondataavailable = e => { + view.recorder.ondataavailable = (e) => { view.chunks.push(e.data); }; + + view.recorderAudioCtx = new AudioContext(); + view.recorderAnalyser = view.recorderAudioCtx.createAnalyser(); + view.recorderSource = view.recorderAudioCtx.createMediaStreamSource(stream); + view.recorderSource.connect(view.recorderAnalyser); + view.recorderAnalyser.fftSize = 2 ** 6; + view.recorderBufferLength = view.recorderAnalyser.frequencyBinCount; + view.recorderFrequencyData = new Uint8Array(view.recorderBufferLength); }) .catch(() => { view.companionWarning({ - info: view.$gettext('Sie müssen ein Mikrofon freigeben, um eine Aufnahme starten zu können.') + info: view.$gettext('Sie müssen ein Mikrofon freigeben, um eine Aufnahme starten zu können.'), }); }); }, @@ -563,7 +845,9 @@ export default { this.timer = 0; this.recorder.start(); this.isRecording = true; - setTimeout(function(){ view.setTimer(); }, 1000); + setTimeout(function () { + view.setTimer(); + }, 1000); }, stopRecording() { this.isRecording = false; @@ -574,48 +858,136 @@ export default { let view = this; if (this.recorder.state === 'recording') { this.timer++; - setTimeout(function(){ view.setTimer(); }, 1000); + setTimeout(function () { + view.setTimer(); + }, 1000); + } + }, + recorderDrawTimeData() { + this.recorderAnalyser.getByteFrequencyData(this.recorderFrequencyData); + + for (let i = 0; i < this.recorderFrequencyData.length; i++) { + let ref = 'bar' + i; + this.$refs[ref][0].style.height = (this.recorderFrequencyData[i] / 255) * 28 + 'px'; + } + + if (this.isRecording) { + let view = this; + requestAnimationFrame(() => view.recorderDrawTimeData()); } }, async storeRecording() { let view = this; - let user = this.usersById({id: this.userId}); - let blob = new Blob(view.chunks, {type: 'audio/webm; codecs:vp9' }); + let user = this.usersById({ id: this.userId }); + let blob = new Blob(view.chunks, { type: 'audio/webm; codecs:vp9' }); + let file = { attributes: { - name: (user.attributes["formatted-name"]).replace(/\s+/g, '_') + '.webm' + name: user.attributes['formatted-name'].replace(/\s+/g, '_') + '.webm', }, relationships: { 'terms-of-use': { data: { - id: 'SELFMADE_NONPUB' - } - } - } + id: 'SELFMADE_NONPUB', + }, + }, + }, }; let fileObj = await this.createFile({ file: file, filedata: blob, - folder: {id: this.currentFolderId} + folder: { id: this.currentFolderId }, }); - if(fileObj && fileObj.type === 'file-refs') { + if (fileObj && fileObj.type === 'file-refs') { this.companionSuccess({ - info: this.$gettext('Die Aufnahme wurde erfolgreich im Dateibereich abgelegt.') + info: this.$gettext('Die Aufnahme wurde erfolgreich im Dateibereich abgelegt.'), }); + fileObj.attributes.description = 'CoursewareRecording'; + await this.updateFileRefs(fileObj); } else { this.companionError({ - info: this.$gettext('Es ist ein Fehler aufgetreten! Die Aufnahme konnte nicht gespeichert werden.') + info: this.$gettext('Es ist ein Fehler aufgetreten! Die Aufnahme konnte nicht gespeichert werden.'), }); } this.newRecording = false; + this.userRecorderEnabled = false; this.getFolderFiles(); }, resetRecorder() { + this.userRecorderEnabled = false; + this.isRecording = false; this.newRecording = false; this.chunks = []; this.timer = 0; this.blob = null; }, + displayEditMP3() { + this.stopAudio(); + this.currentMP3Title = this.tags.title; + this.currentMP3Artist = this.tags.artist; + this.showEditMP3 = true; + }, + closeEditMP3() { + this.showEditMP3 = false; + this.currentMP3Title = ''; + this.currentMP3Artist = ''; + this.imageBytes = null; + this.newCoverUrl = ''; + this.deleteCover = false; + }, + removeCover() { + this.deleteCover = true; + this.$refs.newCover.value = ''; + }, + async updateCover() { + this.deleteCover = false; + const file = this.$refs?.newCover?.files[0]; + const buffer = await this.readFile(file); + this.imageBytes = new Uint8Array(buffer); + this.newCoverUrl = this.imageURL(this.imageBytes, 'image/jpeg'); + }, + readFile(file) { + return new Promise(function (resolve, reject) { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result); + }; + reader.onerror = reject; + reader.readAsArrayBuffer(file); + }); + }, + async updateMP3() { + this.mp3tag.tags.title = this.currentMP3Title; + this.mp3tag.tags.artist = this.currentMP3Artist; + + if (this.imageBytes) { + this.mp3tag.tags.v2.APIC = [ + { + format: 'image/jpeg', + type: 3, + description: '', + data: this.imageBytes, + }, + ]; + } + if (this.deleteCover) { + this.mp3tag.tags.v2.APIC = []; + } + this.mp3tag.save(); + const modifiedFile = new File([this.mp3tag.buffer], this.activeTrackName, { + type: 'audio/mpeg', + }); + + const fileRef = await this.fileRefById({ id: this.activeFile.id }); + + let fileObj = await this.updateFileContent({ + file: fileRef, + filedata: modifiedFile, + }); + + this.closeEditMP3(); + this.getFolderFiles(); + }, }, watch: { currentFolderId(newState) { @@ -625,9 +997,27 @@ export default { this.getFolderFiles(); } }, + currentURL() { + this.loadingCover = true; + this.loadTags(); + if (this.fileIsRecording) { + const ownerId = this.activeFileRef?.relationships?.owner?.data?.id; + if (ownerId) { + this.loadUser({ id: ownerId }); + } + } + setTimeout(() => { + this.loadingCover = false; + }, 200); + }, + isRecording(newState) { + if (newState) { + this.recorderDrawTimeData(); + } + }, }, }; </script> <style scoped lang="scss"> - @import "../../../../assets/stylesheets/scss/courseware/blocks/audio.scss"; -</style> \ No newline at end of file +@import '../../../../assets/stylesheets/scss/courseware/blocks/audio.scss'; +</style> diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 89d7da82d7ecdd27f0f8f0ae571e2443bf610fb5..f5bb68b6e78c346e50033b46b861ccb8273dc27e 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -360,6 +360,23 @@ export const actions = { return response ? response.data.data : response; }, + async updateFileContent(context, { file, filedata }) { + const url = `file-refs/${file.id}/content`; + const formData = new FormData(); + formData.append('file', filedata, file.attributes.name); + let request = await state.httpClient.post(url, formData); + let response = null; + try { + response = await state.httpClient.get(request.headers.location); + } + catch(e) { + console.debug(e); + response = null; + } + + return response ? response.data.data : response; + }, + async createRootFolder({ dispatch, rootGetters }, { context, folder }) { // get root folder for this context await dispatch(