mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-18 01:39:00 +00:00
feat: better mkv support + video.js player
This commit is contained in:
@@ -120,7 +120,27 @@ class VideoController extends OCSController {
|
||||
|
||||
// Get file info
|
||||
$fileSize = $file->getSize();
|
||||
$mimeType = $file->getMimeType();
|
||||
|
||||
// Override MIME type based on file extension for better browser compatibility
|
||||
$fileName = $file->getName();
|
||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
$mimeTypeMap = [
|
||||
'mp4' => 'video/mp4',
|
||||
'webm' => 'video/webm',
|
||||
'ogg' => 'video/ogg',
|
||||
'ogv' => 'video/ogg',
|
||||
'mkv' => 'video/webm', // Try webm MIME type as webm is a subset of MKV
|
||||
'avi' => 'video/x-msvideo',
|
||||
'mov' => 'video/quicktime',
|
||||
];
|
||||
$mimeType = $mimeTypeMap[$extension] ?? $file->getMimeType();
|
||||
|
||||
$this->logger->info('Streaming video file', [
|
||||
'fileName' => $fileName,
|
||||
'extension' => $extension,
|
||||
'mimeType' => $mimeType,
|
||||
'fileSize' => $fileSize,
|
||||
]);
|
||||
|
||||
// Handle range requests for video seeking
|
||||
$rangeHeader = $this->request->getHeader('range');
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"chart.js": "^4.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"video.js": "^8.23.4",
|
||||
"vue": "^3.5.22",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
|
||||
158
pnpm-lock.yaml
generated
158
pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
||||
linkifyjs:
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2
|
||||
video.js:
|
||||
specifier: ^8.23.4
|
||||
version: 8.23.4
|
||||
vue:
|
||||
specifier: ^3.5.22
|
||||
version: 3.5.22(typescript@5.9.3)
|
||||
@@ -1059,6 +1062,19 @@ packages:
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@videojs/http-streaming@3.17.2':
|
||||
resolution: {integrity: sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
peerDependencies:
|
||||
video.js: ^8.19.0
|
||||
|
||||
'@videojs/vhs-utils@4.1.1':
|
||||
resolution: {integrity: sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
|
||||
'@videojs/xhr@2.7.0':
|
||||
resolution: {integrity: sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4':
|
||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -1190,6 +1206,10 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -1200,6 +1220,9 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
aes-decrypter@4.0.2:
|
||||
resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==}
|
||||
|
||||
ajv-draft-04@1.0.0:
|
||||
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
|
||||
peerDependencies:
|
||||
@@ -1694,6 +1717,9 @@ packages:
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
dom-walk@0.1.2:
|
||||
resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==}
|
||||
|
||||
domain-browser@4.22.0:
|
||||
resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2150,6 +2176,9 @@ packages:
|
||||
resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
global@4.4.0:
|
||||
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
|
||||
|
||||
globals@13.24.0:
|
||||
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2379,6 +2408,9 @@ packages:
|
||||
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-function@1.0.2:
|
||||
resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==}
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2592,6 +2624,9 @@ packages:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
m3u8-parser@7.2.0:
|
||||
resolution: {integrity: sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==}
|
||||
|
||||
magic-string@0.30.19:
|
||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||
|
||||
@@ -2738,6 +2773,9 @@ packages:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
min-document@2.19.0:
|
||||
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
|
||||
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
@@ -2764,12 +2802,21 @@ packages:
|
||||
moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
|
||||
mpd-parser@1.3.1:
|
||||
resolution: {integrity: sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
mux.js@7.1.0:
|
||||
resolution: {integrity: sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
hasBin: true
|
||||
|
||||
nano-spawn@1.0.3:
|
||||
resolution: {integrity: sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==}
|
||||
engines: {node: '>=20.17'}
|
||||
@@ -2936,6 +2983,10 @@ packages:
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
pkcs7@1.0.4:
|
||||
resolution: {integrity: sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==}
|
||||
hasBin: true
|
||||
|
||||
pkg-dir@5.0.0:
|
||||
resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3758,6 +3809,21 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
video.js@8.23.4:
|
||||
resolution: {integrity: sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==}
|
||||
|
||||
videojs-contrib-quality-levels@4.1.0:
|
||||
resolution: {integrity: sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==}
|
||||
engines: {node: '>=16', npm: '>=8'}
|
||||
peerDependencies:
|
||||
video.js: ^8
|
||||
|
||||
videojs-font@4.2.0:
|
||||
resolution: {integrity: sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==}
|
||||
|
||||
videojs-vtt.js@0.15.5:
|
||||
resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==}
|
||||
|
||||
vite-plugin-css-injected-by-js@3.5.2:
|
||||
resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==}
|
||||
peerDependencies:
|
||||
@@ -4969,6 +5035,28 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@videojs/http-streaming@3.17.2(video.js@8.23.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@videojs/vhs-utils': 4.1.1
|
||||
aes-decrypter: 4.0.2
|
||||
global: 4.4.0
|
||||
m3u8-parser: 7.2.0
|
||||
mpd-parser: 1.3.1
|
||||
mux.js: 7.1.0
|
||||
video.js: 8.23.4
|
||||
|
||||
'@videojs/vhs-utils@4.1.1':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
global: 4.4.0
|
||||
|
||||
'@videojs/xhr@2.7.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
global: 4.4.0
|
||||
is-function: 1.0.2
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@6.3.6(@types/node@20.17.10)(sass-embedded@1.93.2)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vite: 6.3.6(@types/node@20.17.10)(sass-embedded@1.93.2)(sass@1.93.2)(yaml@2.8.1)
|
||||
@@ -5145,12 +5233,21 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
aes-decrypter@4.0.2:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@videojs/vhs-utils': 4.1.1
|
||||
global: 4.4.0
|
||||
pkcs7: 1.0.4
|
||||
|
||||
ajv-draft-04@1.0.0(ajv@8.13.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.13.0
|
||||
@@ -5682,6 +5779,8 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
dom-walk@0.1.2: {}
|
||||
|
||||
domain-browser@4.22.0: {}
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
@@ -6279,6 +6378,11 @@ snapshots:
|
||||
kind-of: 6.0.3
|
||||
which: 1.3.1
|
||||
|
||||
global@4.4.0:
|
||||
dependencies:
|
||||
min-document: 2.19.0
|
||||
process: 0.11.10
|
||||
|
||||
globals@13.24.0:
|
||||
dependencies:
|
||||
type-fest: 0.20.2
|
||||
@@ -6517,6 +6621,8 @@ snapshots:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.4.0
|
||||
|
||||
is-function@1.0.2: {}
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -6720,6 +6826,12 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
m3u8-parser@7.2.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@videojs/vhs-utils': 4.1.1
|
||||
global: 4.4.0
|
||||
|
||||
magic-string@0.30.19:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -7005,6 +7117,10 @@ snapshots:
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
min-document@2.19.0:
|
||||
dependencies:
|
||||
dom-walk: 0.1.2
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimalistic-crypto-utils@1.0.1: {}
|
||||
@@ -7032,10 +7148,22 @@ snapshots:
|
||||
|
||||
moment@2.30.1: {}
|
||||
|
||||
mpd-parser@1.3.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@videojs/vhs-utils': 4.1.1
|
||||
'@xmldom/xmldom': 0.8.11
|
||||
global: 4.4.0
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
mux.js@7.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
global: 4.4.0
|
||||
|
||||
nano-spawn@1.0.3: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
@@ -7231,6 +7359,10 @@ snapshots:
|
||||
|
||||
pidtree@0.6.0: {}
|
||||
|
||||
pkcs7@1.0.4:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
|
||||
pkg-dir@5.0.0:
|
||||
dependencies:
|
||||
find-up: 5.0.0
|
||||
@@ -8210,6 +8342,32 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
video.js@8.23.4:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@videojs/http-streaming': 3.17.2(video.js@8.23.4)
|
||||
'@videojs/vhs-utils': 4.1.1
|
||||
'@videojs/xhr': 2.7.0
|
||||
aes-decrypter: 4.0.2
|
||||
global: 4.4.0
|
||||
m3u8-parser: 7.2.0
|
||||
mpd-parser: 1.3.1
|
||||
mux.js: 7.1.0
|
||||
videojs-contrib-quality-levels: 4.1.0(video.js@8.23.4)
|
||||
videojs-font: 4.2.0
|
||||
videojs-vtt.js: 0.15.5
|
||||
|
||||
videojs-contrib-quality-levels@4.1.0(video.js@8.23.4):
|
||||
dependencies:
|
||||
global: 4.4.0
|
||||
video.js: 8.23.4
|
||||
|
||||
videojs-font@4.2.0: {}
|
||||
|
||||
videojs-vtt.js@0.15.5:
|
||||
dependencies:
|
||||
global: 4.4.0
|
||||
|
||||
vite-plugin-css-injected-by-js@3.5.2(vite@6.3.6(@types/node@20.17.10)(sass-embedded@1.93.2)(sass@1.93.2)(yaml@2.8.1)):
|
||||
dependencies:
|
||||
vite: 6.3.6(@types/node@20.17.10)(sass-embedded@1.93.2)(sass@1.93.2)(yaml@2.8.1)
|
||||
|
||||
@@ -5,19 +5,30 @@
|
||||
</template>
|
||||
|
||||
<div v-if="video" class="video-container">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="video-player"
|
||||
controls
|
||||
:poster="video.thumbnail ?? undefined"
|
||||
@loadedmetadata="handleLoadedMetadata"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@play="handlePlay"
|
||||
@pause="handlePause"
|
||||
@ended="handleEnded">
|
||||
<source :src="streamUrl" :type="videoMimeType" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div v-if="showError" class="error-message">
|
||||
<h3>Video format not supported in {{ browserName }}</h3>
|
||||
<p>
|
||||
This {{ videoFileExtension.toUpperCase() }} file contains codecs that {{ browserName }} cannot play.
|
||||
<span v-if="isFirefox">Chrome may have better compatibility with this file format.</span>
|
||||
</p>
|
||||
<p>You can download the video to play it in a media player like VLC or MPV.</p>
|
||||
<div class="error-actions">
|
||||
<a :href="streamUrl" :download="video.path.split('/').pop()" class="download-button">
|
||||
Download Video
|
||||
</a>
|
||||
<a v-if="isFirefox" :href="currentUrl" class="download-button secondary" target="_blank" rel="noopener">
|
||||
Try in Chrome
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="video-wrapper">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="video-js vjs-default-skin vjs-big-play-centered"
|
||||
:poster="video.thumbnail ?? undefined">
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="video-info">
|
||||
<h2>{{ video.title || 'Untitled' }}</h2>
|
||||
@@ -35,12 +46,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, computed, watch, onUnmounted } from 'vue'
|
||||
import { defineComponent, ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { axios } from '@/axios'
|
||||
import Page from '@/components/Page.vue'
|
||||
import playback from '@/composables/usePlayback'
|
||||
import type { Video } from '@/models/media'
|
||||
import videojs from 'video.js'
|
||||
import type Player from 'video.js/dist/types/player'
|
||||
import 'video.js/dist/video-js.css'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VideoView',
|
||||
@@ -52,6 +66,8 @@
|
||||
const video = ref<Video | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
const player = ref<Player | null>(null)
|
||||
const showError = ref(false)
|
||||
const { overwriteQueue, isPlaying, currentTime, setSeek, currentMedia } = playback
|
||||
|
||||
const streamUrl = computed(() => {
|
||||
@@ -67,103 +83,171 @@
|
||||
webm: 'video/webm',
|
||||
ogg: 'video/ogg',
|
||||
ogv: 'video/ogg',
|
||||
mkv: 'video/x-matroska',
|
||||
mkv: 'video/webm', // Try webm MIME type as webm is a subset of MKV
|
||||
avi: 'video/x-msvideo',
|
||||
mov: 'video/quicktime',
|
||||
}
|
||||
return mimeTypes[ext || ''] || 'video/mp4'
|
||||
})
|
||||
|
||||
const videoFileExtension = computed(() => {
|
||||
if (!video.value?.path) return ''
|
||||
return video.value.path.split('.').pop()?.toLowerCase() || ''
|
||||
})
|
||||
|
||||
const isFirefox = /Firefox/i.test(navigator.userAgent)
|
||||
const browserName = computed(() => {
|
||||
const ua = navigator.userAgent
|
||||
if (/Firefox/i.test(ua)) return 'Firefox'
|
||||
if (/Chrome/i.test(ua)) return 'Chrome'
|
||||
if (/Safari/i.test(ua)) return 'Safari'
|
||||
if (/Edge/i.test(ua)) return 'Edge'
|
||||
return 'your browser'
|
||||
})
|
||||
|
||||
const currentUrl = computed(() => window.location.href)
|
||||
|
||||
let isSyncing = false
|
||||
|
||||
onMounted(async () => {
|
||||
const id = decodeURIComponent(route.params.id as string)
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/video/${id}`)
|
||||
video.value = res.data
|
||||
isLoading.value = false
|
||||
|
||||
// Add video to queue and start playing
|
||||
// Add video to queue
|
||||
if (video.value) {
|
||||
overwriteQueue([{ type: 'video', ...video.value }], 0)
|
||||
// Auto-play the video after a short delay to ensure element is ready
|
||||
setTimeout(() => {
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch((err) => console.error('Auto-play failed:', err))
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Wait for DOM to update before initializing video.js
|
||||
await nextTick()
|
||||
|
||||
// Initialize video.js player
|
||||
if (videoElement.value) {
|
||||
console.log('Initializing video.js player')
|
||||
|
||||
player.value = videojs(videoElement.value, {
|
||||
controls: true,
|
||||
responsive: true,
|
||||
aspectRatio: '16:9',
|
||||
preload: 'auto',
|
||||
html5: {
|
||||
vhs: {
|
||||
overrideNative: true,
|
||||
},
|
||||
nativeAudioTracks: false,
|
||||
nativeVideoTracks: false,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Video.js player initialized:', player.value)
|
||||
|
||||
// Set the source
|
||||
player.value.src({
|
||||
src: streamUrl.value,
|
||||
type: videoMimeType.value,
|
||||
})
|
||||
|
||||
console.log('Video source set:', streamUrl.value, videoMimeType.value)
|
||||
|
||||
// Event listeners
|
||||
player.value.on('play', () => {
|
||||
console.log('Video play event')
|
||||
if (!isPlaying.value && !isSyncing) {
|
||||
isSyncing = true
|
||||
playback.togglePlay()
|
||||
setTimeout(() => { isSyncing = false }, 100)
|
||||
}
|
||||
})
|
||||
|
||||
player.value.on('pause', () => {
|
||||
console.log('Video pause event')
|
||||
if (isPlaying.value && !isSyncing) {
|
||||
isSyncing = true
|
||||
playback.togglePlay()
|
||||
setTimeout(() => { isSyncing = false }, 100)
|
||||
}
|
||||
})
|
||||
|
||||
player.value.on('ended', () => {
|
||||
console.log('Video ended event')
|
||||
playback.next()
|
||||
})
|
||||
|
||||
player.value.on('timeupdate', () => {
|
||||
if (!isSyncing && player.value) {
|
||||
const time = player.value.currentTime()
|
||||
if (time !== undefined && Math.abs(currentTime.value - time) > 0.5) {
|
||||
setSeek(time)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
player.value.on('error', () => {
|
||||
const error = player.value?.error()
|
||||
console.error('Video.js error:', error)
|
||||
|
||||
// Check for unsupported media errors
|
||||
if (error && (error.code === 4 || error.code === 3)) {
|
||||
// MEDIA_ERR_SRC_NOT_SUPPORTED (4) or MEDIA_ERR_DECODE (3)
|
||||
console.log('Media format not supported, showing error message')
|
||||
showError.value = true
|
||||
}
|
||||
})
|
||||
|
||||
player.value.on('loadedmetadata', () => {
|
||||
console.log('Video metadata loaded')
|
||||
})
|
||||
|
||||
// Auto-play
|
||||
player.value.ready(() => {
|
||||
console.log('Video.js ready, attempting auto-play')
|
||||
player.value?.play().catch((err) => console.error('Auto-play failed:', err))
|
||||
})
|
||||
} else {
|
||||
console.error('Video element not found')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load video:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
let isSyncing = false
|
||||
|
||||
// Sync video element with playback state
|
||||
// Sync video.js player with playback state
|
||||
watch(isPlaying, (playing) => {
|
||||
if (!videoElement.value || isSyncing) return
|
||||
if (playing && videoElement.value.paused) {
|
||||
videoElement.value.play().catch((err) => console.error('Video play failed:', err))
|
||||
} else if (!playing && !videoElement.value.paused) {
|
||||
videoElement.value.pause()
|
||||
if (!player.value || isSyncing) return
|
||||
if (playing && player.value.paused()) {
|
||||
player.value.play().catch((err) => console.error('Video play failed:', err))
|
||||
} else if (!playing && !player.value.paused()) {
|
||||
player.value.pause()
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
// Sync video currentTime when seeking from external controls
|
||||
let lastExternalSeek = 0
|
||||
watch(currentTime, (time) => {
|
||||
if (!videoElement.value || isSyncing) return
|
||||
const diff = Math.abs(videoElement.value.currentTime - time)
|
||||
if (!player.value || isSyncing) return
|
||||
const currentPlayerTime = player.value.currentTime()
|
||||
if (currentPlayerTime === undefined) return
|
||||
const diff = Math.abs(currentPlayerTime - time)
|
||||
// Only sync if difference is significant (more than 1 second) to avoid feedback loops
|
||||
if (diff > 1 && Date.now() - lastExternalSeek > 500) {
|
||||
videoElement.value.currentTime = time
|
||||
player.value.currentTime(time)
|
||||
lastExternalSeek = Date.now()
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
// When current media changes away from this video, pause it
|
||||
watch(currentMedia, (media) => {
|
||||
if (!videoElement.value) return
|
||||
if (!player.value) return
|
||||
if (!media || media.type !== 'video' || media.id !== video.value?.id) {
|
||||
videoElement.value.pause()
|
||||
player.value.pause()
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (!videoElement.value) return
|
||||
// Video is ready to play
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
// Don't update during sync to avoid feedback loops
|
||||
if (isSyncing) return
|
||||
// Passively update the current time for display only
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
// Video started playing - sync with playback state
|
||||
if (!isPlaying.value) {
|
||||
isSyncing = true
|
||||
playback.togglePlay()
|
||||
setTimeout(() => { isSyncing = false }, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
// Video paused - sync with playback state
|
||||
if (isPlaying.value) {
|
||||
isSyncing = true
|
||||
playback.togglePlay()
|
||||
setTimeout(() => { isSyncing = false }, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
// Video ended
|
||||
playback.next()
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
@@ -176,9 +260,10 @@
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
// Pause video when leaving the page
|
||||
if (videoElement.value) {
|
||||
videoElement.value.pause()
|
||||
// Dispose video.js player
|
||||
if (player.value) {
|
||||
player.value.dispose()
|
||||
player.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -187,12 +272,12 @@
|
||||
isLoading,
|
||||
streamUrl,
|
||||
videoMimeType,
|
||||
videoFileExtension,
|
||||
videoElement,
|
||||
handleLoadedMetadata,
|
||||
handleTimeUpdate,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleEnded,
|
||||
showError,
|
||||
isFirefox,
|
||||
browserName,
|
||||
currentUrl,
|
||||
formatDuration,
|
||||
}
|
||||
},
|
||||
@@ -201,35 +286,97 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
background-color: #000;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
margin-top: 1.5rem;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
.video-wrapper {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
background-color: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
.error-message {
|
||||
padding: 2rem;
|
||||
background-color: var(--color-background-dark);
|
||||
border: 2px solid var(--color-error);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
span:not(:last-child)::after {
|
||||
content: '•';
|
||||
margin-left: 1rem;
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--color-error);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-element-light);
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background-color: var(--color-background-dark);
|
||||
border: 2px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-info {
|
||||
margin-top: 1.5rem;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
|
||||
span:not(:last-child)::after {
|
||||
content: '•';
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Global video.js styles (not scoped)
|
||||
.video-js {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,6 +28,7 @@ export default createAppConfig(
|
||||
if (id.includes('vue')) return 'vue'
|
||||
if (id.includes('vue-router')) return 'vue-router'
|
||||
if (id.includes('axios')) return 'axios'
|
||||
if (id.includes('video.js')) return 'video-js'
|
||||
return 'vendor' // fallback for other deps
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user