feat: implement VideoProgressService for persistent video progress tracking, refactor video player setup, and enhance backend (model/serializer/view) to support progress saving
This commit is contained in:
parent
77960560d3
commit
26e34a2dfc
8 changed files with 222 additions and 21 deletions
|
|
@ -1,5 +1,5 @@
|
|||
from django.contrib import admin
|
||||
from .models import Video
|
||||
from .models import Video, VideoProgress
|
||||
from import_export import resources
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
|
|
@ -7,8 +7,18 @@ class VideoResource(resources.ModelResource):
|
|||
class Meta:
|
||||
model = Video
|
||||
|
||||
class VideoProgressInline(admin.TabularInline):
|
||||
model = VideoProgress
|
||||
extra = 0
|
||||
readonly_fields = ('user', 'position', 'updated_at')
|
||||
can_delete = False
|
||||
|
||||
@admin.register(Video)
|
||||
class VideoAdmin(ImportExportModelAdmin):
|
||||
resource_class = VideoResource
|
||||
readonly_fields = ('file_path',)
|
||||
list_display = ('title', 'file_path', 'genre', 'created_at')
|
||||
list_display = ('title', 'file_path', 'genre', 'created_at', 'is_available')
|
||||
list_display_links = ('title',)
|
||||
list_filter = ('is_available', 'genre', 'created_at')
|
||||
search_fields = ('title', 'description')
|
||||
inlines = [VideoProgressInline]
|
||||
|
|
@ -15,3 +15,25 @@ class Video(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f'({self.id}) {self.title}'
|
||||
|
||||
|
||||
class VideoProgress(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='video_progress'
|
||||
)
|
||||
video = models.ForeignKey(
|
||||
'Video',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='progress_entries'
|
||||
)
|
||||
position = models.FloatField(default=0.0)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'video')
|
||||
ordering = ['-updated_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user} - {self.video} @ {round(self.position, 1)}s'
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
from rest_framework import serializers
|
||||
from video_app.class_assets import VIDEO_GENRES
|
||||
from .models import Video
|
||||
from .models import Video, VideoProgress
|
||||
|
||||
class VideoSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Video
|
||||
fields = "__all__"
|
||||
|
||||
class VideoProgressSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = VideoProgress
|
||||
fields = ['id', 'user', 'video', 'position', 'updated_at']
|
||||
read_only_fields = ['id', 'user', 'updated_at']
|
||||
|
||||
class VideoUploadSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Video
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from .serializer import VideoSerializer, VideoUploadSerializer
|
||||
from .models import Video
|
||||
from .serializer import VideoProgressSerializer, VideoSerializer, VideoUploadSerializer
|
||||
from .models import Video, VideoProgress
|
||||
from .services import save_video_file, create_video_record
|
||||
from .class_assets import VIDEO_GENRES
|
||||
from django.conf import settings
|
||||
|
|
@ -71,3 +72,33 @@ def video_upload(request):
|
|||
return Response(VideoSerializer(video).data, status=201)
|
||||
except Exception as e:
|
||||
return Response({'error': f'Error while saving video: {str(e)}'}, status=500)
|
||||
|
||||
@api_view(['GET', 'POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def video_progress_view(request, video_id):
|
||||
"""
|
||||
GET: Get last watched position of a video for the current user.
|
||||
POST: Update the current watching position for the user.
|
||||
"""
|
||||
video = get_object_or_404(Video, id=video_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
progress = VideoProgress.objects.get(user=request.user, video=video)
|
||||
serializer = VideoProgressSerializer(progress)
|
||||
return Response(serializer.data)
|
||||
except VideoProgress.DoesNotExist:
|
||||
return Response({'position': 0.0})
|
||||
|
||||
elif request.method == 'POST':
|
||||
position = request.data.get('position')
|
||||
if position is None:
|
||||
return Response({'error': 'No position provided'}, status=400)
|
||||
|
||||
progress, _ = VideoProgress.objects.update_or_create(
|
||||
user=request.user,
|
||||
video=video,
|
||||
defaults={'position': position}
|
||||
)
|
||||
serializer = VideoProgressSerializer(progress)
|
||||
return Response(serializer.data)
|
||||
|
|
@ -25,6 +25,7 @@ urlpatterns = [
|
|||
path('video/upload/', video_views.video_upload, name='video_upload'),
|
||||
path('video/genres/', video_views.genre_list, name='genre_list'),
|
||||
path('video/<int:id>/', video_views.check_video_resolutions, name='check_video_resolutions'),
|
||||
path('video/progress/<int:video_id>', video_views.video_progress_view, name='video-progress'),
|
||||
|
||||
# Users URLs
|
||||
path('users/', user_views.user_list, name='user_list'),
|
||||
|
|
|
|||
|
|
@ -70,7 +70,10 @@
|
|||
<img src="./../../../../assets/img/logo_ci.svg" alt="Logo" />
|
||||
</div>
|
||||
</div>
|
||||
<app-video-player [playVideo]="playVideo"></app-video-player>
|
||||
<app-video-player
|
||||
[playVideo]="playVideo"
|
||||
[currentVideo]="currentVideo"
|
||||
></app-video-player>
|
||||
</div>
|
||||
} } @if (!isLoading && videos.length === 0) {
|
||||
<!-- If the database is empty -->
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Component, OnInit, OnDestroy, ElementRef, Input } from '@angular/core';
|
||||
import { ResolutionService } from '../../../services/resolution.service';
|
||||
import Hls from 'hls.js';
|
||||
import { VideoProgressService } from '../../../services/video-progress.service';
|
||||
import { Video } from '../../../interfaces/video.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
|
|
@ -11,6 +13,7 @@ import Hls from 'hls.js';
|
|||
})
|
||||
export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
@Input() playVideo: string = '';
|
||||
@Input() currentVideo: Video | null = null;
|
||||
|
||||
private hls: Hls | null = null;
|
||||
private videoElement: HTMLVideoElement | null = null;
|
||||
|
|
@ -18,51 +21,86 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||
private defaultResolution: string;
|
||||
|
||||
/**
|
||||
* Initializes the VideoPlayerComponent with the ElementRef and ResolutionService.
|
||||
* Initializes the VideoPlayerComponent with the ElementRef, ResolutionService and VideoProgressService.
|
||||
* Get the default resolution.
|
||||
*/
|
||||
constructor(
|
||||
private elementRef: ElementRef,
|
||||
private resolutionService: ResolutionService
|
||||
private resolutionService: ResolutionService,
|
||||
private videoProgressService: VideoProgressService
|
||||
) {
|
||||
this.defaultResolution = this.resolutionService.getDefaultResolution();
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular lifecycle hook: Called once the component is initialized.
|
||||
* Initializes the video player with the default resolution.
|
||||
* Lifecycle hook: Called once the component is initialized.
|
||||
* Sets up the player and starts tracking video progress.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.initializePlayer();
|
||||
this.setupPlayer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular lifecycle hook: Called once the component is about to be destroyed.
|
||||
* Cleans up HLS resources.
|
||||
* Cleans up HLS resources and stops tracking video progress.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.hls?.destroy();
|
||||
this.videoProgressService.stopTracking();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the video player and loads the default resolution.
|
||||
* Sets up the video player by initializing the video element, retrieving resolution URLs,
|
||||
* and obtaining the initial playback position for the current video. It also starts tracking
|
||||
* the video's playback progress and updates the video's screen dimensions.
|
||||
*/
|
||||
private initializePlayer(): void {
|
||||
private setupPlayer(): void {
|
||||
this.videoElement = this.elementRef.nativeElement.querySelector('video');
|
||||
if (!this.playVideo || !this.videoElement) return;
|
||||
|
||||
this.resolutionUrls = this.getResolutionUrls();
|
||||
const defaultUrl = this.resolutionUrls[this.defaultResolution];
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
this.initHlsPlayer(defaultUrl);
|
||||
} else if (this.videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
this.initNativePlayer(defaultUrl);
|
||||
}
|
||||
this.videoProgressService
|
||||
.getInitialProgress(this.currentVideo!.id)
|
||||
.then((startTime) => {
|
||||
this.initPlayer(defaultUrl, startTime);
|
||||
this.videoProgressService.startTracking(
|
||||
this.videoElement!,
|
||||
this.currentVideo!.id
|
||||
);
|
||||
});
|
||||
|
||||
this.updateScreenDimensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the video player using HLS.js if supported, or falls back to native HTML5 support.
|
||||
* @param url - The URL of the video stream to load.
|
||||
* @param startTime - The initial playback position of the video in seconds.
|
||||
*/
|
||||
private initPlayer(url: string, startTime: number): void {
|
||||
if (!this.videoElement) return;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
this.hls = new Hls();
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.videoElement);
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (this.videoElement) {
|
||||
this.videoElement.currentTime = startTime;
|
||||
this.videoElement.play();
|
||||
}
|
||||
});
|
||||
} else if (this.videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
this.videoElement.src = url;
|
||||
this.videoElement.addEventListener('canplay', () => {
|
||||
this.videoElement!.currentTime = startTime;
|
||||
this.videoElement!.play();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a mapping of available resolutions to their respective URLs.
|
||||
* @returns An object containing resolution-URL pairs.
|
||||
|
|
|
|||
90
frontend/src/app/services/video-progress.service.ts
Normal file
90
frontend/src/app/services/video-progress.service.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { firstValueFrom, interval, Observable, Subscription } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VideoProgressService {
|
||||
private subscription: Subscription | null = null;
|
||||
|
||||
/**
|
||||
* Initializes the VideoProgressService with ApiService.
|
||||
*/
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
/**
|
||||
* Starts tracking the playback position of a video at regular intervals.
|
||||
* Progress is only saved if the video is currently playing.
|
||||
*
|
||||
* @param videoElement - The HTMLVideoElement to track.
|
||||
* @param videoId - The unique ID of the video.
|
||||
* @param intervalSec - The interval in seconds at which progress is saved (default is 5 seconds).
|
||||
*/
|
||||
startTracking(
|
||||
videoElement: HTMLVideoElement,
|
||||
videoId: number,
|
||||
intervalSec = 5
|
||||
): void {
|
||||
this.stopTracking();
|
||||
this.subscription = interval(intervalSec * 1000).subscribe(() => {
|
||||
const position = videoElement.currentTime;
|
||||
|
||||
if (!videoElement.paused && !videoElement.ended && !isNaN(position)) {
|
||||
this.saveProgress(videoId, position).subscribe({
|
||||
error: (err) => console.error('Progress save failed:', err),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops tracking video progress and clears the subscription.
|
||||
*/
|
||||
stopTracking(): void {
|
||||
this.subscription?.unsubscribe();
|
||||
this.subscription = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the last saved playback position for a given video.
|
||||
*
|
||||
* @param videoId - The unique ID of the video.
|
||||
* @returns A promise resolving to the last saved position in seconds, or 0 if unavailable.
|
||||
*/
|
||||
async getInitialProgress(videoId: number): Promise<number> {
|
||||
try {
|
||||
const res = await firstValueFrom(this.getProgress(videoId));
|
||||
return res?.position ?? 0;
|
||||
} catch (err) {
|
||||
console.warn(`No saved progress for video ${videoId}`, err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the saved progress for a specific video from the backend.
|
||||
*
|
||||
* @param videoId - The unique ID of the video.
|
||||
* @returns An observable containing the progress object with `position` in seconds.
|
||||
*/
|
||||
getProgress(videoId: number): Observable<{ position: number }> {
|
||||
return this.apiService.get<{ position: number }>(
|
||||
`/video/progress/${videoId}`,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the current playback position of a video to the backend for saving.
|
||||
*
|
||||
* @param videoId - The unique ID of the video.
|
||||
* @param position - The current playback position in seconds.
|
||||
* @returns An observable representing the HTTP POST request.
|
||||
*/
|
||||
saveProgress(videoId: number, position: number): Observable<any> {
|
||||
return this.apiService.post(
|
||||
`/video/progress/${videoId}`,
|
||||
{ position },
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue