Merge branch 'master' into stable24

old_stable24
Varun Patil 2022-11-22 09:40:16 -08:00
commit 27482cb750
20 changed files with 714 additions and 29 deletions

View File

@ -4,6 +4,9 @@ This file is manually updated. Please file an issue if something is missing.
## v4.8.0, v3.8.0 ## v4.8.0, v3.8.0
- **Feature**: Support for Live Photos ([#124](https://github.com/pulsejet/memories/issues/124))
- You need to run `occ memories:index --clear` to reindex live photos
- Only JPEG (iOS with MOV, Google, Samsung) is supported. HEIC is not supported.
- **Feature**: Timeline path now scans recursively for mounted volumes / shares inside it - **Feature**: Timeline path now scans recursively for mounted volumes / shares inside it
- **Feature**: Multiple timeline paths can be specified ([#178](https://github.com/pulsejet/memories/issues/178)) - **Feature**: Multiple timeline paths can be specified ([#178](https://github.com/pulsejet/memories/issues/178))
- Support for server-side encrypted storage ([#99](https://github.com/pulsejet/memories/issues/99)) - Support for server-side encrypted storage ([#99](https://github.com/pulsejet/memories/issues/99))

View File

@ -59,6 +59,7 @@ return [
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], ['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
['name' => 'Video#transcode', 'url' => '/api/video/transcode/{client}/{fileid}/{profile}', 'verb' => 'GET'], ['name' => 'Video#transcode', 'url' => '/api/video/transcode/{client}/{fileid}/{profile}', 'verb' => 'GET'],
['name' => 'Video#livephoto', 'url' => '/api/video/livephoto/{fileid}', 'verb' => 'GET'],
// Config API // Config API
['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'], ['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],

View File

@ -268,6 +268,11 @@ class Index extends Command
return; return;
} }
// check path contains IMDB then skip
if (false !== strpos($folderPath, 'IMDB')) {
return;
}
$nodes = $folder->getDirectoryListing(); $nodes = $folder->getDirectoryListing();
foreach ($nodes as $i => &$node) { foreach ($nodes as $i => &$node) {
@ -287,16 +292,21 @@ class Index extends Command
} }
} }
private function parseFile(File &$file, bool &$refresh): void private function parseFile(File &$file, bool &$refresh): bool
{ {
// Process the file // Process the file
$res = $this->timelineWrite->processFile($file, $refresh); $res = $this->timelineWrite->processFile($file, $refresh);
if (2 === $res) { if (2 === $res) {
++$this->nProcessed; ++$this->nProcessed;
} elseif (1 === $res) {
return true;
}
if (1 === $res) {
++$this->nSkipped; ++$this->nSkipped;
} else { } else {
++$this->nInvalid; ++$this->nInvalid;
} }
return false;
} }
} }

View File

@ -23,9 +23,11 @@ declare(strict_types=1);
namespace OCA\Memories\Controller; namespace OCA\Memories\Controller;
use OCA\Memories\Exif;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\File;
class VideoController extends ApiBase class VideoController extends ApiBase
{ {
@ -35,8 +37,6 @@ class VideoController extends ApiBase
* @NoCSRFRequired * @NoCSRFRequired
* *
* Transcode a video to HLS by proxy * Transcode a video to HLS by proxy
*
* @return JSONResponse an empty JSONResponse with respective http status code
*/ */
public function transcode(string $client, string $fileid, string $profile): Http\Response public function transcode(string $client, string $fileid, string $profile): Http\Response
{ {
@ -137,6 +137,102 @@ class VideoController extends ApiBase
return $response; return $response;
} }
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Return the live video part of a live photo
*/
public function livephoto(string $fileid)
{
$fileid = (int) $fileid;
$files = $this->rootFolder->getById($fileid);
if (0 === \count($files)) {
return new JSONResponse(['message' => 'File not found'], Http::STATUS_NOT_FOUND);
}
$file = $files[0];
// Check file etag
$etag = $file->getEtag();
if ($etag !== $this->request->getParam('etag')) {
return new JSONResponse(['message' => 'File changed'], Http::STATUS_PRECONDITION_FAILED);
}
// Check file liveid
$liveid = $this->request->getParam('liveid');
if (!$liveid) {
return new JSONResponse(['message' => 'Live ID not provided'], Http::STATUS_BAD_REQUEST);
}
// Response data
$name = '';
$blob = null;
$mime = '';
// Video is inside the file
$path = null;
if (str_starts_with($liveid, 'self__')) {
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
$mime = 'video/mp4';
$name = $file->getName().'.mp4';
}
// Different manufacurers have different formats
if ('self__trailer' === $liveid) {
try { // Get trailer
$blob = Exif::getBinaryExifProp($path, '-trailer');
} catch (\Exception $e) {
return new JSONResponse(['message' => 'Trailer not found'], Http::STATUS_NOT_FOUND);
}
} elseif ('self__embeddedvideo' === $liveid) {
try { // Get embedded video file
$blob = Exif::getBinaryExifProp($path, '-EmbeddedVideoFile');
} catch (\Exception $e) {
return new JSONResponse(['message' => 'Embedded video not found'], Http::STATUS_NOT_FOUND);
}
} else {
// Get stored video file (Apple MOV)
$lp = $this->timelineQuery->getLivePhoto($fileid);
if (!$lp || $lp['liveid'] !== $liveid) {
return new JSONResponse(['message' => 'Live ID not found'], Http::STATUS_NOT_FOUND);
}
// Get and return file
$liveFileId = (int) $lp['fileid'];
$files = $this->rootFolder->getById($liveFileId);
if (0 === \count($files)) {
return new JSONResponse(['message' => 'Live file not found'], Http::STATUS_NOT_FOUND);
}
$liveFile = $files[0];
if ($liveFile instanceof File) {
// Requested only JSON info
if ('json' === $this->request->getParam('format')) {
return new JSONResponse($lp);
}
$name = $liveFile->getName();
$blob = $liveFile->getContent();
$mime = $liveFile->getMimeType();
}
}
// Make and send response
if ($blob) {
$response = new DataDisplayResponse($blob, Http::STATUS_OK, []);
$response->setHeaders([
'Content-Type' => $mime,
'Content-Disposition' => "attachment; filename=\"{$name}\"",
]);
$response->cacheFor(3600 * 24, false, false);
return $response;
}
return new JSONResponse(['message' => 'Live file not found'], Http::STATUS_NOT_FOUND);
}
private function getUpstream($client, $path, $profile) private function getUpstream($client, $path, $profile)
{ {
$path = rawurlencode($path); $path = rawurlencode($path);

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File;
use OCP\IDBConnection;
class LivePhoto
{
protected IDBConnection $connection;
public function __construct(IDBConnection $connection)
{
$this->connection = $connection;
}
/** Check if a given Exif data is the video part of a live photo */
public function isVideoPart(array &$exif)
{
return 'video/quicktime' === $exif['MIMEType'] && \array_key_exists('ContentIdentifier', $exif);
}
/** Get liveid from photo part */
public function getLivePhotoId(array &$exif)
{
// Apple JPEG (MOV has ContentIdentifier)
if (\array_key_exists('MediaGroupUUID', $exif)) {
return $exif['MediaGroupUUID'];
}
// Samsung JPEG
if (\array_key_exists('EmbeddedVideoType', $exif) && str_contains($exif['EmbeddedVideoType'], 'MotionPhoto')) {
return 'self__embeddedvideo';
}
// Google JPEG and Samsung HEIC (Apple?)
if (\array_key_exists('MotionPhoto', $exif)) {
if ('image/jpeg' === $exif['MIMEType']) {
// Google JPEG -- image should hopefully be in trailer
return 'self__trailer';
}
if ('image/heic' === $exif['MIMEType']) {
// Samsung HEIC -- no way to get this out yet
return '';
}
}
return '';
}
public function processVideoPart(File &$file, array &$exif)
{
$fileId = $file->getId();
$mtime = $file->getMTime();
$liveid = $exif['ContentIdentifier'];
if (empty($liveid)) {
return;
}
$query = $this->connection->getQueryBuilder();
$query->select('fileid')
->from('memories_livephoto')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$cursor = $query->executeQuery();
$prevRow = $cursor->fetch();
$cursor->closeCursor();
if ($prevRow) {
// Update existing row
$query->update('memories_livephoto')
->set('liveid', $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR))
->set('mtime', $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
} else {
// Try to create new row
try {
$query->insert('memories_livephoto')
->values([
'liveid' => $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR),
'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT),
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
])
;
$query->executeStatement();
} catch (\Exception $ex) {
error_log('Failed to create memories_livephoto record: '.$ex->getMessage());
}
}
}
}

View File

@ -14,6 +14,7 @@ class TimelineQuery
use TimelineQueryFaces; use TimelineQueryFaces;
use TimelineQueryFilters; use TimelineQueryFilters;
use TimelineQueryFolders; use TimelineQueryFolders;
use TimelineQueryLivePhoto;
use TimelineQueryTags; use TimelineQueryTags;
protected IDBConnection $connection; protected IDBConnection $connection;

View File

@ -152,7 +152,7 @@ trait TimelineQueryDays
// We don't actually use m.datetaken here, but postgres // We don't actually use m.datetaken here, but postgres
// needs that all fields in ORDER BY are also in SELECT // needs that all fields in ORDER BY are also in SELECT
// when using DISTINCT on selected fields // when using DISTINCT on selected fields
$query->select($fileid, 'm.isvideo', 'm.video_duration', 'm.datetaken', 'm.dayid', 'm.w', 'm.h') $query->select($fileid, 'm.isvideo', 'm.video_duration', 'm.datetaken', 'm.dayid', 'm.w', 'm.h', 'm.liveid')
->from('memories', 'm') ->from('memories', 'm')
; ;
@ -282,6 +282,9 @@ trait TimelineQueryDays
$row['isfavorite'] = 1; $row['isfavorite'] = 1;
} }
unset($row['categoryid']); unset($row['categoryid']);
if (!$row['liveid']) {
unset($row['liveid']);
}
// Check if path exists and starts with basePath and remove // Check if path exists and starts with basePath and remove
if (isset($row['path']) && !empty($row['path'])) { if (isset($row['path']) && !empty($row['path'])) {

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
trait TimelineQueryLivePhoto
{
public function getLivePhoto(int $fileid)
{
$qb = $this->connection->getQueryBuilder();
$qb->select('lp.fileid', 'lp.liveid')
->from('memories', 'm')
->where($qb->expr()->eq('m.fileid', $qb->createNamedParameter($fileid)))
->innerJoin('m', 'memories_livephoto', 'lp', $qb->expr()->andX(
$qb->expr()->eq('lp.liveid', 'm.liveid'),
))
;
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return $row;
}
}

View File

@ -15,11 +15,13 @@ class TimelineWrite
{ {
protected IDBConnection $connection; protected IDBConnection $connection;
protected IPreview $preview; protected IPreview $preview;
protected LivePhoto $livePhoto;
public function __construct(IDBConnection $connection, IPreview &$preview) public function __construct(IDBConnection $connection, IPreview &$preview)
{ {
$this->connection = $connection; $this->connection = $connection;
$this->preview = $preview; $this->preview = $preview;
$this->livePhoto = new LivePhoto($connection);
} }
/** /**
@ -79,6 +81,19 @@ class TimelineWrite
$cursor = $query->executeQuery(); $cursor = $query->executeQuery();
$prevRow = $cursor->fetch(); $prevRow = $cursor->fetch();
$cursor->closeCursor(); $cursor->closeCursor();
// Check in live-photo table in case this is a video part of a live photo
if (!$prevRow) {
$query = $this->connection->getQueryBuilder();
$query->select('fileid', 'mtime')
->from('memories_livephoto')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$cursor = $query->executeQuery();
$prevRow = $cursor->fetch();
$cursor->closeCursor();
}
if ($prevRow && !$force && (int) $prevRow['mtime'] === $mtime) { if ($prevRow && !$force && (int) $prevRow['mtime'] === $mtime) {
return 1; return 1;
} }
@ -91,11 +106,19 @@ class TimelineWrite
} catch (\Exception $e) { } catch (\Exception $e) {
} }
// Hand off if live photo video part
if ($isvideo && $this->livePhoto->isVideoPart($exif)) {
$this->livePhoto->processVideoPart($file, $exif);
return 2;
}
// Get more parameters // Get more parameters
$dateTaken = Exif::getDateTaken($file, $exif); $dateTaken = Exif::getDateTaken($file, $exif);
$dayId = floor($dateTaken / 86400); $dayId = floor($dateTaken / 86400);
$dateTaken = gmdate('Y-m-d H:i:s', $dateTaken); $dateTaken = gmdate('Y-m-d H:i:s', $dateTaken);
[$w, $h] = Exif::getDimensions($exif); [$w, $h] = Exif::getDimensions($exif);
$liveid = $this->livePhoto->getLivePhotoId($exif);
// Video parameters // Video parameters
$videoDuration = 0; $videoDuration = 0;
@ -103,11 +126,17 @@ class TimelineWrite
$videoDuration = round($exif['Duration'] ?? $exif['TrackDuration'] ?? 0); $videoDuration = round($exif['Duration'] ?? $exif['TrackDuration'] ?? 0);
} }
// Truncate any fields >2048 chars // Clean up EXIF to keep only useful metadata
foreach ($exif as $key => &$value) { foreach ($exif as $key => &$value) {
// Truncate any fields > 2048 chars
if (\is_string($value) && \strlen($value) > 2048) { if (\is_string($value) && \strlen($value) > 2048) {
$exif[$key] = substr($value, 0, 2048); $exif[$key] = substr($value, 0, 2048);
} }
// These are huge and not needed
if (str_starts_with($key, 'Nikon') || str_starts_with($key, 'QuickTime')) {
unset($exif[$key]);
}
} }
// Store JSON string // Store JSON string
@ -134,6 +163,7 @@ class TimelineWrite
->set('w', $query->createNamedParameter($w, IQueryBuilder::PARAM_INT)) ->set('w', $query->createNamedParameter($w, IQueryBuilder::PARAM_INT))
->set('h', $query->createNamedParameter($h, IQueryBuilder::PARAM_INT)) ->set('h', $query->createNamedParameter($h, IQueryBuilder::PARAM_INT))
->set('exif', $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR)) ->set('exif', $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR))
->set('liveid', $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR))
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
; ;
$query->executeStatement(); $query->executeStatement();
@ -152,6 +182,7 @@ class TimelineWrite
'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT), 'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT),
'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT), 'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT),
'exif' => $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR), 'exif' => $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR),
'liveid' => $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR),
]) ])
; ;
$query->executeStatement(); $query->executeStatement();

View File

@ -262,6 +262,28 @@ class Exif
} }
} }
public static function getBinaryExifProp(string $path, string $prop)
{
$pipes = [];
$proc = proc_open(array_merge(self::getExiftool(), [$prop, '-n', '-b', $path]), [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
stream_set_blocking($pipes[1], false);
try {
return self::readOrTimeout($pipes[1], 5000);
} catch (\Exception $ex) {
error_log("Exiftool timeout: [{$path}]");
throw new \Exception('Could not read from Exiftool');
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
}
}
/** Get path to exiftool binary */ /** Get path to exiftool binary */
private static function getExiftool() private static function getExiftool()
{ {

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Your name <your@email.com>
* @author Your name <your@email.com>
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Migration;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version400800Date20221122105007 extends SimpleMigrationStep
{
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('memories');
if (!$table->hasColumn('liveid')) {
$table->addColumn('liveid', 'string', [
'notnull' => false,
'length' => 256,
'default' => '',
]);
}
// Live photos table
if (!$schema->hasTable('memories_livephoto')) {
$table = $schema->createTable('memories_livephoto');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('liveid', 'string', [
'notnull' => true,
'length' => 256,
]);
$table->addColumn('fileid', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('mtime', Types::INTEGER, [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['liveid'], 'memories_lp_liveid_index');
$table->addUniqueIndex(['fileid'], 'memories_lp_fileid_index');
}
return $schema;
}
/**
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
*/
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
{
}
}

View File

@ -17,7 +17,7 @@ mv "exiftool-$exifver" exiftool
rm -rf *.zip exiftool/t exiftool/html rm -rf *.zip exiftool/t exiftool/html
chmod 755 exiftool/exiftool chmod 755 exiftool/exiftool
govod="0.0.15" govod="0.0.16"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-amd64" wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-amd64"
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-aarch64" wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-aarch64"
chmod 755 go-vod-* chmod 755 go-vod-*

View File

@ -356,4 +356,39 @@ aside.app-sidebar {
height: 100%; height: 100%;
display: block; display: block;
} }
:root {
--livephoto-img-transition: opacity 0.4s linear, transform 0.3s ease-in-out;
}
// Live photo transitions
.memories-livephoto {
position: relative;
overflow: hidden;
contain: strict;
img,
video {
position: absolute;
padding: inherit;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
transition: var(--livephoto-img-transition);
}
video,
&.playing.canplay img {
opacity: 0;
}
img,
&.playing.canplay video {
opacity: 1;
}
&.playing.canplay img {
transform: scale(1.05);
}
}
</style> </style>

View File

@ -0,0 +1,76 @@
import PhotoSwipe from "photoswipe";
import * as utils from "../services/Utils";
function isLiveContent(content): boolean {
return Boolean(content?.data?.photo?.liveid);
}
class LivePhotoContentSetup {
constructor(lightbox: PhotoSwipe, private options) {
this.initLightboxEvents(lightbox);
}
initLightboxEvents(lightbox: PhotoSwipe) {
lightbox.on("contentLoad", this.onContentLoad.bind(this));
lightbox.on("contentActivate", this.onContentActivate.bind(this));
lightbox.on("contentDeactivate", this.onContentDeactivate.bind(this));
lightbox.on("contentAppend", this.onContentAppend.bind(this));
}
onContentLoad(e) {
const content = e.content;
if (!isLiveContent(content)) return;
e.preventDefault();
if (content.element) return;
const photo = content?.data?.photo;
const video = document.createElement("video");
video.muted = true;
video.autoplay = false;
video.playsInline = true;
video.preload = "none";
video.src = utils.getLivePhotoVideoUrl(photo);
const div = document.createElement("div");
div.className = "memories-livephoto";
div.appendChild(video);
content.element = div;
utils.setupLivePhotoHooks(video);
const img = document.createElement("img");
img.src = content.data.src;
img.onload = () => content.onLoaded();
div.appendChild(img);
content.element = div;
}
onContentActivate({ content }) {
if (isLiveContent(content) && content.element) {
const video = content.element.querySelector("video");
if (video) {
video.currentTime = 0;
video.play();
}
}
}
onContentDeactivate({ content }) {
if (isLiveContent(content) && content.element) {
content.element.querySelector("video")?.pause();
}
}
onContentAppend(e) {
if (isLiveContent(e.content)) {
e.preventDefault();
e.content.isAttached = true;
e.content.appendImage();
}
}
}
export default LivePhotoContentSetup;

View File

@ -1213,7 +1213,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
*/ */
async deleteFromViewWithAnimation(delPhotos: IPhoto[]) { async deleteFromViewWithAnimation(delPhotos: IPhoto[]) {
// Only keep photos with day // Only keep photos with day
delPhotos = delPhotos.filter((p) => p.d); delPhotos = delPhotos.filter((p) => p?.d);
if (delPhotos.length === 0) return; if (delPhotos.length === 0) return;
// Get all days that need to be updatd // Get all days that need to be updatd

View File

@ -13,7 +13,12 @@
@close="editorOpen = false" @close="editorOpen = false"
/> />
<div class="inner" ref="inner" v-show="!editorOpen"> <div
class="inner"
ref="inner"
v-show="!editorOpen"
@pointermove.passive="setUiVisible"
>
<div class="top-bar" v-if="photoswipe" :class="{ showControls }"> <div class="top-bar" v-if="photoswipe" :class="{ showControls }">
<NcActions <NcActions
:inline="numInlineActions" :inline="numInlineActions"
@ -81,6 +86,17 @@
<DownloadIcon :size="24" /> <DownloadIcon :size="24" />
</template> </template>
</NcActionButton> </NcActionButton>
<NcActionButton
v-if="currentPhoto?.liveid"
:aria-label="t('memories', 'Download Video')"
@click="downloadCurrentLiveVideo"
:close-after-click="true"
>
{{ t("memories", "Download Video") }}
<template #icon>
<DownloadIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton <NcActionButton
v-if="!routeIsPublic" v-if="!routeIsPublic"
:aria-label="t('memories', 'View in folder')" :aria-label="t('memories', 'View in folder')"
@ -120,6 +136,7 @@ import PhotoSwipe, { PhotoSwipeOptions } from "photoswipe";
import "photoswipe/style.css"; import "photoswipe/style.css";
import PsVideo from "./PsVideo"; import PsVideo from "./PsVideo";
import PsLivePhoto from "./PsLivePhoto";
import ShareIcon from "vue-material-design-icons/ShareVariant.vue"; import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
import DeleteIcon from "vue-material-design-icons/TrashCanOutline.vue"; import DeleteIcon from "vue-material-design-icons/TrashCanOutline.vue";
@ -161,6 +178,9 @@ export default class Viewer extends Mixins(GlobalMixin) {
private sidebarWidth = 400; private sidebarWidth = 400;
private outerWidth = "100vw"; private outerWidth = "100vw";
/** User interaction detection */
private activityTimer = 0;
/** Base dialog */ /** Base dialog */
private photoswipe: PhotoSwipe | null = null; private photoswipe: PhotoSwipe | null = null;
@ -237,13 +257,40 @@ export default class Viewer extends Mixins(GlobalMixin) {
} }
/** Event on file changed */ /** Event on file changed */
handleFileUpdated({ fileid }: { fileid: number }) { private handleFileUpdated({ fileid }: { fileid: number }) {
if (this.currentPhoto && this.currentPhoto.fileid === fileid) { if (this.currentPhoto && this.currentPhoto.fileid === fileid) {
this.currentPhoto.etag += "_"; this.currentPhoto.etag += "_";
this.photoswipe.refreshSlideContent(this.currIndex); this.photoswipe.refreshSlideContent(this.currIndex);
} }
} }
/** User interacted with the page with mouse */
private setUiVisible(evt: any) {
clearTimeout(this.activityTimer);
if (evt) {
// If directly triggered, always update ui visibility
// If triggered through a pointer event, only update if this is not
// a touch event (i.e. a mouse move).
// On touch devices, tapAction directly handles the ui visibility
// through Photoswipe.
const isPointer = evt instanceof PointerEvent;
const isMouse = isPointer && evt.pointerType !== "touch";
if (this.isOpen && (!isPointer || isMouse)) {
this.photoswipe?.template?.classList.add("pswp--ui-visible");
if (isMouse) {
this.activityTimer = window.setTimeout(() => {
if (this.isOpen) {
this.photoswipe?.template?.classList.remove("pswp--ui-visible");
}
}, 2000);
}
}
} else {
this.photoswipe?.template?.classList.remove("pswp--ui-visible");
}
}
/** Create the base photoswipe object */ /** Create the base photoswipe object */
private async createBase(args: PhotoSwipeOptions) { private async createBase(args: PhotoSwipeOptions) {
this.show = true; this.show = true;
@ -320,7 +367,6 @@ export default class Viewer extends Mixins(GlobalMixin) {
this.photoswipe.on("openingAnimationStart", () => { this.photoswipe.on("openingAnimationStart", () => {
this.isOpen = true; this.isOpen = true;
this.fullyOpened = false; this.fullyOpened = false;
this.showControls = true;
if (this.sidebarOpen) { if (this.sidebarOpen) {
this.openSidebar(); this.openSidebar();
} }
@ -331,7 +377,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
this.photoswipe.on("close", () => { this.photoswipe.on("close", () => {
this.isOpen = false; this.isOpen = false;
this.fullyOpened = false; this.fullyOpened = false;
this.showControls = false; this.setUiVisible(false);
this.hideSidebar(); this.hideSidebar();
this.setRouteHash(undefined); this.setRouteHash(undefined);
this.updateTitle(undefined); this.updateTitle(undefined);
@ -344,7 +390,6 @@ export default class Viewer extends Mixins(GlobalMixin) {
this.show = false; this.show = false;
this.isOpen = false; this.isOpen = false;
this.fullyOpened = false; this.fullyOpened = false;
this.showControls = false;
this.photoswipe = null; this.photoswipe = null;
this.list = []; this.list = [];
this.days.clear(); this.days.clear();
@ -353,11 +398,6 @@ export default class Viewer extends Mixins(GlobalMixin) {
this.globalAnchor = -1; this.globalAnchor = -1;
}); });
// toggle-controls
this.photoswipe.on("tapAction", () => {
this.showControls = !this.showControls;
});
// Update vue route for deep linking // Update vue route for deep linking
this.photoswipe.on("slideActivate", (e) => { this.photoswipe.on("slideActivate", (e) => {
this.currIndex = this.photoswipe.currIndex; this.currIndex = this.photoswipe.currIndex;
@ -367,6 +407,22 @@ export default class Viewer extends Mixins(GlobalMixin) {
globalThis.currentViewerPhoto = photo; globalThis.currentViewerPhoto = photo;
}); });
// Show and hide controls
this.photoswipe.on("uiRegister", (e) => {
if (this.photoswipe?.template) {
new MutationObserver((mutations) => {
mutations.forEach((mutationRecord) => {
this.showControls = (<HTMLElement>(
mutationRecord.target
))?.classList.contains("pswp--ui-visible");
});
}).observe(this.photoswipe.template, {
attributes: true,
attributeFilter: ["class"],
});
}
});
// Video support // Video support
new PsVideo(this.photoswipe, { new PsVideo(this.photoswipe, {
videoAttributes: { controls: "", playsinline: "", preload: "none" }, videoAttributes: { controls: "", playsinline: "", preload: "none" },
@ -374,6 +430,9 @@ export default class Viewer extends Mixins(GlobalMixin) {
preventDragOffset: 40, preventDragOffset: 40,
}); });
// Live photo support
new PsLivePhoto(this.photoswipe, {});
return this.photoswipe; return this.photoswipe;
} }
@ -609,7 +668,10 @@ export default class Viewer extends Mixins(GlobalMixin) {
} }
get canEdit() { get canEdit() {
return this.currentPhoto?.mimetype?.startsWith("image/"); return (
this.currentPhoto?.mimetype?.startsWith("image/") &&
!this.currentPhoto.liveid
);
} }
private openEditor() { private openEditor() {
@ -747,6 +809,13 @@ export default class Viewer extends Mixins(GlobalMixin) {
dav.downloadFilesByPhotos([photo]); dav.downloadFilesByPhotos([photo]);
} }
/** Download live part of current video */
private async downloadCurrentLiveVideo() {
const photo = this.currentPhoto;
if (!photo) return;
window.location.href = utils.getLivePhotoVideoUrl(photo);
}
/** Open the sidebar */ /** Open the sidebar */
private async openSidebar(photo?: IPhoto) { private async openSidebar(photo?: IPhoto) {
const fInfo = await dav.getFiles([photo || this.currentPhoto]); const fInfo = await dav.getFiles([photo || this.currentPhoto]);
@ -852,6 +921,10 @@ export default class Viewer extends Mixins(GlobalMixin) {
.inner, .inner,
.inner :deep .pswp { .inner :deep .pswp {
width: inherit; width: inherit;
.pswp__top-bar {
background: linear-gradient(0deg, transparent, rgba(0, 0, 0, 0.3));
}
} }
:deep .video-js .vjs-big-play-button { :deep .video-js .vjs-big-play-button {

View File

@ -22,10 +22,21 @@
<Video :size="22" /> <Video :size="22" />
</div> </div>
<div
class="livephoto"
@mouseenter.passive="playVideo"
@mouseleave.passive="stopVideo"
>
<LivePhoto :size="22" v-if="data.liveid" />
</div>
<Star :size="22" v-if="data.flag & c.FLAG_IS_FAVORITE" /> <Star :size="22" v-if="data.flag & c.FLAG_IS_FAVORITE" />
<div <div
class="img-outer fill-block" class="img-outer fill-block"
:class="{
'memories-livephoto': data.liveid,
}"
@contextmenu="contextmenu" @contextmenu="contextmenu"
@pointerdown.passive="$emit('pointerdown', $event)" @pointerdown.passive="$emit('pointerdown', $event)"
@touchstart.passive="$emit('touchstart', $event)" @touchstart.passive="$emit('touchstart', $event)"
@ -42,7 +53,15 @@
@load="load" @load="load"
@error="error" @error="error"
/> />
<div class="overlay" /> <video
ref="video"
v-if="videoUrl"
:src="videoUrl"
preload="none"
muted
playsinline
/>
<div class="overlay fill-block" />
</div> </div>
</div> </div>
</template> </template>
@ -59,12 +78,14 @@ import errorsvg from "../../assets/error.svg";
import CheckCircle from "vue-material-design-icons/CheckCircle.vue"; import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
import Star from "vue-material-design-icons/Star.vue"; import Star from "vue-material-design-icons/Star.vue";
import Video from "vue-material-design-icons/PlayCircleOutline.vue"; import Video from "vue-material-design-icons/PlayCircleOutline.vue";
import LivePhoto from "vue-material-design-icons/MotionPlayOutline.vue";
@Component({ @Component({
components: { components: {
CheckCircle, CheckCircle,
Video, Video,
Star, Star,
LivePhoto,
}, },
}) })
export default class Photo extends Mixins(GlobalMixin) { export default class Photo extends Mixins(GlobalMixin) {
@ -95,6 +116,12 @@ export default class Photo extends Mixins(GlobalMixin) {
mounted() { mounted() {
this.hasFaceRect = false; this.hasFaceRect = false;
this.refresh(); this.refresh();
// Setup video hooks
const video = this.$refs.video as HTMLVideoElement;
if (video) {
utils.setupLivePhotoHooks(video);
}
} }
get videoDuration() { get videoDuration() {
@ -104,6 +131,12 @@ export default class Photo extends Mixins(GlobalMixin) {
return null; return null;
} }
get videoUrl() {
if (this.data.liveid) {
return utils.getLivePhotoVideoUrl(this.data);
}
}
async refresh() { async refresh() {
this.src = await this.getSrc(); this.src = await this.getSrc();
} }
@ -204,6 +237,23 @@ export default class Photo extends Mixins(GlobalMixin) {
e.stopPropagation(); e.stopPropagation();
} }
} }
/** Start preview video */
playVideo() {
if (this.$refs.video && !(this.data.flag & this.c.FLAG_SELECTED)) {
const video = this.$refs.video as HTMLVideoElement;
video.currentTime = 0;
video.play();
}
}
/** Stop preview video */
stopVideo() {
if (this.$refs.video) {
const video = this.$refs.video as HTMLVideoElement;
video.pause();
}
}
} }
</script> </script>
@ -273,14 +323,16 @@ $icon-size: $icon-half-size * 2;
} }
} }
.video, .video,
.star-icon { .star-icon,
.livephoto {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
pointer-events: none; pointer-events: none;
transition: transform 0.15s ease; transition: transform 0.15s ease;
filter: invert(1) brightness(100); filter: invert(1) brightness(100);
} }
.video { .video,
.livephoto {
position: absolute; position: absolute;
top: var(--icon-dist); top: var(--icon-dist);
right: var(--icon-dist); right: var(--icon-dist);
@ -298,6 +350,9 @@ $icon-size: $icon-half-size * 2;
margin-right: 3px; margin-right: 3px;
} }
} }
.livephoto {
pointer-events: auto;
}
.star-icon { .star-icon {
bottom: var(--icon-dist); bottom: var(--icon-dist);
left: var(--icon-dist); left: var(--icon-dist);
@ -308,6 +363,7 @@ $icon-size: $icon-half-size * 2;
/* Actual image */ /* Actual image */
div.img-outer { div.img-outer {
position: relative;
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
@ -331,7 +387,7 @@ div.img-outer {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none; -webkit-touch-callout: none;
user-select: none; user-select: none;
transition: border-radius 0.1s ease-in; transition: border-radius 0.1s ease-in, var(--livephoto-img-transition);
.p-outer.placeholder > & { .p-outer.placeholder > & {
display: none; display: none;
@ -341,11 +397,16 @@ div.img-outer {
} }
} }
& > .overlay { > video {
pointer-events: none; pointer-events: none;
width: 100%; object-fit: cover;
height: 100%; }
transform: translateY(-100%); // very weird stuff
> .overlay {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, transparent 30%); background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, transparent 30%);
display: none; display: none;

View File

@ -1,5 +1,6 @@
import { getCanonicalLocale } from "@nextcloud/l10n"; import { getCanonicalLocale } from "@nextcloud/l10n";
import { getCurrentUser } from "@nextcloud/auth"; import { getCurrentUser } from "@nextcloud/auth";
import { generateUrl } from "@nextcloud/router";
import { loadState } from "@nextcloud/initial-state"; import { loadState } from "@nextcloud/initial-state";
import { IPhoto } from "../types"; import { IPhoto } from "../types";
import moment from "moment"; import moment from "moment";
@ -236,6 +237,32 @@ export function getFolderRoutePath(basePath: string) {
return path; return path;
} }
/**
* Get URL to live photo video part
*/
export function getLivePhotoVideoUrl(p: IPhoto) {
return generateUrl(
`/apps/memories/api/video/livephoto/${p.fileid}?etag=${p.etag}&liveid=${p.liveid}`
);
}
/**
* Set up hooks to set classes on parent element for live photo
* @param video Video element
*/
export function setupLivePhotoHooks(video: HTMLVideoElement) {
const div = video.closest(".memories-livephoto") as HTMLDivElement;
video.onplay = () => {
div.classList.add("playing");
};
video.oncanplay = () => {
div.classList.add("canplay");
};
video.onended = video.onpause = () => {
div.classList.remove("playing");
};
}
/** /**
* Get route hash for viewer for photo * Get route hash for viewer for photo
*/ */

View File

@ -1,10 +1,13 @@
import { getCurrentUser } from "@nextcloud/auth"; import { getCurrentUser } from "@nextcloud/auth";
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n"; import { translate as t } from "@nextcloud/l10n";
import axios from "@nextcloud/axios";
import { IFileInfo, IPhoto } from "../../types"; import { IFileInfo, IPhoto } from "../../types";
import client from "../DavClient";
import { genFileInfo } from "../FileUtils"; import { genFileInfo } from "../FileUtils";
import { getAlbumFileInfos } from "./albums"; import { getAlbumFileInfos } from "./albums";
import * as utils from "../Utils";
import client from "../DavClient";
export const props = ` export const props = `
<oc:fileid /> <oc:fileid />
@ -195,10 +198,32 @@ export async function* deletePhotos(photos: IPhoto[]) {
const fileIdsSet = new Set(photos.map((p) => p.fileid)); const fileIdsSet = new Set(photos.map((p) => p.fileid));
// Get live photo data
const livePhotos = (
await Promise.all(
photos
.filter((p) => p.liveid && !p.liveid.startsWith("self__"))
.map(async (p) => {
const url = utils.getLivePhotoVideoUrl(p) + "&format=json";
try {
const response = await axios.get(url);
const data = response.data;
fileIdsSet.add(data.fileid);
return {
fileid: data.fileid,
} as IPhoto;
} catch (error) {
console.error(error);
return null;
}
})
)
).filter((p) => p !== null) as IPhoto[];
// Get files data // Get files data
let fileInfos: IFileInfo[] = []; let fileInfos: IFileInfo[] = [];
try { try {
fileInfos = await getFiles(photos); fileInfos = await getFiles(photos.concat(livePhotos));
} catch (e) { } catch (e) {
console.error("Failed to get file info for files to delete", photos, e); console.error("Failed to get file info for files to delete", photos, e);
showError(t("memories", "Failed to delete files.")); showError(t("memories", "Failed to delete files."));

View File

@ -57,6 +57,8 @@ export type IPhoto = {
w?: number; w?: number;
/** Height of full image */ /** Height of full image */
h?: number; h?: number;
/** Live photo identifier */
liveid?: string;
/** Grid display width px */ /** Grid display width px */
dispW?: number; dispW?: number;