Skip to content

Commit 54b3c47

Browse files
committed
feat: log play activity
1 parent 25d5432 commit 54b3c47

File tree

2 files changed

+82
-42
lines changed

2 files changed

+82
-42
lines changed

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

Lines changed: 80 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild} from '@angular/core';
2-
import {combineLatest, EMPTY, Observable, startWith, Subject} from 'rxjs';
2+
import {combineLatest, EMPTY, fromEvent, mergeAll, Observable, of, pairwise, startWith, Subject, takeUntil, throttleTime} from 'rxjs';
33
import {ActivatedRoute, Router} from '@angular/router';
44
import {CourseMembers, Lecture, ManService} from '../../man.service';
55
import {first, map, switchMap} from 'rxjs/operators';
@@ -31,7 +31,6 @@ import {
3131
} from '@ionic/angular/standalone';
3232
import {DomSanitizer} from '@angular/platform-browser';
3333
import {PlayHistory} from '../../play-tracker.service';
34-
import {Analytics, logEvent} from '@angular/fire/analytics';
3534
import {addIcons} from "ionicons";
3635
import {checkmarkOutline, closeOutline, documentAttachOutline, download, pauseCircleOutline} from "ionicons/icons";
3736
import type Player from 'video.js/dist/types/player';
@@ -84,14 +83,15 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
8483
isAndroid = /Android/i.test(navigator.userAgent);
8584
isIos = /iPad/i.test(navigator.userAgent) || /iPhone/i.test(navigator.userAgent);
8685
lastPlayedVideoKey: number = null;
86+
playLog: { startTime: number, endTime: number | null, playbackRate: number, createdAt: number, updatedAt: number }[] = [];
8787
progressTimedOut: string | null;
8888
progressNetworkError: boolean | null;
8989
sessionUid: string; // Unique ID for session x video (new id for each video)
90-
stopPolling = new Subject<boolean>();
90+
stopPolling$ = new Subject<boolean>();
9191

9292
constructor(private route: ActivatedRoute, private router: Router,
9393
private manService: ManService, private alertController: AlertController,
94-
private analytics: Analytics, private sanitizer: DomSanitizer) {
94+
private sanitizer: DomSanitizer) {
9595
addIcons({ download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline });
9696
}
9797

@@ -105,7 +105,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
105105
if ((this.year && this.course) || this.courseId) {
106106
return combineLatest([
107107
this.manService.getVideosInCourse(this.year, this.course, this.courseId),
108-
this.manService.getPlayRecord(this.year, this.course, this.courseId, this.stopPolling).pipe(startWith(null)),
108+
this.manService.getPlayRecord(this.year, this.course, this.courseId, this.stopPolling$).pipe(startWith(null)),
109109
]).pipe(map(([courseData, history]) => {
110110
if (!courseData) {
111111
return [];
@@ -141,40 +141,80 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
141141
enableModifiersForNumbers: false,
142142
enableVolumeScroll: false,
143143
});
144-
this.videoPlayer.on('pause', () => {
145-
if (!this.videoPlayer.seeking()) {
146-
// is paused, not seeking
147-
this.updatePlayRecord();
148-
}
149-
});
150144
this.videoPlayer.on('ended', () => this.updatePlayRecord());
151-
let lastUpdated = 0;
152-
this.videoPlayer.on('timeupdate', () => {
153-
// Update while playing every 20 seconds
154-
if (Date.now() - lastUpdated > 20000) {
155-
lastUpdated = Date.now();
156-
this.updatePlayRecord();
157-
}
158-
});
159-
this.videoPlayer.on('tracking:performance', (_e: never, data: never) => {
160-
console.log('performance');
161-
if (this.videoPlayer.currentTime() > 30) {
162-
logEvent(this.analytics, 'video_performance', this.attachEventLabel(data, true));
163-
this.updatePlayRecord();
164-
}
165-
});
166145
this.videoPlayer.on('loadedmetadata', () => {
167146
// On video load, seek to last played position
168147
if (this.currentVideo.history.end_time
169148
&& (!this.currentVideo.duration || (((this.currentVideo.history.end_time ?? 0) / this.currentVideo.duration) < 0.995))) {
170149
this.videoPlayer.currentTime(this.currentVideo.history.end_time);
171150
}
172151
});
152+
153+
// Listen for video time updates
154+
let lastUpdated = 0;
155+
const allUpdate$ = of(
156+
fromEvent(this.videoPlayer, 'timeupdate').pipe(throttleTime(2000)),
157+
fromEvent(this.videoPlayer, 'pause'),
158+
).pipe(
159+
mergeAll(),
160+
takeUntil(this.stopPolling$),
161+
map(() => ({
162+
currentTime: this.videoPlayer.currentTime(),
163+
playbackRate: this.videoPlayer.playbackRate(),
164+
isPaused: this.videoPlayer.paused(),
165+
isSeeking: this.videoPlayer.seeking(),
166+
})),
167+
pairwise(), // Groups pairs of consecutive emissions together
168+
);
169+
allUpdate$.subscribe(([previous, current]) => {
170+
const currentTimestamp = Date.now();
171+
// Update play log
172+
const lastLogKey = this.playLog.length - 1;
173+
const lastLog = this.playLog[lastLogKey] ?? null;
174+
if (current.isPaused) {
175+
if (lastLog && lastLog.endTime === null) {
176+
lastLog.endTime = current.currentTime;
177+
lastLog.updatedAt = currentTimestamp;
178+
this.playLog[lastLogKey] = lastLog;
179+
}
180+
} else {
181+
if (!lastLog // New
182+
|| lastLog.playbackRate !== current.playbackRate // Playback rate changed
183+
|| (current.currentTime - lastLog.endTime > 10) // Seek to next position
184+
|| (lastLog.endTime - current.currentTime > 3) // Seek to previous position
185+
|| (currentTimestamp - lastLog.updatedAt > 5000) // Disrupted logging
186+
) {
187+
this.playLog.push({
188+
startTime: current.currentTime,
189+
endTime: current.currentTime,
190+
playbackRate: current.playbackRate,
191+
createdAt: currentTimestamp,
192+
updatedAt: currentTimestamp,
193+
});
194+
} else {
195+
lastLog.endTime = current.currentTime;
196+
lastLog.updatedAt = currentTimestamp;
197+
this.playLog[lastLogKey] = lastLog;
198+
}
199+
}
200+
201+
// Push to server
202+
if (current.currentTime !== previous.currentTime) {
203+
if (currentTimestamp - lastUpdated > 20000) {
204+
// Save progress while playing every 20 seconds
205+
lastUpdated = currentTimestamp;
206+
this.updatePlayRecord();
207+
} else if (current.isPaused && !current.isSeeking) {
208+
// Save progress when pause but not seeking
209+
this.updatePlayRecord();
210+
}
211+
}
212+
});
173213
});
174214
}
175215

176216
ngOnDestroy() {
177-
this.stopPolling.next(true);
217+
this.stopPolling$.next(true);
178218
}
179219

180220
mergeVideoInfo(videos: CourseMembers, history: PlayHistory|null) {
@@ -223,6 +263,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
223263
this.currentVideo = video;
224264
this.videoPlayerElement.nativeElement.focus();
225265
this.sessionUid = ulid();
266+
this.playLog = [];
226267
}
227268

228269
async setPlaybackSpeed() {
@@ -272,18 +313,6 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
272313
return encodeURIComponent(url);
273314
}
274315

275-
lectureById(index: number, lecture: Lecture) {
276-
return lecture.identifier;
277-
}
278-
279-
protected attachEventLabel(data: object, isNonInteraction?: boolean) {
280-
return {
281-
...data,
282-
event_label: this.currentVideo.identifier,
283-
non_interaction: isNonInteraction === true
284-
};
285-
}
286-
287316
protected getCurrentPlayTimeString() {
288317
const time = this.videoPlayer.currentTime();
289318
const seconds = Math.floor(time % 60);
@@ -295,10 +324,21 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
295324
}
296325

297326
protected updatePlayRecord() {
298-
this.manService.updatePlayRecord(this.sessionUid, this.currentVideo.id, this.videoPlayer.currentTime(), this.videoPlayer.playbackRate()).subscribe({
327+
const requestTime = Date.now();
328+
// Clean play log: remove unnecessary logs
329+
this.playLog = this.playLog.filter((log, i) => log.startTime !== log.endTime || i >= this.playLog.length - 1);
330+
this.manService.updatePlayRecord(
331+
this.sessionUid,
332+
this.currentVideo.id,
333+
this.videoPlayer.currentTime(),
334+
this.videoPlayer.playbackRate(),
335+
this.playLog,
336+
).subscribe({
299337
next: () => {
300338
this.progressTimedOut = null;
301339
this.progressNetworkError = null;
340+
// Clear play log, except the last or new one
341+
this.playLog = this.playLog.filter((log, i) => log.updatedAt > requestTime || i >= this.playLog.length - 1);
302342
},
303343
error: e => {
304344
if (e.status === 0) {

src/app/man.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,13 @@ export class ManService {
121121
);
122122
}
123123

124-
updatePlayRecord(uid: string, video_id: string | number, progress: number, speed: number) {
125-
// @todo Throttle this function
124+
updatePlayRecord(uid: string, video_id: string | number, progress: number, speed: number, log: object[]) {
126125
return this.post<JSend<null>>('v1/play_records', {
127126
uid,
128127
video_id,
129128
progress,
130129
speed,
130+
log,
131131
});
132132
}
133133

0 commit comments

Comments
 (0)