commit
5434ca2867
|
@ -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
|
||||||
|
- 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))
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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'])) {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
22
lib/Exif.php
22
lib/Exif.php
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -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-*
|
||||||
|
|
35
src/App.vue
35
src/App.vue
|
@ -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>
|
||||||
|
|
|
@ -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;
|
|
@ -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
|
||||||
|
|
|
@ -86,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')"
|
||||||
|
@ -125,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";
|
||||||
|
@ -418,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -791,6 +806,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]);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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."));
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue