mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-17 17:38:02 +00:00
feat: basic video stream support
This commit is contained in:
@@ -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);
|
||||
|
||||
56
openapi.json
56
openapi.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
235
src/views/VideoView.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user