diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/attachment/attachment.js | 55 | ||||
| -rw-r--r-- | src/components/attachment/attachment.vue | 132 | ||||
| -rw-r--r-- | src/components/gallery/gallery.js | 55 | ||||
| -rw-r--r-- | src/components/gallery/gallery.vue | 60 | ||||
| -rw-r--r-- | src/components/media_modal/media_modal.js | 38 | ||||
| -rw-r--r-- | src/components/media_modal/media_modal.vue | 38 | ||||
| -rw-r--r-- | src/components/registration/registration.vue | 2 | ||||
| -rw-r--r-- | src/components/settings/settings.js | 16 | ||||
| -rw-r--r-- | src/components/settings/settings.vue | 12 | ||||
| -rw-r--r-- | src/components/status/status.js | 33 | ||||
| -rw-r--r-- | src/components/status/status.vue | 22 | ||||
| -rw-r--r-- | src/components/video_attachment/video_attachment.js | 31 | ||||
| -rw-r--r-- | src/components/video_attachment/video_attachment.vue | 11 |
13 files changed, 431 insertions, 74 deletions
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 9212b74b..47939852 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -1,4 +1,5 @@ import StillImage from '../still-image/still-image.vue' +import VideoAttachment from '../video_attachment/video_attachment.vue' import nsfwImage from '../../assets/nsfw.png' import fileTypeService from '../../services/file_type/file_type.service.js' @@ -7,23 +8,29 @@ const Attachment = { 'attachment', 'nsfw', 'statusId', - 'size' + 'size', + 'allowPlay', + 'setMedia' ], data () { return { nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, hideNsfwLocal: this.$store.state.config.hideNsfw, preloadImage: this.$store.state.config.preloadImage, - loopVideo: this.$store.state.config.loopVideo, - showHidden: false, loading: false, - img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img') + img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), + modalOpen: false, + showHidden: false } }, components: { - StillImage + StillImage, + VideoAttachment }, computed: { + usePlaceHolder () { + return this.size === 'hide' || this.type === 'unknown' + }, referrerpolicy () { return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' }, @@ -40,7 +47,7 @@ const Attachment = { return this.size === 'small' }, fullwidth () { - return fileTypeService.fileType(this.attachment.mimetype) === 'html' + return this.type === 'html' || this.type === 'audio' } }, methods: { @@ -49,7 +56,24 @@ const Attachment = { window.open(target.href, '_blank') } }, - toggleHidden () { + openModal (event) { + const modalTypes = this.$store.state.config.playVideosInline + ? ['image'] + : ['image', 'video'] + if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) || + this.usePlaceHolder + ) { + event.stopPropagation() + event.preventDefault() + this.setMedia() + this.$store.dispatch('setCurrent', this.attachment) + } + }, + toggleHidden (event) { + if (this.$store.state.config.useOneClickNsfw && !this.showHidden) { + this.openModal(event) + return + } if (this.img && !this.preloadImage) { if (this.img.onload) { this.img.onload() @@ -64,23 +88,6 @@ const Attachment = { } else { this.showHidden = !this.showHidden } - }, - onVideoDataLoad (e) { - if (typeof e.srcElement.webkitAudioDecodedByteCount !== 'undefined') { - // non-zero if video has audio track - if (e.srcElement.webkitAudioDecodedByteCount > 0) { - this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly - } - } else if (typeof e.srcElement.mozHasAudio !== 'undefined') { - // true if video has audio track - if (e.srcElement.mozHasAudio) { - this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly - } - } else if (typeof e.srcElement.audioTracks !== 'undefined') { - if (e.srcElement.audioTracks.length > 0) { - this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly - } - } } } } diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index d7f25953..5a80db8a 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -1,19 +1,44 @@ <template> - <div v-if="size==='hide'"> - <a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a> + <div v-if="usePlaceHolder" @click="openModal"> + <a class="placeholder" + v-if="type !== 'html'" + target="_blank" :href="attachment.url" + > + [{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}] + </a> </div> - <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" v-show="!isEmpty"> - <a class="image-attachment" v-if="hidden" @click.prevent="toggleHidden()"> - <img :key="nsfwImage" :src="nsfwImage"/> + <div + v-else class="attachment" + :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" + v-show="!isEmpty" + > + <a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden"> + <img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/> + <i v-if="type === 'video'" class="play-icon icon-play-circled"></i> </a> <div class="hider" v-if="nsfw && hideNsfwLocal && !hidden"> - <a href="#" @click.prevent="toggleHidden()">Hide</a> + <a href="#" @click.prevent="toggleHidden">Hide</a> </div> - <a v-if="type === 'image' && (!hidden || preloadImage)" class="image-attachment" :class="{'hidden': hidden && preloadImage}" :href="attachment.url" target="_blank" :title="attachment.description"> - <StillImage :class="{'small': isSmall}" :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> + + <a v-if="type === 'image' && (!hidden || preloadImage)" + @click="openModal" + class="image-attachment" + :class="{'hidden': hidden && preloadImage }" + :href="attachment.url" target="_blank" + :title="attachment.description" + > + <StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> </a> - <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" @loadeddata="onVideoDataLoad" :src="attachment.url" controls :loop="loopVideo" playsinline></video> + <a class="video-container" + @click="openModal" + v-if="type === 'video' && !hidden" + :class="{'small': isSmall}" + :href="attachment.url" + > + <VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" /> + <i v-if="!allowPlay" class="play-icon icon-play-circled"></i> + </a> <audio v-if="type === 'audio'" :src="attachment.url" controls></audio> @@ -40,12 +65,17 @@ .attachment.media-upload-container { flex: 0 0 auto; - max-height: 300px; + max-height: 200px; max-width: 100%; + display: flex; + video { + max-width: 100%; + } } .placeholder { - margin-right: 0.5em; + margin-right: 8px; + margin-bottom: 4px; } .nsfw-placeholder { @@ -56,17 +86,9 @@ } } - .small-attachment { - &.image, &.video { - max-width: 35%; - } - max-height: 100px; - } - .attachment { position: relative; - flex: 1 0 30%; - margin: 0.5em 0.7em 0.6em 0.0em; + margin: 0.5em 0.5em 0em 0em; align-self: flex-start; line-height: 0; @@ -78,6 +100,28 @@ border-color: var(--border, $fallback--border); overflow: hidden; } + + .non-gallery.attachment { + &.video { + flex: 1 0 40%; + } + .nsfw { + height: 260px; + } + .small { + height: 120px; + flex-grow: 0; + } + .video { + height: 260px; + display: flex; + } + video { + max-height: 100%; + object-fit: contain; + } + } + .fullwidth { flex-basis: 100%; } @@ -86,6 +130,28 @@ line-height: 0; } + .video-container { + display: flex; + max-height: 100%; + } + + .video { + width: 100%; + } + + .play-icon { + position: absolute; + font-size: 64px; + top: calc(50% - 32px); + left: calc(50% - 32px); + color: rgba(255, 255, 255, 0.75); + text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); + } + + .play-icon::before { + margin: 0; + } + &.html { flex-basis: 90%; width: 100%; @@ -94,6 +160,7 @@ .hider { position: absolute; + white-space: nowrap; margin: 10px; padding: 5px; background: rgba(230,230,230,0.6); @@ -104,13 +171,7 @@ border-radius: var(--tooltipRadius, $fallback--tooltipRadius); } - .small { - max-height: 100px; - } video { - max-height: 500px; - height: 100%; - width: 100%; z-index: 0; } @@ -120,7 +181,7 @@ img.media-upload { line-height: 0; - max-height: 300px; + max-height: 200px; max-width: 100%; } @@ -157,29 +218,20 @@ } .image-attachment { - display: flex; - flex: 1; + width: 100%; + height: 100%; &.hidden { display: none; } - .still-image { + .nsfw { + object-fit: cover; width: 100%; height: 100%; } - .small { - img { - max-height: 100px; - } - } - img { - object-fit: contain; - width: 100%; - height: 100%; /* If this isn't here, chrome will stretch the images */ - max-height: 500px; image-orientation: from-image; } } diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js new file mode 100644 index 00000000..7f33a81b --- /dev/null +++ b/src/components/gallery/gallery.js @@ -0,0 +1,55 @@ +import Attachment from '../attachment/attachment.vue' +import { chunk, last, dropRight } from 'lodash' + +const Gallery = { + data: () => ({ + width: 500 + }), + props: [ + 'attachments', + 'nsfw', + 'setMedia' + ], + components: { Attachment }, + mounted () { + this.resize() + window.addEventListener('resize', this.resize) + }, + destroyed () { + window.removeEventListener('resize', this.resize) + }, + computed: { + rows () { + if (!this.attachments) { + return [] + } + const rows = chunk(this.attachments, 3) + if (last(rows).length === 1 && rows.length > 1) { + // if 1 attachment on last row -> add it to the previous row instead + const lastAttachment = last(rows)[0] + const allButLastRow = dropRight(rows) + last(allButLastRow).push(lastAttachment) + return allButLastRow + } + return rows + }, + rowHeight () { + return itemsPerRow => ({ 'height': `${(this.width / (itemsPerRow + 0.6))}px` }) + }, + useContainFit () { + return this.$store.state.config.useContainFit + } + }, + methods: { + resize () { + // Quick optimization to make resizing not always trigger state change, + // only update attachment size in 10px steps + const width = Math.floor(this.$el.getBoundingClientRect().width / 10) * 10 + if (this.width !== width) { + this.width = width + } + } + } +} + +export default Gallery diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue new file mode 100644 index 00000000..20e8ab2f --- /dev/null +++ b/src/components/gallery/gallery.vue @@ -0,0 +1,60 @@ +<template> + <div ref="galleryContainer" style="width: 100%;"> + <div class="gallery-row" v-for="row in rows" :style="rowHeight(row.length)" :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"> + <attachment + v-for="attachment in row" + :setMedia="setMedia" + :nsfw="nsfw" + :attachment="attachment" + :allowPlay="false" + :key="attachment.id" + /> + </div> + </div> +</template> + +<script src='./gallery.js'></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.gallery-row { + height: 200px; + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-content: stretch; + flex-grow: 1; + margin-top: 0.5em; + + .attachments, .attachment { + margin: 0 0.5em 0 0; + flex-grow: 1; + height: 100%; + box-sizing: border-box; + } + + .image-attachment { + width: 100%; + height: 100%; + } + + .video-container { + height: 100%; + } + + &.contain-fit { + img, video { + object-fit: contain; + } + } + + &.cover-fit { + img, video { + object-fit: cover; + } + } +} + +</style> diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js new file mode 100644 index 00000000..14ae19d4 --- /dev/null +++ b/src/components/media_modal/media_modal.js @@ -0,0 +1,38 @@ +import StillImage from '../still-image/still-image.vue' +import VideoAttachment from '../video_attachment/video_attachment.vue' +import fileTypeService from '../../services/file_type/file_type.service.js' + +const MediaModal = { + components: { + StillImage, + VideoAttachment + }, + computed: { + showing () { + return this.$store.state.mediaViewer.activated + }, + currentIndex () { + return this.$store.state.mediaViewer.currentIndex + }, + currentMedia () { + return this.$store.state.mediaViewer.media[this.currentIndex] + }, + type () { + return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null + } + }, + created () { + document.addEventListener('keyup', e => { + if (e.keyCode === 27 && this.showing) { // escape + this.hide() + } + }) + }, + methods: { + hide () { + this.$store.dispatch('closeMediaViewer') + } + } +} + +export default MediaModal diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue new file mode 100644 index 00000000..796d4e40 --- /dev/null +++ b/src/components/media_modal/media_modal.vue @@ -0,0 +1,38 @@ +<template> + <div class="modal-view" v-if="showing" @click.prevent="hide"> + <img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img> + <VideoAttachment + class="modal-image" + v-if="type === 'video'" + :attachment="currentMedia" + :controls="true" + @click.stop.native=""> + </VideoAttachment> + </div> +</template> + +<script src="./media_modal.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.modal-view { + z-index: 1000; + position: fixed; + width: 100vw; + height: 100vh; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.modal-image { + max-width: 90%; + max-height: 90%; + box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); +} +</style> diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index 25c1a9d0..a3ba14d1 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -215,7 +215,7 @@ $validations-cRed: #f04124; } } -@media all and (max-width: 959px) { +@media all and (max-width: 800px) { .registration-form .container { flex-direction: column-reverse; } diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index d45ec72d..06011e7c 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -13,6 +13,7 @@ const settings = { hideAttachmentsLocal: user.hideAttachments, hideAttachmentsInConvLocal: user.hideAttachmentsInConv, hideNsfwLocal: user.hideNsfw, + useOneClickNsfw: user.useOneClickNsfw, hideISPLocal: user.hideISP, preloadImage: user.preloadImage, @@ -29,7 +30,6 @@ const settings = { notificationVisibilityLocal: user.notificationVisibility, replyVisibilityLocal: user.replyVisibility, loopVideoLocal: user.loopVideo, - loopVideoSilentOnlyLocal: user.loopVideoSilentOnly, muteWordsString: user.muteWords.join('\n'), autoLoadLocal: user.autoLoad, streamingLocal: user.streaming, @@ -58,13 +58,16 @@ const settings = { stopGifs: user.stopGifs, webPushNotificationsLocal: user.webPushNotifications, + loopVideoSilentOnlyLocal: user.loopVideosSilentOnly, loopSilentAvailable: // Firefox Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || // Chrome-likes Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') || // Future spec, still not supported in Nightly 63 as of 08/2018 - Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks') + Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'), + playVideosInline: user.playVideosInline, + useContainFit: user.useContainFit } }, components: { @@ -96,6 +99,9 @@ const settings = { hideNsfwLocal (value) { this.$store.dispatch('setOption', { name: 'hideNsfw', value }) }, + useOneClickNsfw (value) { + this.$store.dispatch('setOption', { name: 'useOneClickNsfw', value }) + }, preloadImage (value) { this.$store.dispatch('setOption', { name: 'preloadImage', value }) }, @@ -157,6 +163,12 @@ const settings = { webPushNotificationsLocal (value) { this.$store.dispatch('setOption', { name: 'webPushNotifications', value }) if (value) this.$store.dispatch('registerPushNotifications') + }, + playVideosInline (value) { + this.$store.dispatch('setOption', { name: 'playVideosInline', value }) + }, + useContainFit (value) { + this.$store.dispatch('setOption', { name: 'useContainFit', value }) } } } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 39125009..08d659d6 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -123,6 +123,10 @@ <input :disabled="!hideNsfwLocal" type="checkbox" id="preloadImage" v-model="preloadImage"> <label for="preloadImage">{{$t('settings.preload_images')}}</label> </li> + <li> + <input type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw"> + <label for="useOneClickNsfw">{{$t('settings.use_one_click_nsfw')}}</label> + </li> </ul> <li> <input type="checkbox" id="stopGifs" v-model="stopGifs"> @@ -141,6 +145,14 @@ </li> </ul> </li> + <li> + <input type="checkbox" id="playVideosInline" v-model="playVideosInline"> + <label for="playVideosInline">{{$t('settings.play_videos_inline')}}</label> + </li> + <li> + <input type="checkbox" id="useContainFit" v-model="useContainFit"> + <label for="useContainFit">{{$t('settings.use_contain_fit')}}</label> + </li> </ul> </div> diff --git a/src/components/status/status.js b/src/components/status/status.js index b14a74ec..2d485616 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -5,9 +5,11 @@ import DeleteButton from '../delete_button/delete_button.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCardContent from '../user_card_content/user_card_content.vue' import StillImage from '../still-image/still-image.vue' +import Gallery from '../gallery/gallery.vue' import { filter, find } from 'lodash' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import fileType from 'src/services/file_type/file_type.service' const Status = { name: 'Status', @@ -35,7 +37,8 @@ const Status = { expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' ? !this.$store.state.instance.collapseMessageWithSubject : !this.$store.state.config.collapseMessageWithSubject, - betterShadow: this.$store.state.interface.browserSupport.cssFilter + betterShadow: this.$store.state.interface.browserSupport.cssFilter, + maxAttachments: 9 } }, computed: { @@ -205,12 +208,31 @@ const Status = { }, attachmentSize () { if ((this.$store.state.config.hideAttachments && !this.inConversation) || - (this.$store.state.config.hideAttachmentsInConv && this.inConversation)) { + (this.$store.state.config.hideAttachmentsInConv && this.inConversation) || + (this.status.attachments.length > this.maxAttachments)) { return 'hide' } else if (this.compact) { return 'small' } return 'normal' + }, + galleryTypes () { + if (this.attachmentSize === 'hide') { + return [] + } + return this.$store.state.config.playVideosInline + ? ['image'] + : ['image', 'video'] + }, + galleryAttachments () { + return this.status.attachments.filter( + file => fileType.fileMatchesSomeType(this.galleryTypes, file) + ) + }, + nonGalleryAttachments () { + return this.status.attachments.filter( + file => !fileType.fileMatchesSomeType(this.galleryTypes, file) + ) } }, components: { @@ -220,7 +242,8 @@ const Status = { DeleteButton, PostStatusForm, UserCardContent, - StillImage + StillImage, + Gallery }, methods: { visibilityIcon (visibility) { @@ -295,6 +318,10 @@ const Status = { }, generateUserProfileLink (id, name) { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) + }, + setMedia () { + const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments + return () => this.$store.dispatch('setMedia', attachments) } }, watch: { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 5c956467..c1800d64 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -93,9 +93,23 @@ <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a> </div> - <div v-if='status.attachments && !hideSubjectStatus' class='attachments media-body'> - <attachment :size="attachmentSize" :status-id="status.id" :nsfw="nsfwClickthrough" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id"> - </attachment> + <div v-if="status.attachments && !hideSubjectStatus" class="attachments media-body"> + <attachment + class="non-gallery" + v-for="attachment in nonGalleryAttachments" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + :attachment="attachment" + :allowPlay="true" + :setMedia="setMedia()" + :key="attachment.id" + /> + <gallery + v-if="galleryAttachments.length > 0" + :nsfw="nsfwClickthrough" + :attachments="galleryAttachments" + :setMedia="setMedia()" + /> </div> <div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'> @@ -561,7 +575,7 @@ a.unmute { } } -@media all and (max-width: 960px) { +@media all and (max-width: 800px) { .status-el { .retweet-info { .avatar { diff --git a/src/components/video_attachment/video_attachment.js b/src/components/video_attachment/video_attachment.js new file mode 100644 index 00000000..76b19a02 --- /dev/null +++ b/src/components/video_attachment/video_attachment.js @@ -0,0 +1,31 @@ + +const VideoAttachment = { + props: ['attachment', 'controls'], + data () { + return { + loopVideo: this.$store.state.config.loopVideo + } + }, + methods: { + onVideoDataLoad (e) { + const target = e.srcElement || e.target + if (typeof target.webkitAudioDecodedByteCount !== 'undefined') { + // non-zero if video has audio track + if (target.webkitAudioDecodedByteCount > 0) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly + } + } else if (typeof target.mozHasAudio !== 'undefined') { + // true if video has audio track + if (target.mozHasAudio) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly + } + } else if (typeof target.audioTracks !== 'undefined') { + if (target.audioTracks.length > 0) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly + } + } + } + } +} + +export default VideoAttachment diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue new file mode 100644 index 00000000..68de201e --- /dev/null +++ b/src/components/video_attachment/video_attachment.vue @@ -0,0 +1,11 @@ +<template> + <video class="video" + @loadeddata="onVideoDataLoad" + :src="attachment.url" + :loop="loopVideo" + :controls="controls" + playsinline + /> +</template> + +<script src="./video_attachment.js"></script> |
