Compare commits
1 commit
develop
...
feature/vi
Author | SHA1 | Date | |
---|---|---|---|
|
baeba38acb |
20 changed files with 1639 additions and 123 deletions
1
assets/static/video/plyr.svg
Normal file
1
assets/static/video/plyr.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.6 KiB |
33
frontend/package-lock.json
generated
33
frontend/package-lock.json
generated
|
@ -64,6 +64,7 @@
|
|||
"mocha": "^10.2.0",
|
||||
"node-storage-shim": "^2.0.1",
|
||||
"photoswipe": "^4.1.3",
|
||||
"plyr": "^3.7.8",
|
||||
"postcss": "^8.4.20",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-loader": "^7.0.2",
|
||||
|
@ -4656,6 +4657,11 @@
|
|||
"resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
|
||||
"integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg=="
|
||||
},
|
||||
"node_modules/custom-event-polyfill": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
||||
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w=="
|
||||
},
|
||||
"node_modules/date-format": {
|
||||
"version": "4.0.14",
|
||||
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
|
||||
|
@ -8390,6 +8396,11 @@
|
|||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loadjs": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.2.0.tgz",
|
||||
"integrity": "sha512-AgQGZisAlTPbTEzrHPb6q+NYBMD+DP9uvGSIjSUM5uG+0jG15cb8axWpxuOIqrmQjn6scaaH8JwloiP27b2KXA=="
|
||||
},
|
||||
"node_modules/loadware": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/loadware/-/loadware-2.0.0.tgz",
|
||||
|
@ -9525,6 +9536,18 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.7.8.tgz",
|
||||
"integrity": "sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.26.1",
|
||||
"custom-event-polyfill": "^1.0.7",
|
||||
"loadjs": "^4.2.0",
|
||||
"rangetouch": "^2.0.1",
|
||||
"url-polyfill": "^1.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/pofile": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/pofile/-/pofile-1.1.4.tgz",
|
||||
|
@ -10942,6 +10965,11 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA=="
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
|
@ -12661,6 +12689,11 @@
|
|||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/url-polyfill": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.12.tgz",
|
||||
"integrity": "sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A=="
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
"mocha": "^10.2.0",
|
||||
"node-storage-shim": "^2.0.1",
|
||||
"photoswipe": "^4.1.3",
|
||||
"plyr": "^3.7.8",
|
||||
"postcss": "^8.4.20",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-loader": "^7.0.2",
|
||||
|
|
|
@ -76,9 +76,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div v-if="player.show" class="video-viewer" @click.stop.prevent="closePlayer" @keydown.esc.stop.prevent="closePlayer">
|
||||
<p-video-player ref="player" :source="player.source" :poster="player.poster"
|
||||
:height="player.height" :width="player.width" :autoplay="player.autoplay" :loop="player.loop" @close="closePlayer">
|
||||
</p-video-player>
|
||||
<p-video-player ref="player" :videos="videos" :index="0" @close="closePlayer"></p-video-player>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -101,13 +99,14 @@ export default {
|
|||
canDownload: this.$config.allow("photos", "download") && this.$config.feature("download"),
|
||||
selection: this.$clipboard.selection,
|
||||
config: this.$config.values,
|
||||
item: new Thumb(),
|
||||
item: new Thumb(false),
|
||||
subscriptions: [],
|
||||
interval: false,
|
||||
slideshow: {
|
||||
active: false,
|
||||
next: 0,
|
||||
},
|
||||
videos: [],
|
||||
player: {
|
||||
show: false,
|
||||
loop: false,
|
||||
|
@ -175,28 +174,23 @@ export default {
|
|||
},
|
||||
onPlay() {
|
||||
if (this.item && this.item.Playable) {
|
||||
new Photo().find(this.item.UID).then((video) => this.openPlayer(video));
|
||||
new Photo().find(this.item.UID).then((photo) => this.openPlayer(photo));
|
||||
}
|
||||
},
|
||||
openPlayer(video) {
|
||||
if (!video) {
|
||||
openPlayer(photo) {
|
||||
if (!photo) {
|
||||
this.$notify.error(this.$gettext("No video selected"));
|
||||
return;
|
||||
}
|
||||
|
||||
const params = video.videoParams();
|
||||
const video = photo.video();
|
||||
|
||||
if (params.error) {
|
||||
this.$notify.error(params.error);
|
||||
if (!video || video.Error) {
|
||||
this.$notify.error(this.$gettext("Not Found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set video parameters.
|
||||
this.player.loop = params.loop;
|
||||
this.player.width = params.width;
|
||||
this.player.height = params.height;
|
||||
this.player.poster = params.poster;
|
||||
this.player.source = params.uri;
|
||||
this.videos = [video];
|
||||
|
||||
// Play video.
|
||||
this.player.show = true;
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
<template>
|
||||
<div class="video-wrapper" :style="style">
|
||||
<video :key="source" ref="video" class="video-player" :height="height" :width="width" :autoplay="autoplay"
|
||||
:style="style" :poster="poster" :loop="loop" preload="auto" controls playsinline @click.stop
|
||||
<div class="video-wrapper" :style="style" @click.stop.prevent>
|
||||
<video :key="source" ref="video" class="video-player"
|
||||
preload="auto" controls autoplay playsinline @click.stop
|
||||
@keydown.esc.stop.prevent="$emit('close')">
|
||||
<source :src="source">
|
||||
<source :src="video.url()">
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Video from "model/video";
|
||||
import Plyr from 'plyr';
|
||||
|
||||
export default {
|
||||
name: "PVideoPlayer",
|
||||
props: {
|
||||
|
@ -19,13 +22,13 @@ export default {
|
|||
},
|
||||
poster: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: false,
|
||||
default: ""
|
||||
},
|
||||
source: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: ""
|
||||
index: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
|
@ -59,38 +62,127 @@ export default {
|
|||
error: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
}
|
||||
},
|
||||
videos: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
album: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const c = this.$config;
|
||||
|
||||
return {
|
||||
refresh: false,
|
||||
style: `width: 90vw; height: 90vh`,
|
||||
source: "",
|
||||
video: new Video(false),
|
||||
player: false,
|
||||
current: this.index,
|
||||
options: {
|
||||
iconUrl: `${c.staticUri}/video/plyr.svg`,
|
||||
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'download', 'settings', 'airplay', 'fullscreen'],
|
||||
settings: ['loop'],
|
||||
captions: { active: false, language: 'auto', update: false },
|
||||
hideControls: true,
|
||||
enabled: true,
|
||||
autoplay: true,
|
||||
clickToPlay: true,
|
||||
disableContextMenu: false,
|
||||
resetOnEnd: true,
|
||||
toggleInvert: true,
|
||||
blankVideo: `${c.staticUri}/video/404.mp4`,
|
||||
loop: {
|
||||
active: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
data: () => ({
|
||||
refresh: false,
|
||||
style: `width: 90vw; height: 90vh`,
|
||||
}),
|
||||
watch: {
|
||||
source: function (src) {
|
||||
if (src) {
|
||||
this.setSrc(src);
|
||||
}
|
||||
},
|
||||
videos: function () {
|
||||
this.onVideos();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.body.classList.add("player");
|
||||
this.render();
|
||||
window.addEventListener('keyup', this.onKeyUp);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.body.classList.remove("player");
|
||||
this.stop();
|
||||
},
|
||||
beforeUnmount() {
|
||||
try {
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
window.removeEventListener('keyup', this.onKeyUp);
|
||||
},
|
||||
methods: {
|
||||
videoEl() {
|
||||
return this.$el.getElementsByTagName('video')[0];
|
||||
},
|
||||
updateStyle() {
|
||||
// this.style = `width: ${this.width.toString()}px; height: ${this.height.toString()}px`;
|
||||
this.style = `width:100%; height: 100%`;
|
||||
if (!this.video || !this.video.Width) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = this.video.playerSize();
|
||||
|
||||
this.style = `width: ${size.width.toString()}px; height: ${size.height.toString()}px`;
|
||||
const plyrEl = this.$el.getElementsByClassName('plyr')[0];
|
||||
if (plyrEl) {
|
||||
plyrEl.style.cssText = this.style;
|
||||
}
|
||||
this.$el.style.cssText = this.style;
|
||||
},
|
||||
currentVideo() {
|
||||
if(typeof this.videos[this.current] === 'undefined') {
|
||||
return Video.notFound();
|
||||
}
|
||||
|
||||
return this.videos[this.current];
|
||||
},
|
||||
onVideos() {
|
||||
this.current = this.play;
|
||||
/*
|
||||
const video = this.currentVideo();
|
||||
|
||||
if (!video || video.Error) {
|
||||
this.$notify.error(this.$gettext("Not Found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set video parameters.
|
||||
const size = video.playerSize();
|
||||
this.loop = video.loop();
|
||||
this.width = size.width;
|
||||
this.height = size.height;
|
||||
this.poster = video.posterUrl();
|
||||
this.source = video.url(); */
|
||||
},
|
||||
render() {
|
||||
this.updateStyle();
|
||||
const el = this.videoEl();
|
||||
if (!el) return;
|
||||
|
||||
this.player = new Plyr(el, this.options);
|
||||
// this.player.on("ended", (ev) => { console.log("event.ended", ev); });
|
||||
|
||||
this.play();
|
||||
},
|
||||
fullscreen() {
|
||||
const el = this.videoEl();
|
||||
|
@ -98,12 +190,65 @@ export default {
|
|||
|
||||
el.requestFullscreen();
|
||||
},
|
||||
setSrc(src) {
|
||||
if (!src) {
|
||||
play() {
|
||||
this.video = this.currentVideo();
|
||||
|
||||
if (!this.video) {
|
||||
console.log("render: No current video");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStyle();
|
||||
this.player.source = {
|
||||
type: 'video',
|
||||
title: this.video.Title,
|
||||
sources: [
|
||||
{
|
||||
src: this.video.url(),
|
||||
// type: this.video.Mime,
|
||||
},
|
||||
],
|
||||
poster: this.video.posterUrl("fit_720"),
|
||||
};
|
||||
this.player.loop = this.videos.length === 0 && this.video.loop();
|
||||
this.player.play();
|
||||
},
|
||||
onPrev(ev) {
|
||||
if(this.videos.length < 2) {
|
||||
this.current = 0;
|
||||
return;
|
||||
} else if(this.current <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.player.stop();
|
||||
this.current--;
|
||||
this.play();
|
||||
},
|
||||
onNext(ev) {
|
||||
if(this.videos.length < 2) {
|
||||
this.current = 0;
|
||||
return;
|
||||
} else if(this.current >= this.videos.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.player.stop();
|
||||
this.current++;
|
||||
this.play();
|
||||
},
|
||||
onKeyUp(ev) {
|
||||
switch(ev.key) {
|
||||
case "Escape": this.$emit('close'); break;
|
||||
case "ArrowLeft": this.onPrev(ev); break;
|
||||
case "ArrowRight": this.onNext(ev); break;
|
||||
}
|
||||
},
|
||||
setSrc(src) {
|
||||
// console.log("setSrc", src);
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.videoEl();
|
||||
if (!el) return;
|
||||
|
@ -111,6 +256,17 @@ export default {
|
|||
el.src = src;
|
||||
el.poster = this.poster;
|
||||
el.play();
|
||||
|
||||
this.updateStyle();
|
||||
|
||||
// console.log("el", el);
|
||||
|
||||
/* this.player = new Plyr(el);
|
||||
console.log("this.player", this.player);
|
||||
this.player.play();
|
||||
this.player.source = src;
|
||||
this.player.poster = this.poster;
|
||||
*/
|
||||
},
|
||||
pause() {
|
||||
const el = this.videoEl();
|
||||
|
|
|
@ -26,6 +26,8 @@ Additional information can be found in our Developer Guide:
|
|||
@import url("vendor/icons/material-design-icons.css");
|
||||
@import url("../../node_modules/vuetify/dist/vuetify.min.css");
|
||||
@import url("../../node_modules/maplibre-gl/dist/maplibre-gl.css");
|
||||
@import url("../../node_modules/plyr/dist/plyr.css");
|
||||
@import url("variables.css");
|
||||
@import url("typography.css");
|
||||
@import url("wallpapers.css");
|
||||
@import url("scrollbar.css");
|
||||
|
|
4
frontend/src/css/variables.css
Normal file
4
frontend/src/css/variables.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
:root {
|
||||
--plyr-color-main: #2f303164; /* #d3cbfc66; */
|
||||
--plyr-video-control-background-hover: #2f303190;
|
||||
}
|
|
@ -10,7 +10,7 @@
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0,0,0,1);
|
||||
background-color: rgba(0,0,0,0.74);
|
||||
}
|
||||
|
||||
#photoprism .video-wrapper {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<div v-if="show" class="video-viewer" role="dialog" @click.stop.prevent="onClose" @keydown.esc.stop.prevent="onClose">
|
||||
<p-video-player v-show="show" ref="player" :source="source" :poster="poster" :height="height"
|
||||
:width="width" :autoplay="true" :loop="loop" @close="onClose"></p-video-player>
|
||||
<p-video-player v-show="show" ref="player" :videos="videos" :index="index" :album="album" @close="onClose"></p-video-player>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -18,7 +17,8 @@ export default {
|
|||
defaultHeight: 480,
|
||||
width: 640,
|
||||
height: 480,
|
||||
video: null,
|
||||
index: 0,
|
||||
videos: [],
|
||||
album: null,
|
||||
loop: false,
|
||||
subscriptions: [],
|
||||
|
@ -39,9 +39,16 @@ export default {
|
|||
methods: {
|
||||
onOpen(ev, params) {
|
||||
const fullscreen = !!params.fullscreen;
|
||||
const hasQueue = params.videos && params.videos.length > 0;
|
||||
|
||||
this.video = params.video;
|
||||
this.album = params.album;
|
||||
this.videos = hasQueue ? params.videos : [];
|
||||
this.album = params.album ? params.album : null;
|
||||
|
||||
if(params.index && params.index < this.videos.length) {
|
||||
this.index = params.index;
|
||||
} else {
|
||||
this.index = 0;
|
||||
}
|
||||
|
||||
this.play(fullscreen);
|
||||
},
|
||||
|
@ -58,25 +65,11 @@ export default {
|
|||
this.show = false;
|
||||
},
|
||||
play(fullscreen) {
|
||||
if (!this.video) {
|
||||
this.$notify.error(this.$gettext("No video selected"));
|
||||
if (!this.videos) {
|
||||
this.$notify.error(this.$gettext("No videos found to play"));
|
||||
return;
|
||||
}
|
||||
|
||||
const params = this.video.videoParams();
|
||||
|
||||
if (params.error) {
|
||||
this.$notify.error(params.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set video parameters.
|
||||
this.loop = params.loop;
|
||||
this.width = params.width;
|
||||
this.height = params.height;
|
||||
this.poster = params.poster;
|
||||
this.source = params.uri;
|
||||
|
||||
// Play video.
|
||||
this.show = true;
|
||||
|
||||
|
|
|
@ -24,13 +24,14 @@ Additional information can be found in our Developer Guide:
|
|||
*/
|
||||
|
||||
import RestModel from "model/rest";
|
||||
import Video from "model/video";
|
||||
import { MediaAnimated, MediaImage } from "model/photo";
|
||||
import Api from "common/api";
|
||||
import { DateTime } from "luxon";
|
||||
import Util from "common/util";
|
||||
import { config } from "app/session";
|
||||
import { $gettext } from "common/vm";
|
||||
import download from "common/download";
|
||||
import { MediaImage } from "./photo";
|
||||
|
||||
export class File extends RestModel {
|
||||
getDefaults() {
|
||||
|
@ -128,7 +129,7 @@ export class File extends RestModel {
|
|||
return `${config.contentUri}/t/${this.Hash}/${config.previewToken}/${size}`;
|
||||
}
|
||||
|
||||
getDownloadUrl() {
|
||||
downloadUrl() {
|
||||
return `${config.apiUri}/dl/${this.Hash}?t=${config.downloadToken}`;
|
||||
}
|
||||
|
||||
|
@ -137,7 +138,7 @@ export class File extends RestModel {
|
|||
return;
|
||||
}
|
||||
|
||||
download(this.getDownloadUrl(), this.baseName(this.Name));
|
||||
download(this.downloadUrl(), this.baseName(this.Name));
|
||||
}
|
||||
|
||||
calculateSize(width, height) {
|
||||
|
@ -287,6 +288,48 @@ export class File extends RestModel {
|
|||
return Api.delete(this.getPhotoResource() + "/like");
|
||||
}
|
||||
|
||||
isPlayable() {
|
||||
if (this.MediaType === MediaAnimated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.Video;
|
||||
}
|
||||
|
||||
video() {
|
||||
let width = this.Width;
|
||||
let height = this.Height;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
width = 640;
|
||||
height = 480;
|
||||
}
|
||||
|
||||
return new Video({
|
||||
UID: this.UID,
|
||||
PhotoUID: this.PhotoUID,
|
||||
Hash: this.Hash,
|
||||
PosterHash: this.Hash,
|
||||
Title: this.Name,
|
||||
Description: "",
|
||||
TakenAt: this.TakenAt,
|
||||
Favorite: false,
|
||||
Playable: this.isPlayable(),
|
||||
HDR: this.HDR,
|
||||
Mime: this.Mime,
|
||||
Type: this.Video ? "video" : "animated",
|
||||
Codec: this.Codec,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Duration: this.Duration,
|
||||
FPS: this.FPS,
|
||||
Frames: this.Frames,
|
||||
Projection: this.Projection,
|
||||
ColorProfile: this.ColorProfile,
|
||||
Error: this.Error,
|
||||
});
|
||||
}
|
||||
|
||||
static getCollectionResource() {
|
||||
return "files";
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import memoizeOne from "memoize-one";
|
|||
|
||||
import RestModel from "model/rest";
|
||||
import File from "model/file";
|
||||
import Video from "model/video";
|
||||
import Marker from "model/marker";
|
||||
import Api from "common/api";
|
||||
import { DateTime } from "luxon";
|
||||
|
@ -396,13 +397,7 @@ export class Photo extends RestModel {
|
|||
return files.some((f) => f.Video);
|
||||
});
|
||||
|
||||
videoParams() {
|
||||
const uri = this.videoUrl();
|
||||
|
||||
if (!uri) {
|
||||
return { error: "no video selected" };
|
||||
}
|
||||
|
||||
video() {
|
||||
let main = this.mainFile();
|
||||
let file = this.videoFile();
|
||||
|
||||
|
@ -410,44 +405,37 @@ export class Photo extends RestModel {
|
|||
file = main;
|
||||
}
|
||||
|
||||
const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
|
||||
const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
|
||||
let width = file.Width > 0 ? file.Width : main.Width;
|
||||
let height = file.Height > 0 ? file.Height : main.Height;
|
||||
|
||||
let actualWidth = 640;
|
||||
let actualHeight = 480;
|
||||
|
||||
if (file.Width > 0) {
|
||||
actualWidth = file.Width;
|
||||
} else if (main && main.Width > 0) {
|
||||
actualWidth = main.Width;
|
||||
if (width <= 0 || height <= 0) {
|
||||
width = 640;
|
||||
height = 480;
|
||||
}
|
||||
|
||||
if (file.Height > 0) {
|
||||
actualHeight = file.Height;
|
||||
} else if (main && main.Height > 0) {
|
||||
actualHeight = main.Height;
|
||||
}
|
||||
|
||||
let width = actualWidth;
|
||||
let height = actualHeight;
|
||||
|
||||
if (vw < width + 80) {
|
||||
let newWidth = vw - 90;
|
||||
height = Math.round(newWidth * (actualHeight / actualWidth));
|
||||
width = newWidth;
|
||||
}
|
||||
|
||||
if (vh < height + 100) {
|
||||
let newHeight = vh - 160;
|
||||
width = Math.round(newHeight * (actualWidth / actualHeight));
|
||||
height = newHeight;
|
||||
}
|
||||
|
||||
const loop = this.Type === MediaAnimated || (file.Duration >= 0 && file.Duration <= 5000000000);
|
||||
const poster = this.thumbnailUrl("fit_720");
|
||||
const error = false;
|
||||
|
||||
return { width, height, loop, poster, uri, error };
|
||||
return new Video({
|
||||
UID: file.UID ? file.UID : this.FileUID,
|
||||
PhotoUID: this.UID,
|
||||
Hash: file.Hash ? file.Hash : this.Hash,
|
||||
PosterHash: this.mainFileHash(),
|
||||
Title: this.Title,
|
||||
TakenAtLocal: this.TakenAtLocal,
|
||||
Description: this.Description,
|
||||
Favorite: this.Favorite,
|
||||
Playable: this.isPlayable(),
|
||||
HDR: file.HDR,
|
||||
Mime: file.Mime,
|
||||
Type: this.Type,
|
||||
Codec: file.Codec,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Duration: file.Duration,
|
||||
FPS: file.FPS,
|
||||
Frames: file.Frames,
|
||||
Projection: file.Projection,
|
||||
ColorProfile: file.ColorProfile,
|
||||
Error: file.Error,
|
||||
});
|
||||
}
|
||||
|
||||
videoFile() {
|
||||
|
@ -600,7 +588,7 @@ export class Photo extends RestModel {
|
|||
return `${contentUri}/t/${hash}/${previewToken}/${size}`;
|
||||
});
|
||||
|
||||
getDownloadUrl() {
|
||||
downloadUrl() {
|
||||
return `${config.apiUri}/dl/${this.mainFileHash()}?t=${config.downloadToken}`;
|
||||
}
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ export class Thumb extends Model {
|
|||
}
|
||||
}
|
||||
|
||||
static thumbNotFound() {
|
||||
static notFound() {
|
||||
const result = {
|
||||
UID: "",
|
||||
Title: $gettext("Not Found"),
|
||||
|
@ -112,7 +112,7 @@ export class Thumb extends Model {
|
|||
}
|
||||
|
||||
if (!photo || !photo.Hash) {
|
||||
return this.thumbNotFound();
|
||||
return this.notFound();
|
||||
}
|
||||
|
||||
const result = {
|
||||
|
@ -144,7 +144,7 @@ export class Thumb extends Model {
|
|||
|
||||
static fromFile(photo, file) {
|
||||
if (!photo || !file || !file.Hash) {
|
||||
return this.thumbNotFound();
|
||||
return this.notFound();
|
||||
}
|
||||
|
||||
const result = {
|
||||
|
|
274
frontend/src/model/video.js
Normal file
274
frontend/src/model/video.js
Normal file
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
|
||||
Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
|
||||
*/
|
||||
|
||||
import Model from "model.js";
|
||||
import Api from "common/api";
|
||||
import { $gettext } from "common/vm";
|
||||
import File from "model/file";
|
||||
import Photo from "model/photo";
|
||||
import {
|
||||
CodecAv1,
|
||||
CodecHvc1,
|
||||
CodecOGV,
|
||||
CodecVP8,
|
||||
CodecVP9,
|
||||
FormatAv1,
|
||||
FormatAvc,
|
||||
FormatHevc,
|
||||
FormatWebM,
|
||||
MediaAnimated,
|
||||
} from "model/photo";
|
||||
import { config } from "app/session";
|
||||
import { canUseOGV, canUseVP8, canUseVP9, canUseAv1, canUseWebM, canUseHevc } from "common/caniuse";
|
||||
|
||||
export class Video extends Model {
|
||||
getDefaults() {
|
||||
return {
|
||||
UID: "",
|
||||
PhotoUID: "",
|
||||
Hash: "",
|
||||
PosterHash: "",
|
||||
Title: "",
|
||||
Description: "",
|
||||
TakenAt: "",
|
||||
Favorite: false,
|
||||
Playable: false,
|
||||
HDR: false,
|
||||
Mime: "",
|
||||
Type: "",
|
||||
Codec: "",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
Duration: 0,
|
||||
FPS: 0,
|
||||
Frames: 0,
|
||||
Projection: "",
|
||||
ColorProfile: "",
|
||||
Error: "",
|
||||
};
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.UID ? this.UID : this.PhotoUID;
|
||||
}
|
||||
|
||||
hasId() {
|
||||
return !!this.getId();
|
||||
}
|
||||
|
||||
toggleLike() {
|
||||
this.Favorite = !this.Favorite;
|
||||
|
||||
if (this.Favorite) {
|
||||
return Api.post("photos/" + this.PhotoUID + "/like");
|
||||
} else {
|
||||
return Api.delete("photos/" + this.PhotoUID + "/like");
|
||||
}
|
||||
}
|
||||
|
||||
url() {
|
||||
let hash = this.Hash ? this.Hash : this.PosterHash;
|
||||
|
||||
if (!hash) {
|
||||
return `${config.staticUri}/video/404.mp4`;
|
||||
}
|
||||
|
||||
if (this.Hash && (this.Codec || this.FileType)) {
|
||||
let videoFormat = FormatAvc;
|
||||
|
||||
if (canUseHevc && this.Codec === CodecHvc1) {
|
||||
videoFormat = FormatHevc;
|
||||
} else if (canUseOGV && this.Codec === CodecOGV) {
|
||||
videoFormat = CodecOGV;
|
||||
} else if (canUseVP8 && this.Codec === CodecVP8) {
|
||||
videoFormat = CodecVP8;
|
||||
} else if (canUseVP9 && this.Codec === CodecVP9) {
|
||||
videoFormat = CodecVP9;
|
||||
} else if (canUseAv1 && this.Codec === CodecAv1) {
|
||||
videoFormat = FormatAv1;
|
||||
} else if (canUseWebM && this.FileType === FormatWebM) {
|
||||
videoFormat = FormatWebM;
|
||||
}
|
||||
|
||||
return `${config.videoUri}/videos/${hash}/${config.previewToken}/${videoFormat}`;
|
||||
}
|
||||
|
||||
return `${config.videoUri}/videos/${hash}/${config.previewToken}/${FormatAvc}`;
|
||||
}
|
||||
|
||||
downloadUrl() {
|
||||
return `${config.apiUri}/dl/${this.Hash}?t=${config.downloadToken}`;
|
||||
}
|
||||
|
||||
posterUrl(size) {
|
||||
let hash = this.PosterHash ? this.PosterHash : this.Hash;
|
||||
|
||||
if (!size) {
|
||||
size = "fit_720";
|
||||
}
|
||||
|
||||
if (!hash) {
|
||||
return `${config.contentUri}/svg/video`;
|
||||
}
|
||||
|
||||
return `${config.contentUri}/t/${hash}/${config.previewToken}/${size}`;
|
||||
}
|
||||
|
||||
loop() {
|
||||
return this.Type === MediaAnimated || (this.Duration >= 0 && this.Duration <= 5000000000);
|
||||
}
|
||||
|
||||
playerSize() {
|
||||
const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
|
||||
const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
|
||||
|
||||
let actualWidth = this.Width;
|
||||
let actualHeight = this.Height;
|
||||
|
||||
let width = actualWidth;
|
||||
let height = actualHeight;
|
||||
|
||||
if (vw < width + 70) {
|
||||
let newWidth = vw - 80;
|
||||
height = Math.round(newWidth * (actualHeight / actualWidth));
|
||||
width = newWidth;
|
||||
}
|
||||
|
||||
if (vh < height + 100) {
|
||||
let newHeight = vh - 140;
|
||||
width = Math.round(newHeight * (actualWidth / actualHeight));
|
||||
height = newHeight;
|
||||
}
|
||||
|
||||
if (!width || !height) {
|
||||
width = 640;
|
||||
height = 480;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
static notFound() {
|
||||
const result = new this();
|
||||
result.Title = $gettext("Not Found");
|
||||
result.Error = "not found";
|
||||
return result;
|
||||
}
|
||||
|
||||
static fromPhotos(photos, photosIndex) {
|
||||
let videos = [];
|
||||
let index = 0;
|
||||
|
||||
if (!photosIndex) {
|
||||
photosIndex = 0;
|
||||
}
|
||||
|
||||
const n = photos.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const video = this.fromPhoto(photos[i]);
|
||||
if (video && !video.Error) {
|
||||
videos.push(video);
|
||||
if (photosIndex > i) {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { videos, index };
|
||||
}
|
||||
|
||||
static fromPhoto(photo) {
|
||||
if (!photo || !photo.Hash) {
|
||||
return this.notFound();
|
||||
}
|
||||
|
||||
if (!(photo instanceof Photo)) {
|
||||
photo = new Photo(photo);
|
||||
}
|
||||
|
||||
return photo.video();
|
||||
}
|
||||
|
||||
static fromFile(photo, file) {
|
||||
if (!file || !file.Hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
file = new File(file);
|
||||
}
|
||||
|
||||
if (!file.isPlayable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const video = file.video();
|
||||
|
||||
if (photo) {
|
||||
video.Title = photo.Title;
|
||||
video.Description = photo.Description;
|
||||
video.Favorite = photo.Favorite;
|
||||
}
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
static wrap(data) {
|
||||
return data.map((values) => new this(values));
|
||||
}
|
||||
|
||||
static fromFiles(photos) {
|
||||
let result = [];
|
||||
|
||||
if (!photos || !photos.length) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const n = photos.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p = photos[i];
|
||||
|
||||
if (!p.Files || !p.Files.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let j = 0; j < p.Files.length; j++) {
|
||||
let f = p.Files[j];
|
||||
|
||||
let video = this.fromFile(p, f);
|
||||
|
||||
if (video && !video.Error) {
|
||||
result.push(video);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default Video;
|
|
@ -52,8 +52,9 @@
|
|||
import {Photo, MediaLive, MediaRaw, MediaVideo, MediaAnimated} from "model/photo";
|
||||
import Album from "model/album";
|
||||
import Thumb from "model/thumb";
|
||||
import Event from "pubsub-js";
|
||||
import Video from "model/video";
|
||||
import Viewer from "common/viewer";
|
||||
import Event from "pubsub-js";
|
||||
|
||||
export default {
|
||||
name: 'PPageAlbumPhotos',
|
||||
|
@ -241,7 +242,8 @@ export default {
|
|||
*/
|
||||
if (preferVideo && selected.Type === MediaLive || selected.Type === MediaVideo || selected.Type === MediaAnimated) {
|
||||
if (selected.isPlayable()) {
|
||||
this.$viewer.play({video: selected, album: this.album});
|
||||
const play = Video.fromPhotos(this.results, index);
|
||||
this.$viewer.play({videos: play.videos, index: play.index, album: this.albums});
|
||||
} else {
|
||||
this.$viewer.show(Thumb.fromPhotos(this.results), index);
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
<script>
|
||||
import {MediaAnimated, MediaLive, MediaRaw, MediaVideo, Photo} from "model/photo";
|
||||
import Thumb from "model/thumb";
|
||||
import Video from "model/video";
|
||||
import Viewer from "common/viewer";
|
||||
import Event from "pubsub-js";
|
||||
|
||||
|
@ -323,7 +324,7 @@ export default {
|
|||
*/
|
||||
if (preferVideo && selected.Type === MediaLive || selected.Type === MediaVideo || selected.Type === MediaAnimated) {
|
||||
if (selected.isPlayable()) {
|
||||
this.$viewer.play({video: selected});
|
||||
this.$viewer.play(Video.fromPhotos(this.results, index));
|
||||
} else {
|
||||
this.$viewer.show(Thumb.fromPhotos(this.results), index);
|
||||
}
|
||||
|
|
|
@ -310,6 +310,8 @@ Mock.onDelete("api/v1/link/5").reply(200, "delete success", mockHeaders);
|
|||
|
||||
Mock.onPost("api/v1/photos/55/like").reply(200, { status: "ok" }, mockHeaders);
|
||||
Mock.onDelete("api/v1/photos/55/like").reply(200, { status: "ok" }, mockHeaders);
|
||||
Mock.onPost("api/v1/photos/prqjmzr1jlmr4mpb/like").reply(200, { status: "ok" }, mockHeaders);
|
||||
Mock.onDelete("api/v1/photos/prqjmzr1jlmr4mpb/like").reply(200, { status: "ok" }, mockHeaders);
|
||||
Mock.onGet("api/v1/albums/5").reply(200, { UID: "5" }, mockHeaders);
|
||||
Mock.onPut("api/v1/photos/5").reply(200, { UID: "5" }, mockHeaders);
|
||||
Mock.onDelete("api/v1/photos/abc123/like").reply(200, { status: "ok" }, mockHeaders);
|
||||
|
|
|
@ -120,7 +120,7 @@ describe("model/file", () => {
|
|||
Name: "1/2/IMG123.jpg",
|
||||
};
|
||||
const file = new File(values);
|
||||
assert.equal(file.getDownloadUrl("abc"), "/api/v1/dl/54ghtfd?t=2lbh9x09");
|
||||
assert.equal(file.downloadUrl("abc"), "/api/v1/dl/54ghtfd?t=2lbh9x09");
|
||||
});
|
||||
|
||||
it("should not download as hash is missing", () => {
|
||||
|
|
|
@ -120,7 +120,7 @@ describe("model/photo", () => {
|
|||
it("should get photo download url", () => {
|
||||
const values = { ID: 5, Title: "Crazy Cat", Hash: 345982 };
|
||||
const photo = new Photo(values);
|
||||
const result = photo.getDownloadUrl();
|
||||
const result = photo.downloadUrl();
|
||||
assert.equal(result, "/api/v1/dl/345982?t=2lbh9x09");
|
||||
});
|
||||
|
||||
|
@ -636,11 +636,17 @@ describe("model/photo", () => {
|
|||
],
|
||||
};
|
||||
const photo3 = new Photo(values3);
|
||||
const result = photo3.videoParams();
|
||||
assert.equal(result.height, "463");
|
||||
assert.equal(result.width, "695");
|
||||
assert.equal(result.loop, false);
|
||||
assert.equal(result.uri, "/api/v1/videos/1xxbgdt55/public/avc");
|
||||
|
||||
const video = photo3.video();
|
||||
assert.equal(video.Height, 600);
|
||||
assert.equal(video.Width, 900);
|
||||
assert.equal(video.loop(), false);
|
||||
assert.equal(video.url(), "/api/v1/videos/1xxbgdt55/public/avc");
|
||||
|
||||
const playerSize = video.playerSize();
|
||||
assert.equal(playerSize.height, 470);
|
||||
assert.equal(playerSize.width, 705);
|
||||
|
||||
const values = {
|
||||
ID: 11,
|
||||
UID: "ABC127",
|
||||
|
@ -667,12 +673,17 @@ describe("model/photo", () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
|
||||
const photo = new Photo(values);
|
||||
const result2 = photo.videoParams();
|
||||
assert.equal(result2.height, "440");
|
||||
assert.equal(result2.width, "440");
|
||||
assert.equal(result2.loop, false);
|
||||
assert.equal(result2.uri, "/api/v1/videos/1xxbgdt55/public/avc");
|
||||
const video2 = photo.video();
|
||||
assert.equal(video2.Height, 5000);
|
||||
assert.equal(video2.Width, 5000);
|
||||
assert.equal(video2.loop(), false);
|
||||
assert.equal(video2.url(), "/api/v1/videos/1xxbgdt55/public/avc");
|
||||
|
||||
const playerSize2 = video2.playerSize();
|
||||
assert.equal(playerSize2.height, 460);
|
||||
assert.equal(playerSize2.width, 460);
|
||||
});
|
||||
|
||||
it("should return videofile", () => {
|
||||
|
|
|
@ -66,8 +66,8 @@ describe("model/thumb", () => {
|
|||
assert.equal(thumb.Favorite, true);
|
||||
});
|
||||
|
||||
it("should return thumb not found", () => {
|
||||
const result = Thumb.thumbNotFound();
|
||||
it("should return not placeholder", () => {
|
||||
const result = Thumb.notFound();
|
||||
assert.equal(result.UID, "");
|
||||
assert.equal(result.Favorite, false);
|
||||
});
|
||||
|
|
1011
frontend/tests/unit/model/video_test.js
Normal file
1011
frontend/tests/unit/model/video_test.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue