From a1d38f8d254f4a4fd470b37e2764ccb708613ebb Mon Sep 17 00:00:00 2001 From: Jo Van Bulck Date: Thu, 28 Nov 2024 00:37:09 +0100 Subject: [PATCH 1/3] feat: add panorama and live photo views Fixes #676 --- appinfo/routes.php | 2 ++ lib/Controller/DaysController.php | 10 ++++++++++ lib/Controller/PageController.php | 20 ++++++++++++++++++++ lib/Db/TimelineQueryFilters.php | 15 +++++++++++++++ src/components/Explore.vue | 12 ++++++++++++ src/components/Timeline.vue | 10 ++++++++++ src/router.ts | 16 ++++++++++++++++ src/services/API.ts | 2 ++ src/services/strings.ts | 4 ++++ 9 files changed, 91 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index b54e2e78a..f6ae31edc 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -17,6 +17,8 @@ function w($base, $param) ['name' => 'Page#main', 'url' => '/', 'verb' => 'GET'], ['name' => 'Page#favorites', 'url' => '/favorites', 'verb' => 'GET'], ['name' => 'Page#videos', 'url' => '/videos', 'verb' => 'GET'], + ['name' => 'Page#livephotos', 'url' => '/livephotos', 'verb' => 'GET'], + ['name' => 'Page#panoramas', 'url' => '/panoramas', 'verb' => 'GET'], ['name' => 'Page#archive', 'url' => '/archive', 'verb' => 'GET'], ['name' => 'Page#thisday', 'url' => '/thisday', 'verb' => 'GET'], ['name' => 'Page#map', 'url' => '/map', 'verb' => 'GET'], diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index dc5d8a142..4c0b2c36f 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -115,6 +115,16 @@ private function getTransformations(): array $transforms[] = [$this->tq, 'transformVideoFilter']; } + // Filter only live photos + if ($this->request->getParam('live')) { + $transforms[] = [$this->tq, 'transformLivePhotoFilter']; + } + + // Filter only panoramas + if ($this->request->getParam('pano')) { + $transforms[] = [$this->tq, 'transformPanoFilter']; + } + // Filter geological bounds if ($bounds = $this->request->getParam('mapbounds')) { $transforms[] = [$this->tq, 'transformMapBoundsFilter', $bounds]; diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 3f16c90d6..6d42b3614 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -149,6 +149,26 @@ public function videos(): Response return $this->main(); } + /** + * @NoAdminRequired + * + * @NoCSRFRequired + */ + public function livephotos(): Response + { + return $this->main(); + } + + /** + * @NoAdminRequired + * + * @NoCSRFRequired + */ + public function panoramas(): Response + { + return $this->main(); + } + /** * @NoAdminRequired * diff --git a/lib/Db/TimelineQueryFilters.php b/lib/Db/TimelineQueryFilters.php index 4dac2e663..b21c61650 100644 --- a/lib/Db/TimelineQueryFilters.php +++ b/lib/Db/TimelineQueryFilters.php @@ -9,6 +9,11 @@ use OCP\DB\QueryBuilder\IQueryFunction; use OCP\ITags; +// Wikipedia defines a panoramic image as having an aspect ratio of at least 2:1, +// but some phones approach this with regular photos. Hence, we conservatively set +// the threshold to 3:1 for true panoramas. +const PANOROMA_ASPECT_RATIO = 3; + trait TimelineQueryFilters { public function transformFavoriteFilter(IQueryBuilder &$query, bool $aggregate): void @@ -37,6 +42,16 @@ public function transformVideoFilter(IQueryBuilder &$query, bool $aggregate): vo $query->andWhere($query->expr()->eq('m.isvideo', $query->expr()->literal(1))); } + public function transformLivePhotoFilter(IQueryBuilder &$query, bool $aggregate): void + { + $query->andWhere($query->expr()->neq('m.liveid', $query->expr()->literal(''))); + } + + public function transformPanoFilter(IQueryBuilder &$query, bool $aggregate): void + { + $query->andWhere('m.w >= '.PANOROMA_ASPECT_RATIO.' * m.h'); + } + public function transformLimit(IQueryBuilder &$query, bool $aggregate, int $limit): void { /** @psalm-suppress RedundantCondition */ diff --git a/src/components/Explore.vue b/src/components/Explore.vue index c8b5f63ee..b1004041f 100644 --- a/src/components/Explore.vue +++ b/src/components/Explore.vue @@ -54,6 +54,8 @@ import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'; import FolderIcon from 'vue-material-design-icons/Folder.vue'; import StarIcon from 'vue-material-design-icons/Star.vue'; import VideoIcon from 'vue-material-design-icons/PlayCircle.vue'; +import LivePhotoIcon from './icons/LivePhoto.vue'; +import PanoramaVariantIcon from 'vue-material-design-icons/PanoramaVariant.vue'; import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue'; import CalendarIcon from 'vue-material-design-icons/Calendar.vue'; import MapIcon from 'vue-material-design-icons/Map.vue'; @@ -103,6 +105,16 @@ export default defineComponent({ icon: VideoIcon, link: '/videos', }, + { + name: t('memories', 'Live photos'), + icon: LivePhotoIcon, + link: '/livephotos', + }, + { + name: t('memories', 'Panoramas'), + icon: PanoramaVariantIcon, + link: '/panoramas', + }, { name: t('memories', 'Archive'), icon: ArchiveIcon, diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 0fcf9134e..32ccccc08 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -620,6 +620,16 @@ export default defineComponent({ set(DaysFilterType.VIDEOS); } + // Live photos + if (this.routeIsLivePhotos) { + set(DaysFilterType.LIVE); + } + + // Panoramas + if (this.routeIsPanoramas) { + set(DaysFilterType.PANO); + } + // Folder if (this.routeIsFolders || this.routeIsFolderShare) { const path = utils.getFolderRoutePath(this.config.folders_path); diff --git a/src/router.ts b/src/router.ts index 015a9c6ad..3d54f818d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -18,6 +18,8 @@ export type RouteId = | 'Folders' | 'Favorites' | 'Videos' + | 'LivePhotos' + | 'Panoramas' | 'Albums' | 'Archive' | 'ThisDay' @@ -60,6 +62,20 @@ export const routes: { [key in RouteId]: RouteConfig } = { props: (route: Route) => ({ rootTitle: t('memories', 'Videos') }), }, + Panoramas: { + path: '/panoramas', + component: Timeline, + name: 'panoramas', + props: (route: Route) => ({ rootTitle: t('memories', 'Panoramas') }), + }, + + LivePhotos: { + path: '/livephotos', + component: Timeline, + name: 'livephotos', + props: (route: Route) => ({ rootTitle: t('memories', 'Live photos') }), + }, + Albums: { path: '/albums/:user?/:name?', component: ClusterView, diff --git a/src/services/API.ts b/src/services/API.ts index effb62a0f..a58a71284 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -20,6 +20,8 @@ function tok(url: string) { export const enum DaysFilterType { FAVORITES = 'fav', VIDEOS = 'vid', + LIVE = 'live', + PANO = 'pano', FOLDER = 'folder', ARCHIVE = 'archive', ALBUM = 'albums', diff --git a/src/services/strings.ts b/src/services/strings.ts index aa40a02d1..14e5bdd9e 100644 --- a/src/services/strings.ts +++ b/src/services/strings.ts @@ -41,6 +41,10 @@ export function viewName(routeName: string): string { return t('memories', 'People'); case _m.routes.Videos.name: return t('memories', 'Videos'); + case _m.routes.LivePhotos.name: + return t('memories', 'Live photos'); + case _m.routes.Panoramas.name: + return t('memories', 'Panoramas'); case _m.routes.Albums.name: return t('memories', 'Albums'); case _m.routes.Archive.name: From 6bfcaf4ff6f55bd37a460d888ea8f4ba95a97524 Mon Sep 17 00:00:00 2001 From: Jo Van Bulck Date: Wed, 27 Nov 2024 15:38:37 -0800 Subject: [PATCH 2/3] feat: add photosphere viewer integration Fixes #1036 --- package-lock.json | 45 +++++++++ package.json | 2 + src/components/viewer/PhotoSphere.vue | 137 ++++++++++++++++++++++++++ src/components/viewer/Viewer.vue | 20 +++- 4 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 src/components/viewer/PhotoSphere.vue diff --git a/package-lock.json b/package-lock.json index 3161d496f..9bfb1b932 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "@nextcloud/sharing": "^0.2.3", "@nextcloud/upload": "1.6.0", "@nextcloud/vue": "^8.19.0", + "@photo-sphere-viewer/autorotate-plugin": "^5.11.1", + "@photo-sphere-viewer/core": "^5.11.1", "filerobot-image-editor": "^4.8.1", "fuse.js": "^7.0.0", "hammerjs": "^2.0.8", @@ -2340,6 +2342,24 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@photo-sphere-viewer/autorotate-plugin": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/autorotate-plugin/-/autorotate-plugin-5.11.1.tgz", + "integrity": "sha512-CZ2GUEU3HEQHqxEDwogtycALBTw3oeY8AFR/WzqR4QkXZBuTPdFU7zDxomqvGCQL8c6BBN0lCMakYybtQwoo/A==", + "license": "MIT", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.11.1" + } + }, + "node_modules/@photo-sphere-viewer/core": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.11.1.tgz", + "integrity": "sha512-bxWnoQGYjXfmHGee4OSkoYLZmdgqvJWMn7wmpK0V0Vf46Fqu+TJ4Yt8+dY2PgpM89HoKzNr15Dzt6jqOfjkFxQ==", + "license": "MIT", + "dependencies": { + "three": "^0.169.0" + } + }, "node_modules/@playwright/test": { "version": "1.47.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.1.tgz", @@ -9770,6 +9790,12 @@ } } }, + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "license": "MIT" + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -12944,6 +12970,20 @@ "integrity": "sha512-jqCi4G+Q0H6+Hm8wSN3vRX2+eXG2jXR2bwBX/sErVEsH5UaxT4Nb7KqgdeIjVfeF7ccIdRqpmIb4Pkf0lao67w==", "requires": {} }, + "@photo-sphere-viewer/autorotate-plugin": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/autorotate-plugin/-/autorotate-plugin-5.11.1.tgz", + "integrity": "sha512-CZ2GUEU3HEQHqxEDwogtycALBTw3oeY8AFR/WzqR4QkXZBuTPdFU7zDxomqvGCQL8c6BBN0lCMakYybtQwoo/A==", + "requires": {} + }, + "@photo-sphere-viewer/core": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.11.1.tgz", + "integrity": "sha512-bxWnoQGYjXfmHGee4OSkoYLZmdgqvJWMn7wmpK0V0Vf46Fqu+TJ4Yt8+dY2PgpM89HoKzNr15Dzt6jqOfjkFxQ==", + "requires": { + "three": "^0.169.0" + } + }, "@playwright/test": { "version": "1.47.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.1.tgz", @@ -18361,6 +18401,11 @@ "terser": "^5.26.0" } }, + "three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==" + }, "timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", diff --git a/package.json b/package.json index 5c121530c..d30dc9809 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "@nextcloud/sharing": "^0.2.3", "@nextcloud/upload": "1.6.0", "@nextcloud/vue": "^8.19.0", + "@photo-sphere-viewer/autorotate-plugin": "^5.11.1", + "@photo-sphere-viewer/core": "^5.11.1", "filerobot-image-editor": "^4.8.1", "fuse.js": "^7.0.0", "hammerjs": "^2.0.8", diff --git a/src/components/viewer/PhotoSphere.vue b/src/components/viewer/PhotoSphere.vue new file mode 100644 index 000000000..95506e49f --- /dev/null +++ b/src/components/viewer/PhotoSphere.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index ab0e41581..755d7b044 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -8,6 +8,7 @@ @fullscreenchange="fullscreenChange" > + @@ -15,7 +16,7 @@
@@ -66,6 +67,7 @@ import * as utils from '@services/utils'; import * as nativex from '@native'; import ImageEditor from './ImageEditor.vue'; +import PhotoSphere from './PhotoSphere.vue'; import PhotoSwipe, { type PhotoSwipeOptions } from 'photoswipe'; import 'photoswipe/style.css'; import PsImage from './PsImage'; @@ -84,6 +86,7 @@ import DownloadIcon from 'vue-material-design-icons/Download.vue'; import InfoIcon from 'vue-material-design-icons/InformationOutline.vue'; import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'; import TuneIcon from 'vue-material-design-icons/Tune.vue'; +import PanoramaSphereOutlineIcon from 'vue-material-design-icons/PanoramaSphereOutline.vue'; import SlideshowIcon from 'vue-material-design-icons/PlayBox.vue'; import EditFileIcon from 'vue-material-design-icons/FileEdit.vue'; import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue'; @@ -116,6 +119,7 @@ export default defineComponent({ NcActions, NcActionButton, ImageEditor, + PhotoSphere, }, mixins: [UserConfig], @@ -125,6 +129,7 @@ export default defineComponent({ isOpen: false, originalTitle: null as string | null, editorOpen: false, + sphereOpen: false, editorSrc: '', show: false, @@ -277,6 +282,13 @@ export default defineComponent({ callback: this.toggleSidebar, if: true, }, + { + id: 'sphere', + name: this.t('memories', 'Open in PhotoSphere'), + icon: PanoramaSphereOutlineIcon, + callback: this.openSphere, + if: !this.isVideo, + }, { id: 'edit', name: this.t('memories', 'Edit'), @@ -382,7 +394,7 @@ export default defineComponent({ /** Allow closing the viewer */ allowClose(): boolean { - return !this.editorOpen && !dav.isSingleItem() && !this.slideshowTimer; + return !this.editorOpen && !this.sphereOpen && !dav.isSingleItem() && !this.slideshowTimer; }, /** Get date taken string */ @@ -1016,6 +1028,10 @@ export default defineComponent({ this.editorOpen = true; }, + async openSphere() { + this.sphereOpen = true; + }, + /** Share the current photo externally */ shareCurrent() { _m.modals.sharePhotos([this.currentPhoto!]); From 5eb311e9a409105f19a72786c1974bd09dbe196e Mon Sep 17 00:00:00 2001 From: Jo Van Bulck Date: Thu, 28 Nov 2024 15:33:12 -0800 Subject: [PATCH 3/3] photosphere: hide close button when pane is open --- src/components/viewer/PhotoSphere.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/viewer/PhotoSphere.vue b/src/components/viewer/PhotoSphere.vue index 95506e49f..25585cf68 100644 --- a/src/components/viewer/PhotoSphere.vue +++ b/src/components/viewer/PhotoSphere.vue @@ -1,6 +1,7 @@