Add video player #17
Still need to index metadata. Work in progress. Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
parent
e634fd97a7
commit
bd3426ae51
30 changed files with 597 additions and 42 deletions
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
|
@ -4163,6 +4163,11 @@
|
|||
"entities": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"dom-walk": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||
},
|
||||
"domain-browser": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
|
||||
|
@ -6265,6 +6270,22 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
|
||||
"integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
|
||||
"requires": {
|
||||
"min-document": "^2.19.0",
|
||||
"process": "~0.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"process": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
|
||||
"integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8="
|
||||
}
|
||||
}
|
||||
},
|
||||
"global-modules": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
|
||||
|
@ -6453,6 +6474,22 @@
|
|||
"resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz",
|
||||
"integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ=="
|
||||
},
|
||||
"hls.js": {
|
||||
"version": "0.13.2",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.13.2.tgz",
|
||||
"integrity": "sha512-sIg2t4uGpWQLzuK1Iid9614WOKqxj4OYg+EbFbhhTDCsxpENBN+Du3yBFnoi+a83DuOOHdiQd1ydnti9loSGXw==",
|
||||
"requires": {
|
||||
"eventemitter3": "3.1.0",
|
||||
"url-toolkit": "^2.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter3": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz",
|
||||
"integrity": "sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"hmac-drbg": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
||||
|
@ -8120,6 +8157,14 @@
|
|||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
|
||||
},
|
||||
"mediaelement": {
|
||||
"version": "4.2.16",
|
||||
"resolved": "https://registry.npmjs.org/mediaelement/-/mediaelement-4.2.16.tgz",
|
||||
"integrity": "sha512-5GinxsRpVA36w6tAD6nTqVSiZ0LzIhqUrzD8wzOAtZPPM7NOwOBtz6Oa85VemS+3Jvoo38jM1RvNqwKYJBBxtQ==",
|
||||
"requires": {
|
||||
"global": "^4.3.1"
|
||||
}
|
||||
},
|
||||
"mem": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz",
|
||||
|
@ -8223,6 +8268,14 @@
|
|||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
|
||||
"integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
|
||||
},
|
||||
"min-document": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
|
||||
"integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
|
||||
"requires": {
|
||||
"dom-walk": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"mini-css-extract-plugin": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz",
|
||||
|
@ -9252,9 +9305,9 @@
|
|||
"integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs="
|
||||
},
|
||||
"postcss": {
|
||||
"version": "7.0.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.29.tgz",
|
||||
"integrity": "sha512-ba0ApvR3LxGvRMMiUa9n0WR4HjzcYm7tS+ht4/2Nd0NLtHpPIH77fuB9Xh1/yJVz9O/E/95Y/dn8ygWsyffXtw==",
|
||||
"version": "7.0.30",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.30.tgz",
|
||||
"integrity": "sha512-nu/0m+NtIzoubO+xdAlwZl/u5S5vi/y6BCsoL8D+8IxsD3XvBS8X4YEADNIVXKVuQvduiucnRv+vPIqj56EGMQ==",
|
||||
"requires": {
|
||||
"chalk": "^2.4.2",
|
||||
"source-map": "^0.6.1",
|
||||
|
@ -12532,6 +12585,11 @@
|
|||
"schema-utils": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"url-toolkit": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.1.6.tgz",
|
||||
"integrity": "sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw=="
|
||||
},
|
||||
"use": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
||||
|
|
|
@ -61,6 +61,7 @@
|
|||
"eventsource-polyfill": "^0.9.6",
|
||||
"file-loader": "^3.0.1",
|
||||
"friendly-errors-webpack-plugin": "^1.7.0",
|
||||
"hls.js": "^0.13.2",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"http-proxy-middleware": "^0.19.1",
|
||||
"i18n-iso-countries": "^5.3.0",
|
||||
|
@ -75,6 +76,7 @@
|
|||
"luxon": "^1.24.1",
|
||||
"mapbox-gl": "^1.10.0",
|
||||
"material-design-icons-iconfont": "^5.0.1",
|
||||
"mediaelement": "^4.2.16",
|
||||
"mini-css-extract-plugin": "^0.7.0",
|
||||
"minimist": ">=1.2.5",
|
||||
"mocha": "^6.2.3",
|
||||
|
@ -83,7 +85,7 @@
|
|||
"ora": "^3.4.0",
|
||||
"photoswipe": "^4.1.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"postcss": "^7.0.29",
|
||||
"postcss": "^7.0.30",
|
||||
"postcss-browser-reporter": "^0.6.0",
|
||||
"postcss-import": "^12.0.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
|
|
|
@ -21,12 +21,16 @@ import VueLuxon from "vue-luxon";
|
|||
import VueFilters from "vue2-filters";
|
||||
import VueFullscreen from "vue-fullscreen";
|
||||
import VueInfiniteScroll from "vue-infinite-scroll";
|
||||
import Hls from "hls.js";
|
||||
|
||||
// Initialize helpers
|
||||
const viewer = new Viewer();
|
||||
const clipboard = new Clipboard(window.localStorage, "photo_clipboard");
|
||||
const isPublic = config.get("public");
|
||||
|
||||
// HTTP Live Streaming (video support)
|
||||
window.Hls = Hls;
|
||||
|
||||
// Assign helpers to VueJS prototype
|
||||
Vue.prototype.$event = Event;
|
||||
Vue.prototype.$notify = Notify;
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import PNotify from "./p-notify.vue";
|
||||
import PNavigation from "./p-navigation.vue";
|
||||
import PLoadingBar from "./p-loading-bar.vue";
|
||||
import PPhotoSearch from "./p-photo-search.vue";
|
||||
import PVideoPlayer from "./p-video-player.vue";
|
||||
import PPhotoViewer from "./p-photo-viewer.vue";
|
||||
import PPhotoCards from "./p-photo-cards.vue";
|
||||
import PPhotoMosaic from "./p-photo-mosaic.vue";
|
||||
import PPhotoList from "./p-photo-list.vue";
|
||||
import PPhotoSearch from "./p-photo-search.vue";
|
||||
import PPhotoClipboard from "./p-photo-clipboard.vue";
|
||||
import PLabelClipboard from "./p-label-clipboard.vue";
|
||||
import PAlbumClipboard from "./p-album-clipboard.vue";
|
||||
import PAlbumToolbar from "./p-album-toolbar.vue";
|
||||
import PPhotoViewer from "./p-photo-viewer.vue";
|
||||
import PScrollTop from "./p-scroll-top.vue";
|
||||
|
||||
const components = {};
|
||||
|
@ -18,6 +19,7 @@ components.install = (Vue) => {
|
|||
Vue.component("p-notify", PNotify);
|
||||
Vue.component("p-navigation", PNavigation);
|
||||
Vue.component("p-loading-bar", PLoadingBar);
|
||||
Vue.component("p-video-player", PVideoPlayer);
|
||||
Vue.component("p-photo-viewer", PPhotoViewer);
|
||||
Vue.component("p-photo-cards", PPhotoCards);
|
||||
Vue.component("p-photo-mosaic", PPhotoMosaic);
|
||||
|
|
|
@ -269,6 +269,7 @@
|
|||
@confirm="upload.dialog = false"></p-upload-dialog>
|
||||
<p-photo-edit-dialog :show="edit.dialog" :selection="edit.selection" :index="edit.index" :album="edit.album"
|
||||
@close="edit.dialog = false"></p-photo-edit-dialog>
|
||||
<p-video-dialog ref="video" :play="video.play" :album="video.album"></p-video-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -288,16 +289,21 @@
|
|||
config: this.$config.values,
|
||||
page: this.$config.page,
|
||||
upload: {
|
||||
dialog: false,
|
||||
subscription: null,
|
||||
dialog: false,
|
||||
},
|
||||
edit: {
|
||||
dialog: false,
|
||||
subscription: null,
|
||||
dialog: false,
|
||||
album: null,
|
||||
selection: [],
|
||||
index: 0,
|
||||
},
|
||||
video: {
|
||||
subscription: null,
|
||||
album: null,
|
||||
play: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -334,6 +340,7 @@
|
|||
},
|
||||
created() {
|
||||
this.upload.subscription = Event.subscribe("dialog.upload", () => this.upload.dialog = true);
|
||||
|
||||
this.edit.subscription = Event.subscribe("dialog.edit", (ev, data) => {
|
||||
if (!this.edit.dialog) {
|
||||
this.edit.index = data.index;
|
||||
|
@ -342,10 +349,17 @@
|
|||
this.edit.dialog = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.video.subscription = Event.subscribe("dialog.video", (ev, data) => {
|
||||
this.video.play = data.play;
|
||||
this.video.album = data.album;
|
||||
this.$refs.video.show = true;
|
||||
});
|
||||
},
|
||||
destroyed() {
|
||||
Event.unsubscribe(this.upload.subscription);
|
||||
Event.unsubscribe(this.edit.subscription);
|
||||
Event.unsubscribe(this.video.subscription);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -45,13 +45,13 @@
|
|||
<v-progress-circular indeterminate color="accent lighten-5"></v-progress-circular>
|
||||
</v-layout>
|
||||
|
||||
<v-btn v-if="hidePrivate && photo.PhotoPrivate"
|
||||
<v-btn v-if="hidePrivate && photo.PhotoPrivate" :ripple="false"
|
||||
icon flat large absolute
|
||||
class="p-photo-private opacity-75">
|
||||
<v-icon color="white">lock</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="hover || selection.length && $clipboard.has(photo)"
|
||||
<v-btn v-if="hover || selection.length && $clipboard.has(photo)" :ripple="false"
|
||||
icon flat large absolute
|
||||
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
|
||||
@click.stop.prevent="onSelect($event, index)">
|
||||
|
@ -61,14 +61,19 @@
|
|||
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon flat large absolute
|
||||
<v-btn icon flat large absolute :ripple="false"
|
||||
:class="photo.PhotoFavorite ? 'p-photo-like opacity-75' : 'p-photo-like opacity-50'"
|
||||
@click.stop.prevent="photo.toggleLike()">
|
||||
<v-icon v-if="photo.PhotoFavorite" color="white" class="t-like t-on">favorite</v-icon>
|
||||
<v-icon v-else color="accent lighten-3" class="t-like t-off">favorite_border</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="photo.Files.length > 1"
|
||||
<v-btn v-if="photo.PhotoVideo" color="white" :ripple="false"
|
||||
outline large fab absolute class="p-photo-play opacity-75" :depressed="false"
|
||||
@click.stop.prevent="openPhoto(index, true)">
|
||||
<v-icon color="white" class="action-play">play_arrow</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else-if="photo.Files.length > 1" :ripple="false"
|
||||
icon flat large absolute class="p-photo-merged opacity-75"
|
||||
@click.stop.prevent="openPhoto(index, true)">
|
||||
<v-icon color="white" class="action-burst">burst_mode</v-icon>
|
||||
|
|
|
@ -28,10 +28,15 @@
|
|||
color="accent lighten-5"></v-progress-circular>
|
||||
</v-layout>
|
||||
|
||||
<v-btn v-if="selection.length && $clipboard.has(props.item)" :flat="true" :ripple="false"
|
||||
icon large absolute class="p-photo-select">
|
||||
<v-btn v-if="selection.length && $clipboard.has(props.item)" :ripple="false"
|
||||
flat icon large absolute class="p-photo-select">
|
||||
<v-icon color="white" class="t-select t-on">check_circle</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else-if="!selection.length && props.item.PhotoVideo" :ripple="false"
|
||||
flat icon large absolute class="p-photo-play opacity-75"
|
||||
@click.stop.prevent="openPhoto(props.index, true)">
|
||||
<v-icon color="white" class="action-play">play_arrow</v-icon>
|
||||
</v-btn>
|
||||
</v-img>
|
||||
</td>
|
||||
<td class="p-photo-desc p-pointer" @click.exact="editPhoto(props.index)" style="user-select: none;">
|
||||
|
@ -136,8 +141,14 @@
|
|||
} else {
|
||||
this.$clipboard.toggle(this.photos[index]);
|
||||
}
|
||||
} else {
|
||||
this.openPhoto(index, false);
|
||||
} else if(this.photos[index]) {
|
||||
let photo = this.photos[index];
|
||||
|
||||
if(photo.PhotoVideo) {
|
||||
this.openPhoto(index, true);
|
||||
} else {
|
||||
this.openPhoto(index, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onContextMenu(ev, index) {
|
||||
|
|
|
@ -44,13 +44,13 @@
|
|||
color="accent lighten-5"></v-progress-circular>
|
||||
</v-layout>
|
||||
|
||||
<v-btn v-if="hidePrivate && photo.PhotoPrivate"
|
||||
<v-btn v-if="hidePrivate && photo.PhotoPrivate" :ripple="false"
|
||||
icon flat small absolute
|
||||
class="p-photo-private opacity-75">
|
||||
<v-icon color="white">lock</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="hover || selection.length && $clipboard.has(photo)"
|
||||
<v-btn v-if="hover || selection.length && $clipboard.has(photo)" :ripple="false"
|
||||
icon flat small absolute
|
||||
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
|
||||
@click.stop.prevent="onSelect($event, index)">
|
||||
|
@ -60,14 +60,19 @@
|
|||
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon flat small absolute
|
||||
<v-btn icon flat small absolute :ripple="false"
|
||||
:class="photo.PhotoFavorite ? 'p-photo-like opacity-75' : 'p-photo-like opacity-50'"
|
||||
@click.stop.prevent="photo.toggleLike()">
|
||||
<v-icon v-if="photo.PhotoFavorite" color="white" class="t-like t-on">favorite</v-icon>
|
||||
<v-icon v-else color="accent lighten-3" class="t-like t-off">favorite_border</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="photo.Files.length > 1"
|
||||
<v-btn v-if="photo.PhotoVideo" color="white"
|
||||
outline fab absolute class="p-photo-play opacity-75" :depressed="false" :ripple="false"
|
||||
@click.stop.prevent="openPhoto(index, true)">
|
||||
<v-icon color="white" class="action-play">play_arrow</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else-if="photo.Files.length > 1" :ripple="false"
|
||||
icon flat small absolute class="p-photo-merged opacity-75"
|
||||
@click.stop.prevent="openPhoto(index, true)">
|
||||
<v-icon color="white" class="action-burst">burst_mode</v-icon>
|
||||
|
|
128
frontend/src/component/p-video-player.vue
Normal file
128
frontend/src/component/p-video-player.vue
Normal file
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<video class="p-video-player" ref="player" :height="height" :width="width" :autoplay="autoplay"
|
||||
:preload="preload"></video>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "mediaelement";
|
||||
|
||||
export default {
|
||||
name: "p-photo-player",
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
source: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: ""
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "auto"
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "auto"
|
||||
},
|
||||
preload: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "none"
|
||||
},
|
||||
autoplay: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
success: {
|
||||
type: Function,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
error: {
|
||||
type: Function,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
refresh: false,
|
||||
player: null,
|
||||
}),
|
||||
mounted() {
|
||||
this.render();
|
||||
},
|
||||
methods: {
|
||||
render() {
|
||||
const {MediaElementPlayer} = global;
|
||||
|
||||
const self = this;
|
||||
this.player = new MediaElementPlayer(this.$el, {
|
||||
videoWidth: this.width,
|
||||
videoHeight: this.height,
|
||||
pluginPath: '/static/build/',
|
||||
shimScriptAccess: 'always',
|
||||
forceLive: false,
|
||||
loop: false,
|
||||
stretching: false,
|
||||
autoplay: true,
|
||||
success: (mediaElement, originalNode, instance) => {
|
||||
instance.setSrc(self.source);
|
||||
this.success(mediaElement, originalNode, instance);
|
||||
mediaElement.addEventListener(Hls.Events.MEDIA_ATTACHED, function () {
|
||||
});
|
||||
},
|
||||
error: (e) => {
|
||||
// console.log(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
remove() {
|
||||
if (this.player) {
|
||||
this.player.remove();
|
||||
this.player = "";
|
||||
}
|
||||
},
|
||||
setSource(src) {
|
||||
if (!this.player) {
|
||||
console.log('source: player not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.player.height = this.height;
|
||||
this.player.width = this.width;
|
||||
this.player.videoHeight = this.height;
|
||||
this.player.videoWidth = this.width;
|
||||
this.player.setSrc(src);
|
||||
this.player.setPoster("");
|
||||
this.player.load();
|
||||
},
|
||||
pause() {
|
||||
if (this.player) {
|
||||
this.player.pause();
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.remove();
|
||||
},
|
||||
watch: {
|
||||
source: function (source) {
|
||||
if (source) {
|
||||
this.setSource(source);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -1,6 +1,7 @@
|
|||
@import url("../../node_modules/material-design-icons-iconfont/dist/material-design-icons.css");
|
||||
@import url("../../node_modules/vuetify/dist/vuetify.min.css");
|
||||
@import url("../../node_modules/mapbox-gl/dist/mapbox-gl.css");
|
||||
@import url("video.css");
|
||||
@import url("colorchange.css");
|
||||
@import url("maps.css");
|
||||
@import url("viewer.css");
|
||||
|
|
|
@ -11,9 +11,15 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
#photoprism .p-photo-list .p-photo-select {
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
#photoprism .p-photo-list .p-photo-select,
|
||||
#photoprism .p-photo-list .p-photo-play {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#photoprism .p-photo-mosaic .p-photo-private,
|
||||
|
@ -34,6 +40,17 @@
|
|||
left: 4px;
|
||||
}
|
||||
|
||||
#photoprism .p-photo-mosaic .p-photo-play,
|
||||
#photoprism .p-photo-cards .p-photo-play {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#photoprism .p-photo-mosaic .p-photo-like,
|
||||
#photoprism .p-photo-cards .p-photo-like {
|
||||
left: 4px;
|
||||
|
|
31
frontend/src/css/video.css
Normal file
31
frontend/src/css/video.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
@import url("../../node_modules/mediaelement/build/mediaelementplayer.min.css");
|
||||
|
||||
.mejs__container {
|
||||
min-width: auto !important;
|
||||
}
|
||||
.mejs__overlay-button {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
:not([style*='display: none']).mejs__controls {
|
||||
background: none !important;
|
||||
}
|
||||
.mejs__controls div{
|
||||
display: inline-block;
|
||||
}
|
||||
.mejs__time-rail {
|
||||
width: 50%;
|
||||
}
|
||||
.mejs__fullscreen > button {
|
||||
background-position: -80px 0 !important;
|
||||
}
|
||||
|
||||
#photoprism .p-video-dialog {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
#photoprism .p-video-player {
|
||||
overflow: hidden !important;
|
||||
}
|
|
@ -8,6 +8,7 @@ import PPhotoShareDialog from "./p-photo-share-dialog.vue";
|
|||
import PAlbumDeleteDialog from "./album/p-album-delete-dialog.vue";
|
||||
import PLabelDeleteDialog from "./label/p-label-delete-dialog.vue";
|
||||
import PUploadDialog from "./p-upload-dialog.vue";
|
||||
import PVideoDialog from "./p-video-dialog.vue";
|
||||
|
||||
const dialogs = {};
|
||||
|
||||
|
@ -22,6 +23,7 @@ dialogs.install = (Vue) => {
|
|||
Vue.component("p-album-delete-dialog", PAlbumDeleteDialog);
|
||||
Vue.component("p-label-delete-dialog", PLabelDeleteDialog);
|
||||
Vue.component("p-upload-dialog", PUploadDialog);
|
||||
Vue.component("p-video-dialog", PVideoDialog);
|
||||
};
|
||||
|
||||
export default dialogs;
|
||||
|
|
79
frontend/src/dialog/p-video-dialog.vue
Normal file
79
frontend/src/dialog/p-video-dialog.vue
Normal file
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<v-dialog lazy v-model="show" :scrollable="false" :max-width="width" class="p-video-dialog">
|
||||
<p-video-player v-show="show" ref="player" :source="source" :height="height.toString()" :width="width.toString()" :autoplay="true"></p-video-player>
|
||||
</v-dialog>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'p-video-dialog',
|
||||
props: {
|
||||
play: Object,
|
||||
album: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
source: "",
|
||||
defaultWidth: 640,
|
||||
defaultHeight: 480,
|
||||
width: 640,
|
||||
height: 480,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
load(video) {
|
||||
if (!video) {
|
||||
this.$notify.error("no video selected");
|
||||
return;
|
||||
}
|
||||
|
||||
let main = video.mainFile();
|
||||
let file = video.videoFile();
|
||||
let uri = video.videoUri();
|
||||
|
||||
if (!uri) {
|
||||
this.$notify.error("no video file found");
|
||||
return;
|
||||
}
|
||||
|
||||
if(file.FileWidth > 0) {
|
||||
this.width = file.FileWidth;
|
||||
} else if(main.FileWidth > 0) {
|
||||
this.width = main.FileWidth;
|
||||
} else {
|
||||
this.width = this.defaultWidth;
|
||||
}
|
||||
|
||||
if(window.innerWidth < (this.width + 50)) {
|
||||
this.width = window.innerWidth - 50;
|
||||
}
|
||||
|
||||
if(file.FileHeight > 0) {
|
||||
this.height = file.FileHeight;
|
||||
} else if(main.FileHeight > 0) {
|
||||
this.height = main.FileHeight;
|
||||
} else {
|
||||
this.height = this.defaultHeight;
|
||||
}
|
||||
|
||||
this.$el.style.height = this.height;
|
||||
this.$el.style.width = this.width;
|
||||
|
||||
this.source = uri;
|
||||
this.show = true;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
play: function (play) {
|
||||
if (play) {
|
||||
this.load(play);
|
||||
}
|
||||
},
|
||||
show: function(show) {
|
||||
if(!show) {
|
||||
this.$refs.player.pause();
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -25,8 +25,8 @@ class Photo extends RestModel {
|
|||
PhotoTitle: "",
|
||||
TitleSrc: "",
|
||||
PhotoFavorite: false,
|
||||
PhotoStory: false,
|
||||
PhotoPrivate: false,
|
||||
PhotoVideo: false,
|
||||
PhotoResolution: 0,
|
||||
PhotoQuality: 0,
|
||||
PhotoLat: 0.0,
|
||||
|
@ -111,6 +111,30 @@ class Photo extends RestModel {
|
|||
this.FileHeight = file.FileHeight;
|
||||
}
|
||||
|
||||
videoFile() {
|
||||
if (!this.Files) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let file = this.Files.find(f => f.FileType === "mp4");
|
||||
|
||||
if (!file) {
|
||||
file = this.Files.find(f => !!f.FileVideo);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
videoUri() {
|
||||
const file = this.videoFile()
|
||||
|
||||
if (!file) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "/api/v1/videos/" + file.FileHash + "/mp4"
|
||||
}
|
||||
|
||||
mainFile() {
|
||||
if (!this.Files) {
|
||||
return false;
|
||||
|
|
|
@ -137,11 +137,19 @@
|
|||
Event.publish("dialog.edit", {selection: selection, album: this.album, index: index});
|
||||
},
|
||||
openPhoto(index, showMerged) {
|
||||
if (showMerged) {
|
||||
if(!this.results[index]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (showMerged && this.results[index].PhotoVideo) {
|
||||
Event.publish("dialog.video", {play: this.results[index], album: null});
|
||||
} else if (showMerged) {
|
||||
this.$viewer.show(Thumb.fromFiles([this.results[index]]), 0)
|
||||
} else {
|
||||
this.$viewer.show(Thumb.fromPhotos(this.results), index);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
loadMore() {
|
||||
if (this.scrollDisabled) return;
|
||||
|
|
|
@ -174,7 +174,13 @@
|
|||
Event.publish("dialog.edit", {selection: selection, album: null, index: index});
|
||||
},
|
||||
openPhoto(index, showMerged) {
|
||||
if (showMerged) {
|
||||
if(!this.results[index]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (showMerged && this.results[index].PhotoVideo) {
|
||||
Event.publish("dialog.video", {play: this.results[index], album: null});
|
||||
} else if (showMerged) {
|
||||
this.$viewer.show(Thumb.fromFiles([this.results[index]]), 0)
|
||||
} else {
|
||||
this.$viewer.show(Thumb.fromPhotos(this.results), index);
|
||||
|
|
|
@ -48,8 +48,8 @@ const config = {
|
|||
},
|
||||
performance: {
|
||||
hints: isDev ? false : "error",
|
||||
maxEntrypointSize: 3000000,
|
||||
maxAssetSize: 3000000,
|
||||
maxEntrypointSize: 4000000,
|
||||
maxAssetSize: 4000000,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/query"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
|
@ -47,12 +45,21 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
fileName := path.Join(conf.OriginalsPath(), f.FileName)
|
||||
|
||||
if !fs.FileExists(fileName) {
|
||||
log.Errorf("photo: could not find original for %s", fileName)
|
||||
log.Errorf("photo: could not find original for %s", txt.Quote(f.FileName))
|
||||
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
|
||||
|
||||
// Set missing flag so that the file doesn't show up in search results anymore
|
||||
f.FileMissing = true
|
||||
entity.Db().Save(&f)
|
||||
|
||||
if err := f.Save(); err != nil {
|
||||
log.Errorf("photo: %s", err)
|
||||
} else if f.AllFilesMissing() {
|
||||
log.Infof("photo: deleting photo, all files missing for %s", txt.Quote(f.FileName))
|
||||
|
||||
if err := f.Photo.Delete(false); err != nil {
|
||||
log.Errorf("photo: %s", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -80,9 +87,9 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
}
|
||||
|
||||
if c.Query("download") != "" {
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", f.ShareFileName()))
|
||||
c.FileAttachment(thumbnail, f.ShareFileName())
|
||||
} else {
|
||||
c.File(thumbnail)
|
||||
}
|
||||
|
||||
c.File(thumbnail)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -12,6 +12,9 @@ var photoIconSvg = []byte(`
|
|||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
</svg>`)
|
||||
|
||||
var videoIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M0 0h24v24H0z" fill="none"/><path d="M10 8v8l5-4-5-4zm9-5H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>`)
|
||||
|
||||
var albumIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
|
||||
<path d="M0 0h24v24H0z" fill="none"/></svg>`)
|
||||
|
@ -35,6 +38,10 @@ func GetSvg(router *gin.RouterGroup) {
|
|||
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
|
||||
})
|
||||
|
||||
router.GET("/svg/video", func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
|
||||
})
|
||||
|
||||
router.GET("/svg/label", func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
|
||||
})
|
||||
|
|
68
internal/api/video.go
Normal file
68
internal/api/video.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/query"
|
||||
"github.com/photoprism/photoprism/internal/video"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// GET /api/v1/videos/:hash/:type
|
||||
//
|
||||
// Parameters:
|
||||
// hash: string The photo or video file hash as returned by the search API
|
||||
// type: string Video type
|
||||
func GetVideo(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.GET("/videos/:hash/:type", func(c *gin.Context) {
|
||||
fileHash := c.Param("hash")
|
||||
typeName := c.Param("type")
|
||||
|
||||
_, ok := video.Types[typeName]
|
||||
|
||||
if !ok {
|
||||
log.Errorf("video: invalid type %s", txt.Quote(typeName))
|
||||
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := query.FileByHash(fileHash)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("video: db error %s", err.Error())
|
||||
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
if f.FileError != "" {
|
||||
log.Errorf("video: file error %s", f.FileError)
|
||||
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
fileName := path.Join(conf.OriginalsPath(), f.FileName)
|
||||
|
||||
if !fs.FileExists(fileName) {
|
||||
log.Errorf("video: could not find file for %s", fileName)
|
||||
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
|
||||
|
||||
// Set missing flag so that the file doesn't show up in search results anymore
|
||||
f.FileMissing = true
|
||||
entity.Db().Save(&f)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("download") != "" {
|
||||
c.FileAttachment(fileName, f.ShareFileName())
|
||||
} else {
|
||||
c.File(fileName)
|
||||
}
|
||||
|
||||
return
|
||||
})
|
||||
}
|
|
@ -162,9 +162,9 @@ var GlobalFlags = []cli.Flag{
|
|||
EnvVar: "PHOTOPRISM_HEIFCONVERT_BIN",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "ffmpeg-bin",
|
||||
Usage: "ffmpeg executable `FILENAME`",
|
||||
Value: "ffmpeg",
|
||||
Name: "ffmpeg-bin",
|
||||
Usage: "ffmpeg executable `FILENAME`",
|
||||
Value: "ffmpeg",
|
||||
EnvVar: "PHOTOPRISM_FFMPEG_BIN",
|
||||
},
|
||||
cli.StringFlag{
|
||||
|
|
|
@ -113,3 +113,25 @@ func (m File) Changed(fileSize int64, fileModified time.Time) bool {
|
|||
func (m *File) Purge() error {
|
||||
return Db().Unscoped().Model(m).Updates(map[string]interface{}{"file_missing": true, "file_primary": false}).Error
|
||||
}
|
||||
|
||||
// AllFilesMissing returns true, if all files for the photo of this file are missing.
|
||||
func (m *File) AllFilesMissing() bool {
|
||||
count := 0
|
||||
|
||||
if err := Db().Model(&File{}).
|
||||
Where("photo_id = ? AND b.file_missing = 0", m.PhotoID).
|
||||
Count(&count).Error; err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
return count == 0
|
||||
}
|
||||
|
||||
// Save stored the file in the database using the default connection.
|
||||
func (m *File) Save() error {
|
||||
if err := Db().Save(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return Db().Model(m).Related(Photo{}).Error
|
||||
}
|
||||
|
|
|
@ -499,6 +499,19 @@ func (m *Photo) SetCoordinates(lat, lng float32, altitude int, source string) {
|
|||
m.LocationSrc = source
|
||||
}
|
||||
|
||||
// AllFilesMissing returns true, if all files for this photo are missing.
|
||||
func (m *Photo) AllFilesMissing() bool {
|
||||
count := 0
|
||||
|
||||
if err := Db().Model(&File{}).
|
||||
Where("photo_id = ? AND b.file_missing = 0", m.ID).
|
||||
Count(&count).Error; err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
return count == 0
|
||||
}
|
||||
|
||||
// Delete deletes the entity from the database.
|
||||
func (m *Photo) Delete(permanently bool) error {
|
||||
if permanently {
|
||||
|
|
|
@ -186,7 +186,6 @@ func TestConvert_ToJson(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
|
||||
func TestConvert_Start(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
|
|
|
@ -29,11 +29,11 @@ func Photos(f form.PhotoSearch) (results PhotosResults, count int, err error) {
|
|||
files.id AS file_id, files.file_uuid, files.file_primary, files.file_missing, files.file_name, files.file_hash,
|
||||
files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio,
|
||||
files.file_orientation, files.file_main_color, files.file_colors, files.file_luminance, files.file_chroma,
|
||||
files.file_diff,
|
||||
files.file_diff, files.file_video, files.file_length,
|
||||
cameras.camera_make, cameras.camera_model,
|
||||
lenses.lens_make, lenses.lens_model,
|
||||
places.loc_label, places.loc_city, places.loc_state, places.loc_country`).
|
||||
Joins("JOIN files ON files.photo_id = photos.id AND files.file_type = 'jpg' AND files.file_missing = 0 AND files.deleted_at IS NULL").
|
||||
Joins("JOIN files ON files.photo_id = photos.id AND files.file_missing = 0 AND files.deleted_at IS NULL AND (files.file_type = 'jpg' OR files.file_video)").
|
||||
Joins("JOIN cameras ON cameras.id = photos.camera_id").
|
||||
Joins("JOIN lenses ON lenses.id = photos.lens_id").
|
||||
Joins("JOIN places ON photos.place_id = places.id").
|
||||
|
|
|
@ -31,6 +31,7 @@ type PhotosResult struct {
|
|||
PhotoCountry string
|
||||
PhotoFavorite bool
|
||||
PhotoPrivate bool
|
||||
PhotoVideo bool
|
||||
PhotoLat float32
|
||||
PhotoLng float32
|
||||
PhotoAltitude int
|
||||
|
@ -65,6 +66,8 @@ type PhotosResult struct {
|
|||
FileUUID string
|
||||
FilePrimary bool
|
||||
FileMissing bool
|
||||
FileVideo bool
|
||||
FileLength time.Duration
|
||||
FileName string
|
||||
FileHash string
|
||||
FileType string
|
||||
|
|
|
@ -26,6 +26,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
api.GetPreview(v1, conf)
|
||||
api.GetThumbnail(v1, conf)
|
||||
api.GetDownload(v1, conf)
|
||||
api.GetVideo(v1, conf)
|
||||
api.CreateZip(v1, conf)
|
||||
api.DownloadZip(v1, conf)
|
||||
|
||||
|
|
|
@ -75,7 +75,9 @@ type Type struct {
|
|||
Options []ResampleOption
|
||||
}
|
||||
|
||||
var Types = map[string]Type{
|
||||
type TypeMap map[string]Type
|
||||
|
||||
var Types = TypeMap{
|
||||
"tile_50": {"tile_500", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
"tile_100": {"tile_500", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
"tile_224": {"tile_500", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
|
|
36
internal/video/video.go
Normal file
36
internal/video/video.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
This package encapsulates JPEG thumbnail generation.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
|
||||
https://github.com/photoprism/photoprism/wiki
|
||||
*/
|
||||
package video
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
var log = event.Log
|
||||
|
||||
type Type struct {
|
||||
Format fs.FileType
|
||||
Width int
|
||||
Height int
|
||||
Public bool
|
||||
}
|
||||
|
||||
type TypeMap map[string]Type
|
||||
|
||||
var TypeMP4 = Type{
|
||||
Format: fs.TypeMP4,
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
Public: true,
|
||||
}
|
||||
|
||||
var Types = TypeMap{
|
||||
"": TypeMP4,
|
||||
"mp4": TypeMP4,
|
||||
}
|
Loading…
Reference in a new issue