From 8a16deeec4f226f154805b927c67c956ff6aec72 Mon Sep 17 00:00:00 2001 From: Varun Patil Date: Tue, 8 Nov 2022 20:08:30 -0800 Subject: [PATCH] hls: initial commit --- appinfo/routes.php | 2 + lib/Controller/PageController.php | 5 ++ lib/Controller/VideoController.php | 96 ++++++++++++++++++++++++++++++ package-lock.json | 42 +++++++++++++ package.json | 1 + src/components/PsVideo.ts | 43 ++++++++++++- 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 lib/Controller/VideoController.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 0f9fb213..3bee0f37 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -57,6 +57,8 @@ return [ ['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], + ['name' => 'Video#transcode', 'url' => '/api/video/transcode/{fileid}/{profile}', 'verb' => 'GET'], + // Config API ['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'], diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 23d5a721..2da07c25 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -95,6 +95,11 @@ class PageController extends Controller $policy->addAllowedWorkerSrcDomain("'self'"); $policy->addAllowedScriptDomain("'self'"); + // Video player + $policy->addAllowedWorkerSrcDomain('blob:'); + $policy->addAllowedScriptDomain('blob:'); + $policy->addAllowedMediaDomain('blob:'); + // Allow nominatim for metadata $policy->addAllowedConnectDomain('nominatim.openstreetmap.org'); $policy->addAllowedFrameDomain('www.openstreetmap.org'); diff --git a/lib/Controller/VideoController.php b/lib/Controller/VideoController.php new file mode 100644 index 00000000..4df66a40 --- /dev/null +++ b/lib/Controller/VideoController.php @@ -0,0 +1,96 @@ + + * @author Varun Patil + * @license AGPL-3.0-or-later + * + * 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 . + */ + +namespace OCA\Memories\Controller; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\DataDisplayResponse; + +class VideoController extends ApiBase +{ + /** + * @NoAdminRequired + * + * @NoCSRFRequired + * + * Transcode a video to HLS by proxy + * + * @param string fileid + * @param string video profile + * + * @return JSONResponse an empty JSONResponse with respective http status code + */ + public function transcode(string $fileid, string $profile): Http\Response + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Make sure not running in read-only mode + if ($this->config->getSystemValue('memories.no_transcode', false)) { + return new JSONResponse(['message' => 'Transcoding disabled'], Http::STATUS_FORBIDDEN); + } + + // Get file + $files = $this->rootFolder->getById($fileid); + if (count($files) === 0) { + return new JSONResponse(['message' => 'File not found'], Http::STATUS_NOT_FOUND); + } + $file = $files[0]; + + // Local files only for now + if (!$file->getStorage()->isLocal()) { + return new JSONResponse(['message' => 'External storage not supported'], Http::STATUS_FORBIDDEN); + } + + // Get file path + $path = $file->getStorage()->getLocalFile($file->getInternalPath()); + if (!$path || !file_exists($path)) { + return new JSONResponse(['message' => 'File not found'], Http::STATUS_NOT_FOUND); + } + + // Make upstream request + $ch = curl_init("http://localhost:9999/vod/$path/$profile"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, 0); + $data = curl_exec($ch); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $returnCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // Check data was received + if ($returnCode >= 400 || false === $data) { + return new JSONResponse(['message' => 'Transcode failed'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // Create and send response + $response = new DataDisplayResponse($data, Http::STATUS_OK, [ + 'Content-Type' => $contentType, + ]); + $response->cacheFor(3600 * 24, false, false); + + return $response; + } +} diff --git a/package-lock.json b/package-lock.json index b87a3720..1207a3c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nextcloud/paths": "^2.1.0", "@nextcloud/sharing": "^0.1.0", "@nextcloud/vue": "7.0.0", + "@silvermine/videojs-quality-selector": "^1.2.5", "camelcase": "^7.0.0", "filerobot-image-editor": "^4.3.7", "justified-layout": "^4.1.0", @@ -2162,6 +2163,18 @@ "styled-components": "^5.1.0" } }, + "node_modules/@silvermine/videojs-quality-selector": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@silvermine/videojs-quality-selector/-/videojs-quality-selector-1.2.5.tgz", + "integrity": "sha512-cielchUzL8r2EX01S7PfR54tTbxDZR53xIDJoUi9Wg6pM2X+ftdJD6XiIDVOjPlBoG94iuG9LJwUtjX5IhrWZQ==", + "dependencies": { + "class.extend": "0.9.1", + "underscore": "1.13.1" + }, + "peerDependencies": { + "video.js": ">=6.0.0" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -3539,6 +3552,11 @@ "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==" }, + "node_modules/class.extend": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/class.extend/-/class.extend-0.9.1.tgz", + "integrity": "sha512-Tzj+2kAkZs+iGiUOUoKvtj4c/SjeVdKZXg/NbLTGKu0kp66h69dyMHQwOSzuyIghXAUswuY24TZc0HdaJCXx2A==" + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -9072,6 +9090,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -11900,6 +11923,15 @@ "use-callback-ref": "^1.2.4" } }, + "@silvermine/videojs-quality-selector": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@silvermine/videojs-quality-selector/-/videojs-quality-selector-1.2.5.tgz", + "integrity": "sha512-cielchUzL8r2EX01S7PfR54tTbxDZR53xIDJoUi9Wg6pM2X+ftdJD6XiIDVOjPlBoG94iuG9LJwUtjX5IhrWZQ==", + "requires": { + "class.extend": "0.9.1", + "underscore": "1.13.1" + } + }, "@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -13056,6 +13088,11 @@ "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==" }, + "class.extend": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/class.extend/-/class.extend-0.9.1.tgz", + "integrity": "sha512-Tzj+2kAkZs+iGiUOUoKvtj4c/SjeVdKZXg/NbLTGKu0kp66h69dyMHQwOSzuyIghXAUswuY24TZc0HdaJCXx2A==" + }, "clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -17325,6 +17362,11 @@ "which-boxed-primitive": "^1.0.2" } }, + "underscore": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json index 88253bd5..e065d406 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@nextcloud/paths": "^2.1.0", "@nextcloud/sharing": "^0.1.0", "@nextcloud/vue": "7.0.0", + "@silvermine/videojs-quality-selector": "^1.2.5", "camelcase": "^7.0.0", "filerobot-image-editor": "^4.3.7", "justified-layout": "^4.1.0", diff --git a/src/components/PsVideo.ts b/src/components/PsVideo.ts index 982decea..f1831dab 100644 --- a/src/components/PsVideo.ts +++ b/src/components/PsVideo.ts @@ -1,6 +1,11 @@ import PhotoSwipe from "photoswipe"; +import { generateUrl } from "@nextcloud/router"; + import videojs from "video.js"; import "video.js/dist/video-js.min.css"; +import qualitySelector from "@silvermine/videojs-quality-selector"; +import "@silvermine/videojs-quality-selector/dist/css/quality-selector.css"; +qualitySelector(videojs); /** * Check if slide has video content @@ -71,19 +76,53 @@ class VideoContentSetup { e.preventDefault(); } else { const content = e.slide.content; + const fileid = content.data.photo.fileid; + + // Create hls sources if enabled + let hlsSources = []; + const baseUrl = generateUrl( + `/apps/memories/api/video/transcode/${fileid}` + ); + for (const q of ["360p", "480p", "720p", "1080p"]) { + hlsSources.push({ + src: `${baseUrl}/${q}.m3u8`, + label: q, + type: "application/x-mpegURL", + selected: q === "480p" ? true : undefined, + }); + } + content.videojs = videojs(content.videoElement, { fluid: true, autoplay: true, controls: true, - sources: [{ src: e.slide.data.src }], + sources: [ + ...hlsSources, + { + src: e.slide.data.src, + label: "Original", + }, + ], preload: "metadata", inactivityTimeout: 0, html5: { vhs: { - withCredentials: true, + overrideNative: !videojs.browser.IS_SAFARI, + withCredentials: false, }, }, + controlBar: { + children: [ + "playToggle", + "progressControl", + "volumePanel", + "qualitySelector", + "fullscreenToggle", + ], + }, }); + + globalThis.videojs = content.videojs; } } });