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 django.contrib import admin
|
||||||
from .models import Video
|
from .models import Video, VideoProgress
|
||||||
from import_export import resources
|
from import_export import resources
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|
||||||
|
|
@ -7,8 +7,18 @@ class VideoResource(resources.ModelResource):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Video
|
model = Video
|
||||||
|
|
||||||
|
class VideoProgressInline(admin.TabularInline):
|
||||||
|
model = VideoProgress
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('user', 'position', 'updated_at')
|
||||||
|
can_delete = False
|
||||||
|
|
||||||
@admin.register(Video)
|
@admin.register(Video)
|
||||||
class VideoAdmin(ImportExportModelAdmin):
|
class VideoAdmin(ImportExportModelAdmin):
|
||||||
resource_class = VideoResource
|
resource_class = VideoResource
|
||||||
readonly_fields = ('file_path',)
|
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]
|
||||||
|
|
@ -14,4 +14,26 @@ class Video(models.Model):
|
||||||
is_available = models.BooleanField(default=False)
|
is_available = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'({self.id}) {self.title}'
|
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 rest_framework import serializers
|
||||||
from video_app.class_assets import VIDEO_GENRES
|
from video_app.class_assets import VIDEO_GENRES
|
||||||
from .models import Video
|
from .models import Video, VideoProgress
|
||||||
|
|
||||||
class VideoSerializer(serializers.ModelSerializer):
|
class VideoSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Video
|
model = Video
|
||||||
fields = "__all__"
|
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 VideoUploadSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Video
|
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.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .serializer import VideoSerializer, VideoUploadSerializer
|
from .serializer import VideoProgressSerializer, VideoSerializer, VideoUploadSerializer
|
||||||
from .models import Video
|
from .models import Video, VideoProgress
|
||||||
from .services import save_video_file, create_video_record
|
from .services import save_video_file, create_video_record
|
||||||
from .class_assets import VIDEO_GENRES
|
from .class_assets import VIDEO_GENRES
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -70,4 +71,34 @@ def video_upload(request):
|
||||||
video = create_video_record(serializer.validated_data, request.user, short_name, file_path)
|
video = create_video_record(serializer.validated_data, request.user, short_name, file_path)
|
||||||
return Response(VideoSerializer(video).data, status=201)
|
return Response(VideoSerializer(video).data, status=201)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({'error': f'Error while saving video: {str(e)}'}, status=500)
|
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/upload/', video_views.video_upload, name='video_upload'),
|
||||||
path('video/genres/', video_views.genre_list, name='genre_list'),
|
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/<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
|
# Users URLs
|
||||||
path('users/', user_views.user_list, name='user_list'),
|
path('users/', user_views.user_list, name='user_list'),
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,10 @@
|
||||||
<img src="./../../../../assets/img/logo_ci.svg" alt="Logo" />
|
<img src="./../../../../assets/img/logo_ci.svg" alt="Logo" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-video-player [playVideo]="playVideo"></app-video-player>
|
<app-video-player
|
||||||
|
[playVideo]="playVideo"
|
||||||
|
[currentVideo]="currentVideo"
|
||||||
|
></app-video-player>
|
||||||
</div>
|
</div>
|
||||||
} } @if (!isLoading && videos.length === 0) {
|
} } @if (!isLoading && videos.length === 0) {
|
||||||
<!-- If the database is empty -->
|
<!-- If the database is empty -->
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Component, OnInit, OnDestroy, ElementRef, Input } from '@angular/core';
|
import { Component, OnInit, OnDestroy, ElementRef, Input } from '@angular/core';
|
||||||
import { ResolutionService } from '../../../services/resolution.service';
|
import { ResolutionService } from '../../../services/resolution.service';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
|
import { VideoProgressService } from '../../../services/video-progress.service';
|
||||||
|
import { Video } from '../../../interfaces/video.interface';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-video-player',
|
selector: 'app-video-player',
|
||||||
|
|
@ -11,6 +13,7 @@ import Hls from 'hls.js';
|
||||||
})
|
})
|
||||||
export class VideoPlayerComponent implements OnInit, OnDestroy {
|
export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||||
@Input() playVideo: string = '';
|
@Input() playVideo: string = '';
|
||||||
|
@Input() currentVideo: Video | null = null;
|
||||||
|
|
||||||
private hls: Hls | null = null;
|
private hls: Hls | null = null;
|
||||||
private videoElement: HTMLVideoElement | null = null;
|
private videoElement: HTMLVideoElement | null = null;
|
||||||
|
|
@ -18,51 +21,86 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||||
private defaultResolution: string;
|
private defaultResolution: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the VideoPlayerComponent with the ElementRef and ResolutionService.
|
* Initializes the VideoPlayerComponent with the ElementRef, ResolutionService and VideoProgressService.
|
||||||
* Get the default resolution.
|
* Get the default resolution.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private elementRef: ElementRef,
|
private elementRef: ElementRef,
|
||||||
private resolutionService: ResolutionService
|
private resolutionService: ResolutionService,
|
||||||
|
private videoProgressService: VideoProgressService
|
||||||
) {
|
) {
|
||||||
this.defaultResolution = this.resolutionService.getDefaultResolution();
|
this.defaultResolution = this.resolutionService.getDefaultResolution();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Angular lifecycle hook: Called once the component is initialized.
|
* Lifecycle hook: Called once the component is initialized.
|
||||||
* Initializes the video player with the default resolution.
|
* Sets up the player and starts tracking video progress.
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.initializePlayer();
|
this.setupPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Angular lifecycle hook: Called once the component is about to be destroyed.
|
* 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 {
|
ngOnDestroy(): void {
|
||||||
this.hls?.destroy();
|
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');
|
this.videoElement = this.elementRef.nativeElement.querySelector('video');
|
||||||
if (!this.playVideo || !this.videoElement) return;
|
if (!this.playVideo || !this.videoElement) return;
|
||||||
|
|
||||||
this.resolutionUrls = this.getResolutionUrls();
|
this.resolutionUrls = this.getResolutionUrls();
|
||||||
const defaultUrl = this.resolutionUrls[this.defaultResolution];
|
const defaultUrl = this.resolutionUrls[this.defaultResolution];
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
this.videoProgressService
|
||||||
this.initHlsPlayer(defaultUrl);
|
.getInitialProgress(this.currentVideo!.id)
|
||||||
} else if (this.videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
.then((startTime) => {
|
||||||
this.initNativePlayer(defaultUrl);
|
this.initPlayer(defaultUrl, startTime);
|
||||||
}
|
this.videoProgressService.startTracking(
|
||||||
|
this.videoElement!,
|
||||||
|
this.currentVideo!.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
this.updateScreenDimensions();
|
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.
|
* Generates a mapping of available resolutions to their respective URLs.
|
||||||
* @returns An object containing resolution-URL pairs.
|
* @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