feat: basic video stream support

This commit is contained in:
2025-10-06 10:22:07 +03:00
parent bb132ba8ac
commit 3f6c22b67e
6 changed files with 323 additions and 84 deletions

View File

@@ -12,7 +12,6 @@ use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\OCSController;
use OCP\Files\File;
@@ -88,24 +87,18 @@ class VideoController extends OCSController {
}
/**
* Stream a video file for playback
* Stream a video file for playback with range request support
*
* @param int $id Video ID
*
* @return FileDisplayResponse<Http::STATUS_OK, array{}>
* | JSONResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
* | JSONResponse<Http::STATUS_FORBIDDEN, array{message: string}, array{}>
* | JSONResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}>
* @return JSONResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
*
* 200: File response returned successfully
* 401: User not authenticated
* 403: Video does not belong to current user
* 404: Video file or record not found
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/api/video/{id}/stream')]
public function streamVideo(int $id): FileDisplayResponse|JSONResponse {
public function streamVideo(int $id) {
$this->logger->info('Received request to stream video with ID: ' . $id);
$user = $this->userSession->getUser();
@@ -125,7 +118,76 @@ class VideoController extends OCSController {
throw new NotFoundException();
}
return new FileDisplayResponse($file);
// Get file info
$fileSize = $file->getSize();
$mimeType = $file->getMimeType();
// Handle range requests for video seeking
$rangeHeader = $this->request->getHeader('range');
$start = 0;
$end = $fileSize - 1;
$statusCode = Http::STATUS_OK;
if ($rangeHeader && preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = (int)$matches[1];
$end = $matches[2] !== '' ? (int)$matches[2] : $end;
$statusCode = Http::STATUS_PARTIAL_CONTENT;
}
$length = $end - $start + 1;
// Clear any previous output
while (ob_get_level() > 0) {
ob_end_clean();
}
// Set headers for streaming
http_response_code($statusCode);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=86400');
header('Content-Disposition: inline; filename="' . basename($file->getName()) . '"');
header('X-Content-Type-Options: nosniff');
// Add CORS headers if needed
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Range, Content-Type');
header('Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges');
if ($statusCode === Http::STATUS_PARTIAL_CONTENT) {
header("Content-Range: bytes $start-$end/$fileSize");
}
// Stream the file in chunks
$handle = $file->fopen('r');
if ($handle === false) {
$this->logger->error('Failed to open video file for streaming');
http_response_code(500);
exit;
}
if ($start > 0) {
fseek($handle, $start);
}
$remaining = $length;
$chunkSize = 1024 * 1024; // 1MB chunks for video
while ($remaining > 0 && !feof($handle)) {
$readSize = min($chunkSize, $remaining);
$data = fread($handle, $readSize);
if ($data === false) {
break;
}
echo $data;
flush();
$remaining -= strlen($data);
}
fclose($handle);
exit;
} catch (NotFoundException $e) {
$this->logger->error('Video file not found for ID: ' . $id, ['exception' => $e]);
return new JSONResponse(['message' => 'Video not found'], Http::STATUS_NOT_FOUND);

View File

@@ -3011,7 +3011,7 @@
"/ocs/v2.php/apps/jukebox/api/video/{id}/stream": {
"get": {
"operationId": "video-stream-video",
"summary": "Stream a video file for playback",
"summary": "Stream a video file for playback with range request support",
"tags": [
"video"
],
@@ -3034,6 +3034,13 @@
"format": "int64"
}
},
{
"name": "range",
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
@@ -3046,17 +3053,6 @@
}
],
"responses": {
"200": {
"description": "File response returned successfully",
"content": {
"*/*": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "User not authenticated",
"content": {
@@ -3099,42 +3095,6 @@
}
}
}
},
"403": {
"description": "Video does not belong to current user",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"404": {
"description": "Video file or record not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}

View File

@@ -21,6 +21,7 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import { useRouter } from 'vue-router'
import type { Video } from '@/models/media'
import VideoIcon from '@icons/Video.vue'
@@ -40,8 +41,9 @@
components: {
VideoIcon,
},
emits: ['play'],
setup(props, { emit }) {
setup(props) {
const router = useRouter()
const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
@@ -56,7 +58,7 @@
}
const handleClick = () => {
emit('play', props.video)
router.push(`/videos/${props.video.id}`)
}
return {

View File

@@ -11,6 +11,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/podcasts/:id', component: () => import('@/views/PodcastView.vue') },
// { path: '/audiobooks', component: () => import('@/views/AudiobooksView.vue') },
{ path: '/videos', component: () => import('@/views/VideosView.vue') },
{ path: '/videos/:id', component: () => import('@/views/VideoView.vue') },
// { path: '/genres', component: () => import('@/views/GenresView.vue') },
{ path: '/radio', component: () => import('@/views/RadioStationsView.vue') },
]

235
src/views/VideoView.vue Normal file
View File

@@ -0,0 +1,235 @@
<template>
<Page :loading="isLoading">
<template #title>
{{ video?.title || 'Video' }}
</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 class="video-info">
<h2>{{ video.title || 'Untitled' }}</h2>
<div class="meta">
<span v-if="video.width && video.height" class="resolution">
{{ video.width }}×{{ video.height }}
</span>
<span v-if="video.year" class="year">{{ video.year }}</span>
<span v-if="video.genre" class="genre">{{ video.genre }}</span>
<span v-if="video.duration" class="duration">{{ formatDuration(video.duration) }}</span>
</div>
</div>
</div>
</Page>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, computed, watch, onUnmounted } 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'
export default defineComponent({
name: 'VideoView',
components: {
Page,
},
setup() {
const route = useRoute()
const video = ref<Video | null>(null)
const isLoading = ref(true)
const videoElement = ref<HTMLVideoElement | null>(null)
const { overwriteQueue, isPlaying, currentTime, setSeek, currentMedia } = playback
const streamUrl = computed(() => {
if (!video.value) return ''
return `${axios.defaults.baseURL}/video/${video.value.id}/stream`
})
const videoMimeType = computed(() => {
if (!video.value?.path) return 'video/mp4'
const ext = video.value.path.split('.').pop()?.toLowerCase()
const mimeTypes: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
ogv: 'video/ogg',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
mov: 'video/quicktime',
}
return mimeTypes[ext || ''] || 'video/mp4'
})
onMounted(async () => {
const id = decodeURIComponent(route.params.id as string)
try {
const res = await axios.get(`/video/${id}`)
video.value = res.data
// Add video to queue and start playing
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)
}
} catch (err) {
console.error('Failed to load video:', err)
} finally {
isLoading.value = false
}
})
let isSyncing = false
// Sync video element 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()
}
}, { 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)
// 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
lastExternalSeek = Date.now()
}
}, { flush: 'post' })
// When current media changes away from this video, pause it
watch(currentMedia, (media) => {
if (!videoElement.value) return
if (!media || media.type !== 'video' || media.id !== video.value?.id) {
videoElement.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)
const secs = seconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
onUnmounted(() => {
// Pause video when leaving the page
if (videoElement.value) {
videoElement.value.pause()
}
})
return {
video,
isLoading,
streamUrl,
videoMimeType,
videoElement,
handleLoadedMetadata,
handleTimeUpdate,
handlePlay,
handlePause,
handleEnded,
formatDuration,
}
},
})
</script>
<style scoped lang="scss">
.video-container {
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;
}
.meta {
display: flex;
gap: 1rem;
font-size: 0.9rem;
color: var(--color-text-maxcontrast);
span:not(:last-child)::after {
content: '•';
margin-left: 1rem;
}
}
}
}
</style>

View File

@@ -3,7 +3,7 @@
<template #title> Videos </template>
<div class="video-gallery">
<VideoGalleryItem v-for="video in videos" :key="video.id" :video="video" @play="handlePlay" />
<VideoGalleryItem v-for="video in videos" :key="video.id" :video="video" />
</div>
</Page>
</template>
@@ -15,7 +15,6 @@
import VideoGalleryItem from '@/components/media/VideoGalleryItem.vue'
import Page from '@/components/Page.vue'
import playback from '@/composables/usePlayback'
export default defineComponent({
name: 'VideosView',
@@ -23,7 +22,6 @@
setup() {
const videos = ref<Video[]>([])
const isLoading = ref(true)
const { overwriteQueue } = playback
onMounted(async () => {
try {
@@ -36,28 +34,9 @@
}
})
const handlePlay = (video: Video) => {
const index = videos.value.findIndex((v) => v.id === video.id)
if (index !== -1) {
// Convert videos to playable format
const playableVideos = videos.value.map((v) => ({
id: v.id,
title: v.title || 'Untitled',
artist: null,
album: null,
duration: v.duration || 0,
thumbnail: v.thumbnail,
streamUrl: `/video/${v.id}/stream`,
type: 'video' as const,
}))
overwriteQueue(playableVideos, index)
}
}
return {
videos,
isLoading,
handlePlay,
}
},
})