aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--resources/assets/stylesheets/scss/courseware/blocks/audio.scss352
-rw-r--r--resources/assets/stylesheets/scss/courseware/blocks/default-block.scss19
-rw-r--r--resources/vue/components/courseware/blocks/CoursewareAudioBlock.vue688
-rw-r--r--resources/vue/store/courseware/courseware.module.js17
5 files changed, 739 insertions, 338 deletions
diff --git a/package.json b/package.json
index 3e8939b..ef5ea47 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 c5c530e..2ea944b 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 24c5751..21e583e 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 e346d20..9ca52b0 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 89d7da8..f5bb68b 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(