feat: better mkv support + video.js player

This commit is contained in:
2025-10-06 10:58:30 +03:00
parent 3f6c22b67e
commit 130426f4f3
5 changed files with 430 additions and 103 deletions

View File

@@ -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');

View File

@@ -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
View File

@@ -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)

View File

@@ -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>

View File

@@ -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
}
},