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/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/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/components/viewer/PhotoSphere.vue b/src/components/viewer/PhotoSphere.vue
new file mode 100644
index 000000000..25585cf68
--- /dev/null
+++ b/src/components/viewer/PhotoSphere.vue
@@ -0,0 +1,146 @@
+
+