diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.js | 2 | ||||
| -rw-r--r-- | src/App.scss | 8 | ||||
| -rw-r--r-- | src/App.vue | 1 | ||||
| -rw-r--r-- | src/assets/nsfw.png | bin | 17071 -> 35104 bytes | |||
| -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 | ||||
| -rw-r--r-- | src/i18n/en.json | 3 | ||||
| -rw-r--r-- | src/i18n/fi.json | 182 | ||||
| -rw-r--r-- | src/main.js | 4 | ||||
| -rw-r--r-- | src/modules/media_viewer.js | 39 | ||||
| -rw-r--r-- | src/services/file_type/file_type.service.js | 31 |
22 files changed, 677 insertions, 98 deletions
@@ -6,6 +6,7 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance import FeaturesPanel from './components/features_panel/features_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import ChatPanel from './components/chat_panel/chat_panel.vue' +import MediaModal from './components/media_modal/media_modal.vue' import SideDrawer from './components/side_drawer/side_drawer.vue' import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils' @@ -20,6 +21,7 @@ export default { FeaturesPanel, WhoToFollowPanel, ChatPanel, + MediaModal, SideDrawer }, data: () => ({ diff --git a/src/App.scss b/src/App.scss index ba8770e2..47086006 100644 --- a/src/App.scss +++ b/src/App.scss @@ -499,7 +499,7 @@ nav { } .main { - flex-basis: 60%; + flex-basis: 50%; flex-grow: 1; flex-shrink: 1; } @@ -533,7 +533,7 @@ nav { } } -@media all and (min-width: 960px) { +@media all and (min-width: 800px) { body { overflow-y: scroll; } @@ -617,7 +617,7 @@ nav { color: $fallback--faint; color: var(--faint, $fallback--faint); } -@media all and (min-width: 959px) { +@media all and (min-width: 800px) { .logo { opacity: 1 !important; } @@ -681,7 +681,7 @@ nav { } } -@media all and (max-width: 959px) { +@media all and (max-width: 800px) { .mobile-hidden { display: none; } diff --git a/src/App.vue b/src/App.vue index feadb009..833608ea 100644 --- a/src/App.vue +++ b/src/App.vue @@ -41,6 +41,7 @@ <router-view></router-view> </transition> </div> + <media-modal></media-modal> </div> <chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel> </div> diff --git a/src/assets/nsfw.png b/src/assets/nsfw.png Binary files differindex 42749033..d2513776 100644 --- a/src/assets/nsfw.png +++ b/src/assets/nsfw.png 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..7e972026 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="allowPlay ? undefined : 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 cf9bf5d9..f428ead3 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -197,7 +197,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 13e79dd0..9730eded 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> diff --git a/src/i18n/en.json b/src/i18n/en.json index 3ff98ab0..a719cb05 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -132,6 +132,7 @@ "hide_attachments_in_tl": "Hide attachments in timeline", "hide_isp": "Hide instance-specific panel", "preload_images": "Preload images", + "use_one_click_nsfw": "Open NSFW attachments with just one click", "hide_post_stats": "Hide post statistics (e.g. the number of favorites)", "hide_user_stats": "Hide user statistics (e.g. the number of followers)", "import_followers_from_a_csv_file": "Import follows from a csv file", @@ -148,6 +149,8 @@ "lock_account_description": "Restrict your account to approved followers only", "loop_video": "Loop videos", "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", + "play_videos_inline": "Play videos directly on timeline", + "use_contain_fit": "Don't crop the attachment in thumbnails", "name": "Name", "name_bio": "Name & Bio", "new_password": "New password", diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 08cfb617..969d5a9e 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -1,4 +1,16 @@ { + "chat": { + "title": "Chat" + }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Media-välityspalvelin", + "scope_options": "Näkyvyyden rajaus", + "text_limit": "Tekstin pituusraja", + "title": "Ominaisuudet", + "who_to_follow": "Seurausehdotukset" + }, "finder": { "error_fetching_user": "Virhe hakiessa käyttäjää", "find_user": "Hae käyttäjä" @@ -9,85 +21,243 @@ }, "login": { "login": "Kirjaudu sisään", + "description": "Kirjaudu sisään OAuthilla", "logout": "Kirjaudu ulos", "password": "Salasana", - "placeholder": "esim. lain", + "placeholder": "esim. Seppo", "register": "Rekisteröidy", "username": "Käyttäjänimi" }, "nav": { + "about": "Tietoja", + "back": "Takaisin", + "chat": "Paikallinen Chat", + "friend_requests": "Seurauspyynnöt", "mentions": "Maininnat", + "dms": "Yksityisviestit", "public_tl": "Julkinen Aikajana", "timeline": "Aikajana", - "twkn": "Koko Tunnettu Verkosto" + "twkn": "Koko Tunnettu Verkosto", + "user_search": "Käyttäjähaku", + "who_to_follow": "Seurausehdotukset", + "preferences": "Asetukset" }, "notifications": { + "broken_favorite": "Viestiä ei löydetty...", "favorited_you": "tykkäsi viestistäsi", "followed_you": "seuraa sinua", + "load_older": "Lataa vanhempia ilmoituksia", "notifications": "Ilmoitukset", "read": "Lue!", "repeated_you": "toisti viestisi" }, "post_status": { + "new_status": "Uusi viesti", + "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi", + "account_not_locked_warning_link": "lukittu", + "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi", + "content_type": { + "plain_text": "Tavallinen teksti" + }, + "content_warning": "Aihe (valinnainen)", "default": "Tulin juuri saunasta.", - "posting": "Lähetetään" + "direct_warning": "Tämä viesti näkyy vain mainituille käyttäjille.", + "posting": "Lähetetään", + "scope": { + "direct": "Yksityisviesti - Näkyy vain mainituille käyttäjille", + "private": "Vain-seuraajille - Näkyy vain seuraajillesi", + "public": "Julkinen - Näkyy julkisilla aikajanoilla", + "unlisted": "Listaamaton - Ei näy julkisilla aikajanoilla" + } }, "registration": { "bio": "Kuvaus", "email": "Sähköposti", "fullname": "Koko nimi", "password_confirm": "Salasanan vahvistaminen", - "registration": "Rekisteröityminen" + "registration": "Rekisteröityminen", + "token": "Kutsuvaltuus", + "captcha": "Varmenne", + "new_captcha": "Paina kuvaa saadaksesi uuden varmenteen", + "validations": { + "username_required": "ei voi olla tyhjä", + "fullname_required": "ei voi olla tyhjä", + "email_required": "ei voi olla tyhjä", + "password_required": "ei voi olla tyhjä", + "password_confirmation_required": "ei voi olla tyhjä", + "password_confirmation_match": "pitää vastata salasanaa" + } }, "settings": { + "attachmentRadius": "Liitteet", "attachments": "Liitteet", "autoload": "Lataa vanhempia viestejä automaattisesti ruudun pohjalla", "avatar": "Profiilikuva", + "avatarAltRadius": "Profiilikuvat (ilmoitukset)", + "avatarRadius": "Profiilikuvat", "background": "Tausta", "bio": "Kuvaus", + "btnRadius": "Napit", + "cBlue": "Sininen (Vastaukset, seuraukset)", + "cGreen": "Vihreä (Toistot)", + "cOrange": "Oranssi (Tykkäykset)", + "cRed": "Punainen (Peruminen)", + "change_password": "Vaihda salasana", + "change_password_error": "Virhe vaihtaessa salasanaa.", + "changed_password": "Salasana vaihdettu!", + "collapse_subject": "Minimoi viestit, joille on asetettu aihe", + "composing": "Viestien laatiminen", + "confirm_new_password": "Vahvista uusi salasana", "current_avatar": "Nykyinen profiilikuvasi", + "current_password": "Nykyinen salasana", "current_profile_banner": "Nykyinen julisteesi", + "data_import_export_tab": "Tietojen tuonti / vienti", + "default_vis": "Oletusnäkyvyysrajaus", + "delete_account": "Poista tili", + "delete_account_description": "Poista tilisi ja viestisi pysyvästi.", + "delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.", + "delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.", + "export_theme": "Tallenna teema", "filtering": "Suodatus", "filtering_explanation": "Kaikki viestit, jotka sisältävät näitä sanoja, suodatetaan. Yksi sana per rivi.", + "follow_export": "Seurausten vienti", + "follow_export_button": "Vie seurauksesi CSV-tiedostoon", + "follow_export_processing": "Käsitellään, sinua pyydetään lataamaan tiedosto hetken päästä", + "follow_import": "Seurausten tuonti", + "follow_import_error": "Virhe tuodessa seuraksia", + "follows_imported": "Seuraukset tuotu! Niiden käsittely vie hetken.", "foreground": "Korostus", + "general": "Yleinen", "hide_attachments_in_convo": "Piilota liitteet keskusteluissa", "hide_attachments_in_tl": "Piilota liitteet aikajanalla", + "hide_isp": "Piilota palvelimenkohtainen ruutu", + "preload_images": "Esilataa kuvat", + "use_one_click_nsfw": "Avaa NSFW-liitteet yhdellä painalluksella", + "hide_post_stats": "Piilota viestien statistiikka (esim. tykkäysten määrä)", + "hide_user_stats": "Piilota käyttäjien statistiikka (esim. seuraajien määrä)", + "import_followers_from_a_csv_file": "Tuo seuraukset CSV-tiedostosta", + "import_theme": "Tuo tallennettu teema", + "inputRadius": "Syöttökentät", + "checkboxRadius": "Valintalaatikot", + "instance_default": "(oletus: {value})", + "instance_default_simple": "(oletus)", + "interface": "Käyttöliittymä", + "interfaceLanguage": "Käyttöliittymän kieli", + "invalid_theme_imported": "Tuotu tallennettu teema on epäkelpo, muutoksia ei tehty nykyiseen teemaasi.", + "limited_availability": "Ei saatavilla selaimessasi", "links": "Linkit", + "lock_account_description": "Vain erikseen hyväksytyt käyttäjät voivat seurata tiliäsi", + "loop_video": "Uudelleentoista videot", + "loop_video_silent_only": "Uudelleentoista ainoastaan äänettömät videot (Video-\"giffit\")", + "play_videos_inline": "Toista videot suoraan aikajanalla", + "use_contain_fit": "Älä rajaa liitteitä esikatselussa", "name": "Nimi", "name_bio": "Nimi ja kuvaus", - "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse.", + "new_password": "Uusi salasana", + "notification_visibility": "Ilmoitusten näkyvyys", + "notification_visibility_follows": "Seuraukset", + "notification_visibility_likes": "Tykkäykset", + "notification_visibility_mentions": "Maininnat", + "notification_visibility_repeats": "Toistot", + "no_rich_text_description": "Älä näytä tekstin muotoilua.", + "hide_network_description": "Älä näytä seurauksiani tai seuraajiani", + "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse", + "panelRadius": "Ruudut", + "pause_on_unfocused": "Pysäytä automaattinen viestien näyttö välilehden ollessa pois fokuksesta", "presets": "Valmiit teemat", "profile_background": "Taustakuva", "profile_banner": "Juliste", + "profile_tab": "Profiili", + "radii_help": "Aseta reunojen pyöristys (pikseleinä)", + "replies_in_timeline": "Keskustelut aikajanalla", "reply_link_preview": "Keskusteluiden vastauslinkkien esikatselu", + "reply_visibility_all": "Näytä kaikki vastaukset", + "reply_visibility_following": "Näytä vain vastaukset minulle tai seuraamilleni käyttäjille", + "reply_visibility_self": "Näytä vain vastaukset minulle", + "saving_err": "Virhe tallentaessa asetuksia", + "saving_ok": "Asetukset tallennettu", + "security_tab": "Tietoturva", + "scope_copy": "Kopioi näkyvyysrajaus vastatessa (Yksityisviestit aina kopioivat)", "set_new_avatar": "Aseta uusi profiilikuva", "set_new_profile_background": "Aseta uusi taustakuva", "set_new_profile_banner": "Aseta uusi juliste", "settings": "Asetukset", + "subject_input_always_show": "Näytä aihe-kenttä", + "subject_line_behavior": "Aihe-kentän kopiointi", + "subject_line_email": "Kuten sähköposti: \"re: aihe\"", + "subject_line_mastodon": "Kopioi sellaisenaan", + "subject_line_noop": "Älä kopioi", + "stop_gifs": "Toista giffit vain kohdistaessa", "streaming": "Näytä uudet viestit automaattisesti ollessasi ruudun huipulla", "text": "Teksti", "theme": "Teema", "theme_help": "Käytä heksadesimaalivärejä muokataksesi väriteemaasi.", - "user_settings": "Käyttäjän asetukset" + "theme_help_v2_1": "Voit asettaa tiettyjen osien värin tai läpinäkyvyyden täyttämällä valintalaatikon, käytä \"Tyhjennä kaikki\"-nappia tyhjentääksesi kaiken.", + "theme_help_v2_2": "Ikonit kenttien alla ovat kontrasti-indikaattoreita, lisätietoa kohdistamalla. Käyttäessä läpinäkyvyyttä ne näyttävät pahimman skenaarion.", + "tooltipRadius": "Ohje- tai huomioviestit", + "user_settings": "Käyttäjän asetukset", + "values": { + "false": "pois päältä", + "true": "päällä" + } }, "timeline": { "collapse": "Sulje", "conversation": "Keskustelu", "error_fetching": "Virhe ladatessa viestejä", "load_older": "Lataa vanhempia viestejä", + "no_retweet_hint": "Viesti ei ole julkinen, eikä sitä voi toistaa", "repeated": "toisti", "show_new": "Näytä uudet", "up_to_date": "Ajantasalla" }, "user_card": { + "approve": "Hyväksy", + "block": "Estä", + "blocked": "Estetty!", + "deny": "Älä hyväksy", "follow": "Seuraa", + "follow_sent": "Pyyntö lähetetty!", + "follow_progress": "Pyydetään...", + "follow_again": "Lähetä pyyntö uudestaan", + "follow_unfollow": "Älä seuraa", "followees": "Seuraa", "followers": "Seuraajat", "following": "Seuraat!", "follows_you": "Seuraa sinua!", + "its_you": "Sinun tili!", "mute": "Hiljennä", "muted": "Hiljennetty", "per_day": "päivässä", + "remote_follow": "Seuraa muualta", "statuses": "Viestit" + }, + "user_profile": { + "timeline_title": "Käyttäjän aikajana" + }, + "who_to_follow": { + "more": "Lisää", + "who_to_follow": "Seurausehdotukset" + }, + "tool_tip": { + "media_upload": "Lataa tiedosto", + "repeat": "Toista", + "reply": "Vastaa", + "favorite": "Tykkää", + "user_settings": "Käyttäjäasetukset" + }, + "upload":{ + "error": { + "base": "Lataus epäonnistui.", + "file_too_big": "Tiedosto liian suuri [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Yritä uudestaan myöhemmin" + }, + "file_size_units": { + "B": "tavua", + "KiB": "kt", + "MiB": "Mt", + "GiB": "Gt", + "TiB": "Tt" + } } } diff --git a/src/main.js b/src/main.js index f87ef9da..adeb0550 100644 --- a/src/main.js +++ b/src/main.js @@ -10,6 +10,7 @@ import apiModule from './modules/api.js' import configModule from './modules/config.js' import chatModule from './modules/chat.js' import oauthModule from './modules/oauth.js' +import mediaViewerModule from './modules/media_viewer.js' import VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' @@ -62,7 +63,8 @@ createPersistedState(persistedStateOptions).then((persistedState) => { api: apiModule, config: configModule, chat: chatModule, - oauth: oauthModule + oauth: oauthModule, + mediaViewer: mediaViewerModule }, plugins: [persistedState, pushNotifications], strict: false // Socket modifies itself, let's ignore this for now. diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js new file mode 100644 index 00000000..a24b408d --- /dev/null +++ b/src/modules/media_viewer.js @@ -0,0 +1,39 @@ +import fileTypeService from '../services/file_type/file_type.service.js' + +const mediaViewer = { + state: { + media: [], + currentIndex: 0, + activated: false + }, + mutations: { + setMedia (state, media) { + state.media = media + }, + setCurrent (state, index) { + state.activated = true + state.currentIndex = index + }, + close (state) { + state.activated = false + } + }, + actions: { + setMedia ({ commit }, attachments) { + const media = attachments.filter(attachment => { + const type = fileTypeService.fileType(attachment.mimetype) + return type === 'image' || type === 'video' + }) + commit('setMedia', media) + }, + setCurrent ({ commit, state }, current) { + const index = state.media.indexOf(current) + commit('setCurrent', index || 0) + }, + closeMediaViewer ({ commit }) { + commit('close') + } + } +} + +export default mediaViewer diff --git a/src/services/file_type/file_type.service.js b/src/services/file_type/file_type.service.js index f543ec79..2a046bec 100644 --- a/src/services/file_type/file_type.service.js +++ b/src/services/file_type/file_type.service.js @@ -1,27 +1,32 @@ -const fileType = (typeString) => { - let type = 'unknown' - - if (typeString.match(/text\/html/)) { - type = 'html' +// TODO this func might as well take the entire file and use its mimetype +// or the entire service could be just mimetype service that only operates +// on mimetypes and not files. Currently the naming is confusing. +const fileType = mimetype => { + if (mimetype.match(/text\/html/)) { + return 'html' } - if (typeString.match(/image/)) { - type = 'image' + if (mimetype.match(/image/)) { + return 'image' } - if (typeString.match(/video/)) { - type = 'video' + if (mimetype.match(/video/)) { + return 'video' } - if (typeString.match(/audio/)) { - type = 'audio' + if (mimetype.match(/audio/)) { + return 'audio' } - return type + return 'unknown' } +const fileMatchesSomeType = (types, file) => + types.some(type => fileType(file.mimetype) === type) + const fileTypeService = { - fileType + fileType, + fileMatchesSomeType } export default fileTypeService |
