Skip to content

Commit 3871e41

Browse files
committed
feat: move play history to PHP/HTTP backend
BREAKING CHANGE: require new backend version
1 parent 1d4428d commit 3871e41

13 files changed

+1475
-1357
lines changed

package.json

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,45 +13,46 @@
1313
},
1414
"private": true,
1515
"dependencies": {
16-
"@angular/common": "^18.2.9",
17-
"@angular/core": "^18.2.9",
16+
"@angular/common": "^18.2.13",
17+
"@angular/core": "^18.2.13",
1818
"@angular/fire": "^18.0.1",
19-
"@angular/forms": "^18.2.9",
20-
"@angular/platform-browser": "^18.2.9",
21-
"@angular/platform-browser-dynamic": "^18.2.9",
22-
"@angular/router": "^18.2.9",
23-
"@angular/service-worker": "^18.2.9",
24-
"@ionic/angular": "^8.3.3",
25-
"core-js": "^3.38.1",
19+
"@angular/forms": "^18.2.13",
20+
"@angular/platform-browser": "^18.2.13",
21+
"@angular/platform-browser-dynamic": "^18.2.13",
22+
"@angular/router": "^18.2.13",
23+
"@angular/service-worker": "^18.2.13",
24+
"@ionic/angular": "^8.4.1",
25+
"core-js": "^3.39.0",
2626
"ionicons": "^7.4.0",
27-
"rxfire": "^6.0.5",
27+
"rxfire": "^6.1.0",
2828
"rxjs": "^7.8.1",
29-
"tslib": "^2.8.0",
30-
"video.js": "^8.19.1",
29+
"tslib": "^2.8.1",
30+
"ulid": "^2.3.0",
31+
"video.js": "^8.21.0",
3132
"videojs-hotkeys": "^0.2.30",
3233
"videojs-youtube": "^3.0.1",
3334
"zone.js": "^0.14.10"
3435
},
3536
"devDependencies": {
36-
"@angular-devkit/architect": "^0.1802.10",
37-
"@angular-devkit/build-angular": "^18.2.10",
38-
"@angular-eslint/builder": "^18.4.0",
39-
"@angular-eslint/eslint-plugin": "^18.4.0",
40-
"@angular-eslint/eslint-plugin-template": "^18.4.0",
41-
"@angular-eslint/schematics": "^18.4.0",
42-
"@angular-eslint/template-parser": "^18.4.0",
43-
"@angular/cli": "^18.2.10",
44-
"@angular/compiler": "^18.2.9",
45-
"@angular/compiler-cli": "^18.2.9",
46-
"@angular/language-service": "^18.2.9",
37+
"@angular-devkit/architect": "^0.1802.12",
38+
"@angular-devkit/build-angular": "^18.2.12",
39+
"@angular-eslint/builder": "^18.4.3",
40+
"@angular-eslint/eslint-plugin": "^18.4.3",
41+
"@angular-eslint/eslint-plugin-template": "^18.4.3",
42+
"@angular-eslint/schematics": "^18.4.3",
43+
"@angular-eslint/template-parser": "^18.4.3",
44+
"@angular/cli": "^18.2.12",
45+
"@angular/compiler": "^18.2.13",
46+
"@angular/compiler-cli": "^18.2.13",
47+
"@angular/language-service": "^18.2.13",
4748
"@ionic/angular-toolkit": "^11.0.1",
48-
"@types/jasmine": "~5.1.4",
49+
"@types/jasmine": "~5.1.5",
4950
"@types/jasminewd2": "^2.0.13",
50-
"@types/node": "^20.17.1",
51+
"@types/node": "^20.17.10",
5152
"@types/video.js": "^7.3.58",
52-
"@typescript-eslint/eslint-plugin": "^8.11.0",
53-
"@typescript-eslint/parser": "^8.11.0",
54-
"eslint": "^9.13.0",
53+
"@typescript-eslint/eslint-plugin": "^8.19.0",
54+
"@typescript-eslint/parser": "^8.19.0",
55+
"eslint": "^9.17.0",
5556
"fuzzy": "^0.1.3",
5657
"inquirer": "^9.3.7",
5758
"inquirer-autocomplete-prompt": "^3.0.1",
@@ -62,6 +63,8 @@
6263
"karma-coverage-istanbul-reporter": "^3.0.3",
6364
"karma-jasmine": "^5.1.0",
6465
"karma-jasmine-html-reporter": "^2.1.0",
66+
"laravel-echo": "^1.17.1",
67+
"pusher-js": "8.4.0-rc2",
6568
"ts-node": "^10.9.2",
6669
"typescript": "~5.5.4"
6770
},

pnpm-lock.yaml

Lines changed: 1252 additions & 1167 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/home/course/course.page.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
<ng-container *ngIf="currentVideo">
2828
<ion-text color="medium">
2929
<p>
30-
{{ currentVideo.title }} - {{ currentVideo.lecturer }}<br/>
30+
{{ currentVideo.title }}<span v-if="currentVideo.lecturer"> - {{ currentVideo.lecturer }}</span><br/>
3131
<ion-button (click)="setPlaybackSpeed()" (keyup)="setPlaybackSpeed()" fill="outline" size="small" tabindex="0" class="set-playback-speed-button">Set playback speed</ion-button>
32-
<span>
32+
<span *ngIf="currentVideo.sourceExternal?.includes('1.mp4')">
3333
| <a [href]="sanitize('//player.docchula.com/?url='+currentVideo.sourceExternal)" rel="noreferrer" target="_blank">
3434
Play with Docchula Player</a>
3535
</span>

src/app/home/course/course.page.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { AngularFireAuth } from '@angular/fire/compat/auth';
44
import { ActivatedRouteStub, FireAnalyticsStub, FireAuthStub } from '../../stubs';
55
import { ManService, ManServiceStub } from '../../man.service';
66
import { RouterTestingModule } from '@angular/router/testing';
7-
import { PlayTrackerService, PlayTrackerServiceStub } from '../../play-tracker.service';
87
import { AngularFireAnalytics } from '@angular/fire/compat/analytics';
98
import { ActivatedRoute } from '@angular/router';
109
import { IonicModule } from '@ionic/angular/ionic-module';
@@ -22,7 +21,6 @@ describe('CoursePage', () => {
2221
{ provide: AngularFireAnalytics, useValue: FireAnalyticsStub },
2322
{ provide: AngularFireAuth, useValue: FireAuthStub },
2423
{ provide: ManService, useValue: ManServiceStub },
25-
{ provide: PlayTrackerService, useValue: PlayTrackerServiceStub }
2624
]
2725
}).compileComponents();
2826

src/app/home/course/course.page.ts

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
2-
import {combineLatest, EMPTY, Observable} from 'rxjs';
1+
import {AfterViewInit, Component, ElementRef, OnInit, ViewChild, OnDestroy} from '@angular/core';
2+
import {combineLatest, EMPTY, Observable, share, Subject, takeUntil, timer} from 'rxjs';
33
import {ActivatedRoute, Router} from '@angular/router';
44
import {CourseMembers, Lecture, ManService} from '../../man.service';
55
import {map, switchMap} from 'rxjs/operators';
@@ -8,18 +8,19 @@ import 'videojs-hotkeys';
88
import 'videojs-youtube';
99
import {AlertController} from '@ionic/angular/standalone';
1010
import {DomSanitizer} from '@angular/platform-browser';
11-
import {PlayHistory, PlayTrackerService} from '../../play-tracker.service';
11+
import {PlayHistory} from '../../play-tracker.service';
1212
import {Analytics, logEvent} from '@angular/fire/analytics';
1313
import {addIcons} from "ionicons";
1414
import {checkmarkOutline, closeOutline, documentAttachOutline, download, pauseCircleOutline} from "ionicons/icons";
1515
import type Player from 'video.js/dist/types/player';
16+
import {ulid} from 'ulid';
1617

1718
@Component({
1819
selector: 'app-course',
1920
templateUrl: './course.page.html',
2021
styleUrls: ['./course.page.scss'],
2122
})
22-
export class CoursePage implements OnInit, AfterViewInit {
23+
export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
2324
@ViewChild('videoPlayer') videoPlayerElement: ElementRef;
2425
videoPlayer: Player;
2526
currentVideo: Lecture;
@@ -33,11 +34,12 @@ export class CoursePage implements OnInit, AfterViewInit {
3334
isAndroid = /Android/i.test(navigator.userAgent);
3435
isIos = /iPad/i.test(navigator.userAgent) || /iPhone/i.test(navigator.userAgent);
3536
lastPlayedVideoKey: string|null = null;
37+
sessionUid: string; // Unique ID for session x video (new id for each video)
38+
stopPolling = new Subject();
3639

3740
constructor(private route: ActivatedRoute, private router: Router,
3841
private manService: ManService, private alertController: AlertController,
39-
private analytics: Analytics, private sanitizer: DomSanitizer,
40-
private playTracker: PlayTrackerService) {
42+
private analytics: Analytics, private sanitizer: DomSanitizer) {
4143
addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline });
4244
}
4345

@@ -48,8 +50,13 @@ export class CoursePage implements OnInit, AfterViewInit {
4850
this.course = s.get('course');
4951
if (this.year && this.course) {
5052
return combineLatest([
51-
this.manService.getVideosInCourse(this.year, this.course), this.playTracker.retrieve()])
52-
.pipe(map(([videos, history]) => this.mergeVideoInfo(videos, history)));
53+
this.manService.getVideosInCourse(this.year, this.course),
54+
timer(1, 30000).pipe(
55+
switchMap(() => this.manService.getPlayRecord(this.year, this.course)),
56+
share(),
57+
takeUntil(this.stopPolling),
58+
),
59+
]).pipe(map(([videos, history]) => this.mergeVideoInfo(videos, history)));
5360
} else if (this.year) {
5461
this.router.navigate(['home/' + this.year]);
5562
} else {
@@ -80,24 +87,23 @@ export class CoursePage implements OnInit, AfterViewInit {
8087
this.videoPlayer.on('pause', () => {
8188
if (!this.videoPlayer.seeking()) {
8289
// is paused, not seeking
83-
this.playTracker.updateCurrentTime(this.currentVideo.identifier, this.videoPlayer.currentTime(), this.year, this.course, this.videoPlayer.duration());
90+
this.updatePlayRecord();
8491
}
8592
});
86-
this.videoPlayer.on('ended', () =>
87-
this.playTracker.updateCurrentTime(this.currentVideo.identifier, this.videoPlayer.currentTime(), this.year, this.course, this.videoPlayer.duration()));
93+
this.videoPlayer.on('ended', () => this.updatePlayRecord());
8894
let lastUpdated = 0;
8995
this.videoPlayer.on('timeupdate', () => {
90-
// Update while playing every 10 minutes
91-
if (Date.now() - lastUpdated > 600000) {
96+
// Update while playing every 2 minutes
97+
if (Date.now() - lastUpdated > 120000) {
9298
lastUpdated = Date.now();
93-
this.playTracker.updateCurrentTime(this.currentVideo.identifier, this.videoPlayer.currentTime(), this.year, this.course, this.videoPlayer.duration());
99+
this.updatePlayRecord();
94100
}
95101
});
96102
this.videoPlayer.on('tracking:performance', (_e, data) => {
97103
console.log('performance');
98104
if (this.videoPlayer.currentTime() > 30) {
99105
logEvent(this.analytics, 'video_performance', this.attachEventLabel(data, true));
100-
this.playTracker.updateCurrentTime(this.currentVideo.identifier, data.currentTime, this.year, this.course);
106+
this.updatePlayRecord();
101107
}
102108
});
103109
this.videoPlayer.on('loadedmetadata', () => {
@@ -109,6 +115,10 @@ export class CoursePage implements OnInit, AfterViewInit {
109115
});
110116
}
111117

118+
ngOnDestroy() {
119+
this.stopPolling.next(true);
120+
}
121+
112122
mergeVideoInfo(videos: CourseMembers, history: PlayHistory) {
113123
const progress = {
114124
viewed: 0,
@@ -119,11 +129,7 @@ export class CoursePage implements OnInit, AfterViewInit {
119129
return history[b].updatedAt - history[a].updatedAt;
120130
}).slice(0, 1)[0] ?? null;
121131
Object.keys(videos).forEach(lectureKey => {
122-
videos[lectureKey].history = history[videos[lectureKey].identifier] ?? { currentTime: null, updatedAt: null, duration: null };
123-
if (!videos[lectureKey].duration && videos[lectureKey].history.duration) {
124-
videos[lectureKey].duration = videos[lectureKey].history.duration;
125-
videos[lectureKey].durationInMin = videos[lectureKey].duration ? Math.round(videos[lectureKey].duration / 60) : 0
126-
}
132+
videos[lectureKey].history = history[videos[lectureKey].id] ?? { currentTime: null, updatedAt: null };
127133
if (videos[lectureKey].duration) {
128134
progress.duration -= -videos[lectureKey].duration;
129135
if (videos[lectureKey].history.currentTime) {
@@ -154,6 +160,7 @@ export class CoursePage implements OnInit, AfterViewInit {
154160
this.videoPlayer.src(video.sources);
155161
this.currentVideo = video;
156162
this.videoPlayerElement.nativeElement.focus();
163+
this.sessionUid = ulid();
157164
}
158165

159166
async setPlaybackSpeed() {
@@ -215,4 +222,7 @@ export class CoursePage implements OnInit, AfterViewInit {
215222
};
216223
}
217224

225+
protected updatePlayRecord() {
226+
this.manService.updatePlayRecord(this.sessionUid, this.currentVideo.id, this.videoPlayer.currentTime(), this.videoPlayer.playbackRate()).subscribe();
227+
}
218228
}

src/app/home/home.page.html

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,38 @@
1313
</ion-header>
1414

1515
<ion-content [fullscreen]="true">
16-
<ion-grid *ngIf="folderList$ | async as folderList">
17-
<ion-row>
18-
<ion-col size-xs="6" size-sm="4" size-md="3" size-lg="2" *ngFor="let folder of folderList">
19-
<ion-card [routerLink]="folder" [ngStyle]="{'background-color': colorByFolderName(folder)}">
20-
<ion-card-header>
21-
<ion-card-title>{{ folder }}</ion-card-title>
22-
</ion-card-header>
23-
</ion-card>
24-
</ion-col>
25-
</ion-row>
26-
</ion-grid>
27-
<div *ngIf="lastVideo$ | async as lastVideo">
28-
<p *ngIf="lastVideo.course">
29-
Last played:
30-
<a (click)="goToLastVideo(lastVideo)" (keyup)="goToLastVideo(lastVideo)" tabindex="0">
31-
<span class="course-name">{{ lastVideo.course }}</span>
32-
<ion-icon name="play"></ion-icon>
33-
</a>
16+
<div *ngIf="response$ | async as response; else notLoaded">
17+
<ion-grid>
18+
<ion-row>
19+
<ion-col size-xs="6" size-sm="4" size-md="3" size-lg="2" *ngFor="let folder of Object.keys(response.years)">
20+
<ion-card [routerLink]="folder" [ngStyle]="{'background-color': colorByFolderName(folder)}">
21+
<ion-card-header>
22+
<ion-card-title>{{ folder }}</ion-card-title>
23+
</ion-card-header>
24+
</ion-card>
25+
</ion-col>
26+
</ion-row>
27+
</ion-grid>
28+
<div *ngIf="response.last_played">
29+
<p>
30+
Last played:
31+
<a (click)="goToLastVideo(response.last_played.video)" (keyup)="goToLastVideo(response.last_played.video)" tabindex="0">
32+
<span class="course-name">{{ response.last_played.video.course.name }}</span>
33+
<ion-icon name="play"></ion-icon>
34+
</a>
35+
</p>
36+
</div>
37+
<p class="small-text">
38+
<span *ngIf="response.last_fetched_at">
39+
Last fetched from MDCU E-Learning at {{ response.last_fetched_at }}<br/>
40+
</span>
41+
Your feedback and suggestions are welcome. Please email them to <a href="mailto:siwat.techa@docchula.com" target="_blank">siwat.techa&#64;docchula.com</a>.<br/>
42+
Contents are provided for MDCU students only. You may not copy, reproduce, distribute, publish, display, create derivative works,
43+
transmit, or
44+
in any way exploit any such content.
3445
</p>
3546
</div>
36-
<p class="small-text">
37-
Your feedback and suggestions are welcome. Please email them to <a href="mailto:siwat.techa@docchula.com" target="_blank">siwat.techa&#64;docchula.com</a>.<br/>
38-
Contents are provided for MDCU students only. You may not copy, reproduce, distribute, publish, display, create derivative works, transmit, or in any way exploit any such content.
39-
</p>
47+
<ng-template #notLoaded>
48+
<h4 class="center-text">Loading...</h4>
49+
</ng-template>
4050
</ion-content>

src/app/home/home.page.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ ion-card-title {
1717
}
1818
}
1919

20+
.center-text {
21+
text-align: center;
22+
}
23+
2024
.small-text {
2125
color: #aaaaaa;
2226
a {

src/app/home/home.page.ts

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { Component, OnInit } from '@angular/core';
2-
import { last, Observable } from 'rxjs';
3-
import { ManService } from '../man.service';
4-
import { map } from 'rxjs/operators';
2+
import { Observable } from 'rxjs';
3+
import {CourseListResponse, Lecture, ManService} from '../man.service';
54
import { Router } from '@angular/router';
65
import { AuthService } from '../auth.service';
76
import { colorByFolderName } from '../../helpers';
8-
import { PlayHistoryValue, PlayTrackerService } from '../play-tracker.service';
97
import { addIcons } from "ionicons";
108
import { logOutOutline, play } from "ionicons/icons";
119

@@ -15,14 +13,10 @@ import { logOutOutline, play } from "ionicons/icons";
1513
styleUrls: ['home.page.scss']
1614
})
1715
export class HomePage implements OnInit {
18-
folderList$: Observable<string[]>;
19-
lastVideo$: Observable<PlayHistoryValue | null>;
20-
protected readonly last = last;
16+
response$: Observable<CourseListResponse>;
2117

22-
constructor(private manService: ManService, private router: Router, private authService: AuthService,
23-
private playTracker: PlayTrackerService) {
18+
constructor(private manService: ManService, private router: Router, private authService: AuthService) {
2419
addIcons({ logOutOutline, play });
25-
2620
}
2721

2822
logout() {
@@ -32,18 +26,14 @@ export class HomePage implements OnInit {
3226
}
3327

3428
ngOnInit() {
35-
this.folderList$ = this.manService.getVideoList().pipe(map(a => Object.keys(a)));
36-
this.lastVideo$ = this.playTracker.retrieve().pipe(map(history => {
37-
return Object.keys(history).sort((a, b) => {
38-
// @ts-ignore
39-
return history[b].updatedAt - history[a].updatedAt;
40-
}).slice(0, 1).map(key => history[key])[0] ?? null;
41-
}));
29+
this.response$ = this.manService.getVideoList();
4230
}
4331

4432
protected readonly colorByFolderName = colorByFolderName;
4533

46-
goToLastVideo(lastVideo: PlayHistoryValue) {
47-
this.router.navigate(['home', lastVideo.year, lastVideo.course]);
34+
goToLastVideo(lastVideo: Lecture) {
35+
return this.router.navigate(['home', lastVideo.course.category, lastVideo.course.name]);
4836
}
37+
38+
protected readonly Object = Object;
4939
}

src/app/home/list/list.page.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111

1212
<ion-content>
1313
<ion-list *ngIf="list$ | async as list">
14-
<ion-item button *ngFor="let course of list" [routerLink]="course">
14+
<ion-item button *ngFor="let course of list" [routerLink]="course.name">
1515
<ion-label>
16-
<span class="e-learning-tag" *ngIf="course.endsWith(' [E-Learning]')">
16+
<span class="e-learning-tag" *ngIf="course.is_remote">
1717
MDCU <small>E-Learning</small>
1818
</span>
19-
{{ course.replace(' [E-Learning]', '') }}
19+
{{ course.name }}
2020
</ion-label>
2121
</ion-item>
2222
</ion-list>

0 commit comments

Comments
 (0)