Compare commits

...

1 commit

Author SHA1 Message Date
Michael Mayer
baeba38acb UX: Proof-of-concept for new video player #1307 #3372
Signed-off-by: Michael Mayer <michael@photoprism.app>
2023-06-19 14:53:15 +02:00
20 changed files with 1639 additions and 123 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -64,6 +64,7 @@
"mocha": "^10.2.0", "mocha": "^10.2.0",
"node-storage-shim": "^2.0.1", "node-storage-shim": "^2.0.1",
"photoswipe": "^4.1.3", "photoswipe": "^4.1.3",
"plyr": "^3.7.8",
"postcss": "^8.4.20", "postcss": "^8.4.20",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
"postcss-loader": "^7.0.2", "postcss-loader": "^7.0.2",
@ -4656,6 +4657,11 @@
"resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
"integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==" "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": { "node_modules/date-format": {
"version": "4.0.14", "version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
@ -8390,6 +8396,11 @@
"json5": "lib/cli.js" "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": { "node_modules/loadware": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/loadware/-/loadware-2.0.0.tgz", "resolved": "https://registry.npmjs.org/loadware/-/loadware-2.0.0.tgz",
@ -9525,6 +9536,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/pofile": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/pofile/-/pofile-1.1.4.tgz", "resolved": "https://registry.npmjs.org/pofile/-/pofile-1.1.4.tgz",
@ -10942,6 +10965,11 @@
"node": ">= 0.6" "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": { "node_modules/raw-body": {
"version": "2.5.2", "version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
@ -12661,6 +12689,11 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/util": {
"version": "0.12.5", "version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",

View file

@ -75,6 +75,7 @@
"mocha": "^10.2.0", "mocha": "^10.2.0",
"node-storage-shim": "^2.0.1", "node-storage-shim": "^2.0.1",
"photoswipe": "^4.1.3", "photoswipe": "^4.1.3",
"plyr": "^3.7.8",
"postcss": "^8.4.20", "postcss": "^8.4.20",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
"postcss-loader": "^7.0.2", "postcss-loader": "^7.0.2",

View file

@ -76,9 +76,7 @@
</div> </div>
</div> </div>
<div v-if="player.show" class="video-viewer" @click.stop.prevent="closePlayer" @keydown.esc.stop.prevent="closePlayer"> <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" <p-video-player ref="player" :videos="videos" :index="0" @close="closePlayer"></p-video-player>
:height="player.height" :width="player.width" :autoplay="player.autoplay" :loop="player.loop" @close="closePlayer">
</p-video-player>
</div> </div>
</div> </div>
</template> </template>
@ -101,13 +99,14 @@ export default {
canDownload: this.$config.allow("photos", "download") && this.$config.feature("download"), canDownload: this.$config.allow("photos", "download") && this.$config.feature("download"),
selection: this.$clipboard.selection, selection: this.$clipboard.selection,
config: this.$config.values, config: this.$config.values,
item: new Thumb(), item: new Thumb(false),
subscriptions: [], subscriptions: [],
interval: false, interval: false,
slideshow: { slideshow: {
active: false, active: false,
next: 0, next: 0,
}, },
videos: [],
player: { player: {
show: false, show: false,
loop: false, loop: false,
@ -175,28 +174,23 @@ export default {
}, },
onPlay() { onPlay() {
if (this.item && this.item.Playable) { 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) { openPlayer(photo) {
if (!video) { if (!photo) {
this.$notify.error(this.$gettext("No video selected")); this.$notify.error(this.$gettext("No video selected"));
return; return;
} }
const params = video.videoParams(); const video = photo.video();
if (params.error) { if (!video || video.Error) {
this.$notify.error(params.error); this.$notify.error(this.$gettext("Not Found"));
return; return;
} }
// Set video parameters. this.videos = [video];
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;
// Play video. // Play video.
this.player.show = true; this.player.show = true;

View file

@ -1,14 +1,17 @@
<template> <template>
<div class="video-wrapper" :style="style"> <div class="video-wrapper" :style="style" @click.stop.prevent>
<video :key="source" ref="video" class="video-player" :height="height" :width="width" :autoplay="autoplay" <video :key="source" ref="video" class="video-player"
:style="style" :poster="poster" :loop="loop" preload="auto" controls playsinline @click.stop preload="auto" controls autoplay playsinline @click.stop
@keydown.esc.stop.prevent="$emit('close')"> @keydown.esc.stop.prevent="$emit('close')">
<source :src="source"> <source :src="video.url()">
</video> </video>
</div> </div>
</template> </template>
<script> <script>
import Video from "model/video";
import Plyr from 'plyr';
export default { export default {
name: "PVideoPlayer", name: "PVideoPlayer",
props: { props: {
@ -19,13 +22,13 @@ export default {
}, },
poster: { poster: {
type: String, type: String,
required: true, required: false,
default: "" default: ""
}, },
source: { index: {
type: String, type: Number,
required: true, required: false,
default: "" default: 0
}, },
width: { width: {
type: Number, type: Number,
@ -59,38 +62,127 @@ export default {
error: { error: {
type: Function, type: Function,
default: () => {}, 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: { watch: {
source: function (src) { source: function (src) {
if (src) { if (src) {
this.setSrc(src); this.setSrc(src);
} }
}, },
videos: function () {
this.onVideos();
},
}, },
mounted() { mounted() {
document.body.classList.add("player"); document.body.classList.add("player");
this.render(); this.render();
window.addEventListener('keyup', this.onKeyUp);
}, },
beforeDestroy() { beforeDestroy() {
document.body.classList.remove("player"); document.body.classList.remove("player");
this.stop(); this.stop();
}, },
beforeUnmount() {
try {
if (this.player) {
this.player.destroy();
}
} catch (e) {
console.log(e);
}
window.removeEventListener('keyup', this.onKeyUp);
},
methods: { methods: {
videoEl() { videoEl() {
return this.$el.getElementsByTagName('video')[0]; return this.$el.getElementsByTagName('video')[0];
}, },
updateStyle() { updateStyle() {
// this.style = `width: ${this.width.toString()}px; height: ${this.height.toString()}px`; if (!this.video || !this.video.Width) {
this.style = `width:100%; height: 100%`; 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; 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() { 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() { fullscreen() {
const el = this.videoEl(); const el = this.videoEl();
@ -98,12 +190,65 @@ export default {
el.requestFullscreen(); el.requestFullscreen();
}, },
setSrc(src) { play() {
if (!src) { this.video = this.currentVideo();
if (!this.video) {
console.log("render: No current video");
return; return;
} }
this.updateStyle(); 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(); const el = this.videoEl();
if (!el) return; if (!el) return;
@ -111,6 +256,17 @@ export default {
el.src = src; el.src = src;
el.poster = this.poster; el.poster = this.poster;
el.play(); 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() { pause() {
const el = this.videoEl(); const el = this.videoEl();

View file

@ -26,6 +26,8 @@ Additional information can be found in our Developer Guide:
@import url("vendor/icons/material-design-icons.css"); @import url("vendor/icons/material-design-icons.css");
@import url("../../node_modules/vuetify/dist/vuetify.min.css"); @import url("../../node_modules/vuetify/dist/vuetify.min.css");
@import url("../../node_modules/maplibre-gl/dist/maplibre-gl.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("typography.css");
@import url("wallpapers.css"); @import url("wallpapers.css");
@import url("scrollbar.css"); @import url("scrollbar.css");

View file

@ -0,0 +1,4 @@
:root {
--plyr-color-main: #2f303164; /* #d3cbfc66; */
--plyr-video-control-background-hover: #2f303190;
}

View file

@ -10,7 +10,7 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
background-color: rgba(0,0,0,1); background-color: rgba(0,0,0,0.74);
} }
#photoprism .video-wrapper { #photoprism .video-wrapper {

View file

@ -1,7 +1,6 @@
<template> <template>
<div v-if="show" class="video-viewer" role="dialog" @click.stop.prevent="onClose" @keydown.esc.stop.prevent="onClose"> <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" <p-video-player v-show="show" ref="player" :videos="videos" :index="index" :album="album" @close="onClose"></p-video-player>
:width="width" :autoplay="true" :loop="loop" @close="onClose"></p-video-player>
</div> </div>
</template> </template>
<script> <script>
@ -18,7 +17,8 @@ export default {
defaultHeight: 480, defaultHeight: 480,
width: 640, width: 640,
height: 480, height: 480,
video: null, index: 0,
videos: [],
album: null, album: null,
loop: false, loop: false,
subscriptions: [], subscriptions: [],
@ -39,9 +39,16 @@ export default {
methods: { methods: {
onOpen(ev, params) { onOpen(ev, params) {
const fullscreen = !!params.fullscreen; const fullscreen = !!params.fullscreen;
const hasQueue = params.videos && params.videos.length > 0;
this.video = params.video; this.videos = hasQueue ? params.videos : [];
this.album = params.album; 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); this.play(fullscreen);
}, },
@ -58,25 +65,11 @@ export default {
this.show = false; this.show = false;
}, },
play(fullscreen) { play(fullscreen) {
if (!this.video) { if (!this.videos) {
this.$notify.error(this.$gettext("No video selected")); this.$notify.error(this.$gettext("No videos found to play"));
return; 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. // Play video.
this.show = true; this.show = true;

View file

@ -24,13 +24,14 @@ Additional information can be found in our Developer Guide:
*/ */
import RestModel from "model/rest"; import RestModel from "model/rest";
import Video from "model/video";
import { MediaAnimated, MediaImage } from "model/photo";
import Api from "common/api"; import Api from "common/api";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import Util from "common/util"; import Util from "common/util";
import { config } from "app/session"; import { config } from "app/session";
import { $gettext } from "common/vm"; import { $gettext } from "common/vm";
import download from "common/download"; import download from "common/download";
import { MediaImage } from "./photo";
export class File extends RestModel { export class File extends RestModel {
getDefaults() { getDefaults() {
@ -128,7 +129,7 @@ export class File extends RestModel {
return `${config.contentUri}/t/${this.Hash}/${config.previewToken}/${size}`; return `${config.contentUri}/t/${this.Hash}/${config.previewToken}/${size}`;
} }
getDownloadUrl() { downloadUrl() {
return `${config.apiUri}/dl/${this.Hash}?t=${config.downloadToken}`; return `${config.apiUri}/dl/${this.Hash}?t=${config.downloadToken}`;
} }
@ -137,7 +138,7 @@ export class File extends RestModel {
return; return;
} }
download(this.getDownloadUrl(), this.baseName(this.Name)); download(this.downloadUrl(), this.baseName(this.Name));
} }
calculateSize(width, height) { calculateSize(width, height) {
@ -287,6 +288,48 @@ export class File extends RestModel {
return Api.delete(this.getPhotoResource() + "/like"); 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() { static getCollectionResource() {
return "files"; return "files";
} }

View file

@ -27,6 +27,7 @@ import memoizeOne from "memoize-one";
import RestModel from "model/rest"; import RestModel from "model/rest";
import File from "model/file"; import File from "model/file";
import Video from "model/video";
import Marker from "model/marker"; import Marker from "model/marker";
import Api from "common/api"; import Api from "common/api";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
@ -396,13 +397,7 @@ export class Photo extends RestModel {
return files.some((f) => f.Video); return files.some((f) => f.Video);
}); });
videoParams() { video() {
const uri = this.videoUrl();
if (!uri) {
return { error: "no video selected" };
}
let main = this.mainFile(); let main = this.mainFile();
let file = this.videoFile(); let file = this.videoFile();
@ -410,44 +405,37 @@ export class Photo extends RestModel {
file = main; file = main;
} }
const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); let width = file.Width > 0 ? file.Width : main.Width;
const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); let height = file.Height > 0 ? file.Height : main.Height;
let actualWidth = 640; if (width <= 0 || height <= 0) {
let actualHeight = 480; width = 640;
height = 480;
if (file.Width > 0) {
actualWidth = file.Width;
} else if (main && main.Width > 0) {
actualWidth = main.Width;
} }
if (file.Height > 0) { return new Video({
actualHeight = file.Height; UID: file.UID ? file.UID : this.FileUID,
} else if (main && main.Height > 0) { PhotoUID: this.UID,
actualHeight = main.Height; Hash: file.Hash ? file.Hash : this.Hash,
} PosterHash: this.mainFileHash(),
Title: this.Title,
let width = actualWidth; TakenAtLocal: this.TakenAtLocal,
let height = actualHeight; Description: this.Description,
Favorite: this.Favorite,
if (vw < width + 80) { Playable: this.isPlayable(),
let newWidth = vw - 90; HDR: file.HDR,
height = Math.round(newWidth * (actualHeight / actualWidth)); Mime: file.Mime,
width = newWidth; Type: this.Type,
} Codec: file.Codec,
Width: width,
if (vh < height + 100) { Height: height,
let newHeight = vh - 160; Duration: file.Duration,
width = Math.round(newHeight * (actualWidth / actualHeight)); FPS: file.FPS,
height = newHeight; Frames: file.Frames,
} Projection: file.Projection,
ColorProfile: file.ColorProfile,
const loop = this.Type === MediaAnimated || (file.Duration >= 0 && file.Duration <= 5000000000); Error: file.Error,
const poster = this.thumbnailUrl("fit_720"); });
const error = false;
return { width, height, loop, poster, uri, error };
} }
videoFile() { videoFile() {
@ -600,7 +588,7 @@ export class Photo extends RestModel {
return `${contentUri}/t/${hash}/${previewToken}/${size}`; return `${contentUri}/t/${hash}/${previewToken}/${size}`;
}); });
getDownloadUrl() { downloadUrl() {
return `${config.apiUri}/dl/${this.mainFileHash()}?t=${config.downloadToken}`; return `${config.apiUri}/dl/${this.mainFileHash()}?t=${config.downloadToken}`;
} }

View file

@ -68,7 +68,7 @@ export class Thumb extends Model {
} }
} }
static thumbNotFound() { static notFound() {
const result = { const result = {
UID: "", UID: "",
Title: $gettext("Not Found"), Title: $gettext("Not Found"),
@ -112,7 +112,7 @@ export class Thumb extends Model {
} }
if (!photo || !photo.Hash) { if (!photo || !photo.Hash) {
return this.thumbNotFound(); return this.notFound();
} }
const result = { const result = {
@ -144,7 +144,7 @@ export class Thumb extends Model {
static fromFile(photo, file) { static fromFile(photo, file) {
if (!photo || !file || !file.Hash) { if (!photo || !file || !file.Hash) {
return this.thumbNotFound(); return this.notFound();
} }
const result = { const result = {

274
frontend/src/model/video.js Normal file
View 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;

View file

@ -52,8 +52,9 @@
import {Photo, MediaLive, MediaRaw, MediaVideo, MediaAnimated} from "model/photo"; import {Photo, MediaLive, MediaRaw, MediaVideo, MediaAnimated} from "model/photo";
import Album from "model/album"; import Album from "model/album";
import Thumb from "model/thumb"; import Thumb from "model/thumb";
import Event from "pubsub-js"; import Video from "model/video";
import Viewer from "common/viewer"; import Viewer from "common/viewer";
import Event from "pubsub-js";
export default { export default {
name: 'PPageAlbumPhotos', name: 'PPageAlbumPhotos',
@ -241,7 +242,8 @@ export default {
*/ */
if (preferVideo && selected.Type === MediaLive || selected.Type === MediaVideo || selected.Type === MediaAnimated) { if (preferVideo && selected.Type === MediaLive || selected.Type === MediaVideo || selected.Type === MediaAnimated) {
if (selected.isPlayable()) { 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 { } else {
this.$viewer.show(Thumb.fromPhotos(this.results), index); this.$viewer.show(Thumb.fromPhotos(this.results), index);
} }

View file

@ -47,6 +47,7 @@
<script> <script>
import {MediaAnimated, MediaLive, MediaRaw, MediaVideo, Photo} from "model/photo"; import {MediaAnimated, MediaLive, MediaRaw, MediaVideo, Photo} from "model/photo";
import Thumb from "model/thumb"; import Thumb from "model/thumb";
import Video from "model/video";
import Viewer from "common/viewer"; import Viewer from "common/viewer";
import Event from "pubsub-js"; import Event from "pubsub-js";
@ -323,7 +324,7 @@ export default {
*/ */
if (preferVideo && selected.Type === MediaLive || selected.Type === MediaVideo || selected.Type === MediaAnimated) { if (preferVideo && selected.Type === MediaLive || selected.Type === MediaVideo || selected.Type === MediaAnimated) {
if (selected.isPlayable()) { if (selected.isPlayable()) {
this.$viewer.play({video: selected}); this.$viewer.play(Video.fromPhotos(this.results, index));
} else { } else {
this.$viewer.show(Thumb.fromPhotos(this.results), index); this.$viewer.show(Thumb.fromPhotos(this.results), index);
} }

View file

@ -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.onPost("api/v1/photos/55/like").reply(200, { status: "ok" }, mockHeaders);
Mock.onDelete("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.onGet("api/v1/albums/5").reply(200, { UID: "5" }, mockHeaders);
Mock.onPut("api/v1/photos/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); Mock.onDelete("api/v1/photos/abc123/like").reply(200, { status: "ok" }, mockHeaders);

View file

@ -120,7 +120,7 @@ describe("model/file", () => {
Name: "1/2/IMG123.jpg", Name: "1/2/IMG123.jpg",
}; };
const file = new File(values); 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", () => { it("should not download as hash is missing", () => {

View file

@ -120,7 +120,7 @@ describe("model/photo", () => {
it("should get photo download url", () => { it("should get photo download url", () => {
const values = { ID: 5, Title: "Crazy Cat", Hash: 345982 }; const values = { ID: 5, Title: "Crazy Cat", Hash: 345982 };
const photo = new Photo(values); const photo = new Photo(values);
const result = photo.getDownloadUrl(); const result = photo.downloadUrl();
assert.equal(result, "/api/v1/dl/345982?t=2lbh9x09"); assert.equal(result, "/api/v1/dl/345982?t=2lbh9x09");
}); });
@ -636,11 +636,17 @@ describe("model/photo", () => {
], ],
}; };
const photo3 = new Photo(values3); const photo3 = new Photo(values3);
const result = photo3.videoParams();
assert.equal(result.height, "463"); const video = photo3.video();
assert.equal(result.width, "695"); assert.equal(video.Height, 600);
assert.equal(result.loop, false); assert.equal(video.Width, 900);
assert.equal(result.uri, "/api/v1/videos/1xxbgdt55/public/avc"); 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 = { const values = {
ID: 11, ID: 11,
UID: "ABC127", UID: "ABC127",
@ -667,12 +673,17 @@ describe("model/photo", () => {
}, },
], ],
}; };
const photo = new Photo(values); const photo = new Photo(values);
const result2 = photo.videoParams(); const video2 = photo.video();
assert.equal(result2.height, "440"); assert.equal(video2.Height, 5000);
assert.equal(result2.width, "440"); assert.equal(video2.Width, 5000);
assert.equal(result2.loop, false); assert.equal(video2.loop(), false);
assert.equal(result2.uri, "/api/v1/videos/1xxbgdt55/public/avc"); 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", () => { it("should return videofile", () => {

View file

@ -66,8 +66,8 @@ describe("model/thumb", () => {
assert.equal(thumb.Favorite, true); assert.equal(thumb.Favorite, true);
}); });
it("should return thumb not found", () => { it("should return not placeholder", () => {
const result = Thumb.thumbNotFound(); const result = Thumb.notFound();
assert.equal(result.UID, ""); assert.equal(result.UID, "");
assert.equal(result.Favorite, false); assert.equal(result.Favorite, false);
}); });

File diff suppressed because it is too large Load diff