Skip to content

Commit

Permalink
feat: lecture evaluation
Browse files Browse the repository at this point in the history
  • Loading branch information
keenthekeen committed Feb 13, 2025
1 parent 54b3c47 commit 67b1dbb
Show file tree
Hide file tree
Showing 8 changed files with 1,077 additions and 816 deletions.
48 changes: 25 additions & 23 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@
},
"private": true,
"dependencies": {
"@angular/common": "^19.1.4",
"@angular/core": "^19.1.4",
"@angular/common": "^19.1.6",
"@angular/core": "^19.1.6",
"@angular/fire": "^19.0.0",
"@angular/forms": "^19.1.4",
"@angular/platform-browser": "^19.1.4",
"@angular/platform-browser-dynamic": "^19.1.4",
"@angular/router": "^19.1.4",
"@angular/service-worker": "^19.1.4",
"@angular/forms": "^19.1.6",
"@angular/platform-browser": "^19.1.6",
"@angular/platform-browser-dynamic": "^19.1.6",
"@angular/router": "^19.1.6",
"@angular/service-worker": "^19.1.6",
"@ionic/angular": "^8.4.3",
"@sentry/angular": "^8.53.0",
"@primeng/themes": "^19.0.6",
"@sentry/angular": "^8.55.0",
"@sentry/cli": "^2.41.1",
"core-js": "^3.40.0",
"ionicons": "^7.4.0",
"primeng": "^19.0.6",
"rxfire": "^6.1.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.1",
Expand All @@ -38,25 +40,25 @@
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/architect": "^0.1901.5",
"@angular-devkit/build-angular": "^19.1.5",
"@angular-eslint/builder": "^19.0.2",
"@angular-eslint/eslint-plugin": "^19.0.2",
"@angular-eslint/eslint-plugin-template": "^19.0.2",
"@angular-eslint/schematics": "^19.0.2",
"@angular-eslint/template-parser": "^19.0.2",
"@angular/cli": "^19.1.5",
"@angular/compiler": "^19.1.4",
"@angular/compiler-cli": "^19.1.4",
"@angular/language-service": "^19.1.4",
"@angular-devkit/architect": "^0.1901.7",
"@angular-devkit/build-angular": "^19.1.7",
"@angular-eslint/builder": "^19.1.0",
"@angular-eslint/eslint-plugin": "^19.1.0",
"@angular-eslint/eslint-plugin-template": "^19.1.0",
"@angular-eslint/schematics": "^19.1.0",
"@angular-eslint/template-parser": "^19.1.0",
"@angular/cli": "^19.1.7",
"@angular/compiler": "^19.1.6",
"@angular/compiler-cli": "^19.1.6",
"@angular/language-service": "^19.1.6",
"@ionic/angular-toolkit": "^12.1.1",
"@types/jasmine": "~5.1.5",
"@types/jasminewd2": "^2.0.13",
"@types/node": "^20.17.16",
"@types/node": "^20.17.18",
"@types/video.js": "^7.3.58",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"eslint": "^9.19.0",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"eslint": "^9.20.1",
"jasmine-core": "~5.5.0",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
Expand Down
1,680 changes: 912 additions & 768 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { getAnalytics, provideAnalytics, ScreenTrackingService, UserTrackingServ
import { getPerformance, providePerformance } from '@angular/fire/performance';
import { getRemoteConfig, provideRemoteConfig } from '@angular/fire/remote-config';
import { getFirestore, provideFirestore } from '@angular/fire/firestore';
import {providePrimeNG} from 'primeng/config';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import Aura from '@primeng/themes/aura';

@NgModule({ declarations: [AppComponent],
bootstrap: [AppComponent],
Expand Down Expand Up @@ -59,6 +62,12 @@ import { getFirestore, provideFirestore } from '@angular/fire/firestore';
provideAppInitializer(() => {
inject(Sentry.TraceService);
}),
provideAnimationsAsync(),
providePrimeNG({
theme: {
preset: Aura
}
})
],
})
export class AppModule {
Expand Down
47 changes: 32 additions & 15 deletions src/app/home/course/course.page.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {combineLatest, EMPTY, fromEvent, mergeAll, Observable, of, pairwise, startWith, Subject, takeUntil, throttleTime} from 'rxjs';
import {ActivatedRoute, Router} from '@angular/router';
import {CourseMembers, Lecture, ManService} from '../../man.service';
import {CourseMembers, EvaluationRecord, Lecture, ManService} from '../../man.service';
import {first, map, switchMap} from 'rxjs/operators';
import videojs from 'video.js';
import 'videojs-hotkeys';
Expand All @@ -28,6 +28,7 @@ import {
IonText,
IonTitle,
IonToolbar,
ModalController,
} from '@ionic/angular/standalone';
import {DomSanitizer} from '@angular/platform-browser';
import {PlayHistory} from '../../play-tracker.service';
Expand All @@ -36,6 +37,7 @@ import {checkmarkOutline, closeOutline, documentAttachOutline, download, pauseCi
import type Player from 'video.js/dist/types/player';
import {ulid} from 'ulid';
import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common';
import {ModalEvaluationComponent} from './modal-evaluation.component';

@Component({
selector: 'app-course',
Expand Down Expand Up @@ -91,7 +93,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {

constructor(private route: ActivatedRoute, private router: Router,
private manService: ManService, private alertController: AlertController,
private sanitizer: DomSanitizer) {
private sanitizer: DomSanitizer, private modalCtrl: ModalController) {
addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline });
}

Expand All @@ -112,7 +114,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
}
this.year = courseData.category;
this.course = courseData.name;
return this.mergeVideoInfo(courseData.lectures, history);
return this.mergeVideoInfo(courseData.lectures, history?.records ?? {}, history?.evaluations ?? {});
}));
} else if (this.year) {
this.router.navigate(['home/' + this.year]);
Expand Down Expand Up @@ -141,7 +143,12 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
enableModifiersForNumbers: false,
enableVolumeScroll: false,
});
this.videoPlayer.on('ended', () => this.updatePlayRecord());
this.videoPlayer.on('ended', () => {
this.updatePlayRecord();
if (!this.currentVideo.is_evaluated) {
this.openEvaluationModal();
}
});
this.videoPlayer.on('loadedmetadata', () => {
// On video load, seek to last played position
if (this.currentVideo.history.end_time
Expand Down Expand Up @@ -217,10 +224,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
this.stopPolling$.next(true);
}

mergeVideoInfo(videos: CourseMembers, history: PlayHistory|null) {
if (!history) {
history = {};
}
mergeVideoInfo(videos: CourseMembers, history: PlayHistory, evaluations: { [key: number]: EvaluationRecord }) {
const progress = {
viewed: 0,
duration: 0
Expand All @@ -230,17 +234,22 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
// @ts-ignore
return ('' + history[b].updated_at).localeCompare(history[a].updated_at);
}).slice(0, 1)[0] ?? null;
Object.keys(videos).forEach(lectureKey => {
videos[lectureKey].history = history[videos[lectureKey].id] ?? { end_time: null, updated_at: null };
if (videos[lectureKey].duration) {
progress.duration -= -videos[lectureKey].duration;
if (videos[lectureKey].history.end_time) {
progress.viewed -= -videos[lectureKey].history.end_time;
const videoInfo = Object.values(videos).map(lecture => {
lecture.history = history[lecture.id] ?? ((lecture.id in evaluations && lecture.duration) ? { // If evaluation exists, treat as watched
end_time: lecture.duration,
updated_at: null,
} : {end_time: null, updated_at: null});
if (lecture.duration) {
progress.duration -= -lecture.duration;
if (lecture.history.end_time) {
progress.viewed -= -lecture.history.end_time;
}
}
lecture.is_evaluated = evaluations[lecture.id]?.type === 'end_play';
return lecture;
});
this.courseProgress = progress;
return Object.values(videos);
return videoInfo;
}

viewVideo(video: Lecture) {
Expand Down Expand Up @@ -298,6 +307,14 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
await alert.present();
}

async openEvaluationModal() {
const modal = await this.modalCtrl.create({
component: ModalEvaluationComponent,
componentProps: {video: this.currentVideo},
});
await modal.present();
}

preventMouseEvent($event: MouseEvent) {
// Prevent right-click only if video is downloadable
if (this.currentVideo?.sources?.filter(s => s.path?.endsWith('.mp4') || s.src?.endsWith('.mp4') || s.path?.endsWith('.webm')).length > 0) {
Expand Down
34 changes: 34 additions & 0 deletions src/app/home/course/modal-evaluation.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<ion-header>
<ion-toolbar>
<ion-title>Evaluation</ion-title>
<ion-buttons slot="end">
<ion-button (click)="confirm()" [strong]="true">Continue</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
@if (video) {
<div style="border-left: 4px solid #1976d2; padding-left: 1rem;">
<span style="font-weight: bold">Lecture:</span> {{ video.title }}<br/>
<span style="font-weight: bold">Instructor:</span> {{ video.lecturer }}
</div>
}
<p>
Please rate the following aspects of this lecture (optional), then click Continue.
</p>
<ion-item>
<ion-label>Instructor's Delivery</ion-label>
<p-rating [(ngModel)]="result.delivery"/>
</ion-item>
<ion-item>
<ion-label>Usefulness of Material</ion-label>
<p-rating [(ngModel)]="result.material"/>
</ion-item>
<ion-item>
<ion-label>Audio/Video Quality</ion-label>
<p-rating [(ngModel)]="result.video"/>
</ion-item>
<p style="margin-top: 2rem">
Thank you for taking the time to complete the survey.
</p>
</ion-content>
38 changes: 38 additions & 0 deletions src/app/home/course/modal-evaluation.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {Component, Input} from '@angular/core';
import {FormsModule} from '@angular/forms';

import {IonButton, IonButtons, IonContent, IonHeader, IonItem, IonLabel, IonTitle, IonToolbar, ModalController} from '@ionic/angular/standalone';
import {Lecture, ManService} from '../../man.service';
import {Rating} from 'primeng/rating';

@Component({
selector: 'app-modal-evaluation',
templateUrl: 'modal-evaluation.component.html',
imports: [FormsModule, IonButton, IonButtons, IonContent, IonHeader, IonItem, IonTitle, IonToolbar, Rating, IonLabel],
})
export class ModalEvaluationComponent {
@Input() video: Lecture;
result: {
delivery: number | null;
material: number | null;
video: number | null;
} = {
delivery: null,
material: null,
video: null,
};

constructor(private manService: ManService, private modalCtrl: ModalController) {
}

cancel() {
return this.modalCtrl.dismiss(null, 'cancel');
}

confirm() {
if (this.video && (this.result.delivery || this.result.material || this.result.video)) {
this.manService.sendEvaluation('end_play', this.video.id, this.result).subscribe();
}
return this.modalCtrl.dismiss(this.result, 'confirm');
}
}
32 changes: 24 additions & 8 deletions src/app/man.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,26 +96,31 @@ export class ManService {
}));
}

getPlayRecord(year: string, course: string, courseId: string|null, stopPolling: Observable<boolean>): Observable<PlayHistory> {
getPlayRecord(year: string, course: string, courseId: string | null, stopPolling: Observable<boolean>): Observable<{
records: PlayHistory,
evaluations: { [key: number]: EvaluationRecord },
}> {
const params = courseId ? new HttpParams().set("course_id", courseId ?? '') : new HttpParams().set("year", year).set("course", course);
this.playTracker.retrieve().subscribe(console.log);
return timer(1, 60000).pipe(
switchMap(() => this.get<JSend<{
records: PlayHistory
}>>('v1/play_records', {params}).pipe(map(response => response?.data?.records))),
records: PlayHistory,
evaluations: { [key: number]: EvaluationRecord },
}>>('v1/play_records', {params}).pipe(map(response => response?.data))),
// Replace value with update from play tracker if available
combineLatestWith(this.playTracker.retrieve().pipe(startWith(null))),
map(([records, update]) => {
if (!records) {
records = {};
}
map(([data, update]) => {
const records = data?.records ?? {};
if (update) {
if (!records[update.video_id] ||
(records[update.video_id].updated_at < update.updated_at)) {
records[update.video_id] = update;
}
}
return records;
return {
records,
evaluations: data?.evaluations ?? {},
};
}),
takeUntil(stopPolling),
);
Expand All @@ -131,6 +136,10 @@ export class ManService {
});
}

sendEvaluation(type: string, video: string | number, result: { delivery: number | null, material: number | null, video: number | null }) {
return this.post<JSend<null>>('v1/evaluations', {type, video, result});
}

checkAuthorization(): Observable<boolean> {
return this.get<object>('v1/auth_check').pipe(timeout(8000), map(a => a.hasOwnProperty('success')));
}
Expand Down Expand Up @@ -206,6 +215,12 @@ export interface CourseListResponse {
last_played: { video: Lecture, updated_at: string, end_time: number } | null;
}

export interface EvaluationRecord {
id: number;
type: string;
video_id: number;
}

export interface Lecture {
title: string;
lecturer: string;
Expand All @@ -228,6 +243,7 @@ export interface Lecture {
duration?: number;
durationInMin?: number;
history?: PlayHistoryValue;
is_evaluated?: boolean;
course?: {
id: number;
name: string;
Expand Down
5 changes: 3 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ if (environment.production) {
// Benign Firebase Auth errors
'auth/cancelled-popup-request', 'auth/popup-blocked', 'auth/popup-closed-by-user',
'auth/network-request-failed', 'auth/internal-error', 'Pending promise was never set',
'auth/user-cancelled',
'auth/user-cancelled', 'auth/missing-or-invalid-nonce',
// HTTP errors
'Http failure response', ' 401', ' 404', ' 504', 'Unknown Error',
'Connection to Indexed Database',
// Video player errors
'Picture-in-Picture', 'requestFullscreen', 'triggered by a user activation', 'Illegal invocation',
'not allowed by the user agent', 'FullScreen', 'InvalidStateError', 'video track', 'exitFullscreen',
'TextTrackCue', 'ResizeObserver',
'TextTrackCue', 'ResizeObserver', 'Maximum call stack size exceeded.',
],
});
}
Expand Down

0 comments on commit 67b1dbb

Please sign in to comment.