1
1
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' ;
3
3
import { ActivatedRoute , Router } from '@angular/router' ;
4
4
import { CourseMembers , Lecture , ManService } from '../../man.service' ;
5
5
import { first , map , switchMap } from 'rxjs/operators' ;
@@ -31,7 +31,6 @@ import {
31
31
} from '@ionic/angular/standalone' ;
32
32
import { DomSanitizer } from '@angular/platform-browser' ;
33
33
import { PlayHistory } from '../../play-tracker.service' ;
34
- import { Analytics , logEvent } from '@angular/fire/analytics' ;
35
34
import { addIcons } from "ionicons" ;
36
35
import { checkmarkOutline , closeOutline , documentAttachOutline , download , pauseCircleOutline } from "ionicons/icons" ;
37
36
import type Player from 'video.js/dist/types/player' ;
@@ -84,14 +83,15 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
84
83
isAndroid = / A n d r o i d / i. test ( navigator . userAgent ) ;
85
84
isIos = / i P a d / i. test ( navigator . userAgent ) || / i P h o n e / i. test ( navigator . userAgent ) ;
86
85
lastPlayedVideoKey : number = null ;
86
+ playLog : { startTime : number , endTime : number | null , playbackRate : number , createdAt : number , updatedAt : number } [ ] = [ ] ;
87
87
progressTimedOut : string | null ;
88
88
progressNetworkError : boolean | null ;
89
89
sessionUid : string ; // Unique ID for session x video (new id for each video)
90
- stopPolling = new Subject < boolean > ( ) ;
90
+ stopPolling$ = new Subject < boolean > ( ) ;
91
91
92
92
constructor ( private route : ActivatedRoute , private router : Router ,
93
93
private manService : ManService , private alertController : AlertController ,
94
- private analytics : Analytics , private sanitizer : DomSanitizer ) {
94
+ private sanitizer : DomSanitizer ) {
95
95
addIcons ( { download, documentAttachOutline, checkmarkOutline, closeOutline, pauseCircleOutline } ) ;
96
96
}
97
97
@@ -105,7 +105,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
105
105
if ( ( this . year && this . course ) || this . courseId ) {
106
106
return combineLatest ( [
107
107
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 ) ) ,
109
109
] ) . pipe ( map ( ( [ courseData , history ] ) => {
110
110
if ( ! courseData ) {
111
111
return [ ] ;
@@ -141,40 +141,80 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
141
141
enableModifiersForNumbers : false ,
142
142
enableVolumeScroll : false ,
143
143
} ) ;
144
- this . videoPlayer . on ( 'pause' , ( ) => {
145
- if ( ! this . videoPlayer . seeking ( ) ) {
146
- // is paused, not seeking
147
- this . updatePlayRecord ( ) ;
148
- }
149
- } ) ;
150
144
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
- } ) ;
166
145
this . videoPlayer . on ( 'loadedmetadata' , ( ) => {
167
146
// On video load, seek to last played position
168
147
if ( this . currentVideo . history . end_time
169
148
&& ( ! this . currentVideo . duration || ( ( ( this . currentVideo . history . end_time ?? 0 ) / this . currentVideo . duration ) < 0.995 ) ) ) {
170
149
this . videoPlayer . currentTime ( this . currentVideo . history . end_time ) ;
171
150
}
172
151
} ) ;
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
+ } ) ;
173
213
} ) ;
174
214
}
175
215
176
216
ngOnDestroy ( ) {
177
- this . stopPolling . next ( true ) ;
217
+ this . stopPolling$ . next ( true ) ;
178
218
}
179
219
180
220
mergeVideoInfo ( videos : CourseMembers , history : PlayHistory | null ) {
@@ -223,6 +263,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
223
263
this . currentVideo = video ;
224
264
this . videoPlayerElement . nativeElement . focus ( ) ;
225
265
this . sessionUid = ulid ( ) ;
266
+ this . playLog = [ ] ;
226
267
}
227
268
228
269
async setPlaybackSpeed ( ) {
@@ -272,18 +313,6 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
272
313
return encodeURIComponent ( url ) ;
273
314
}
274
315
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
-
287
316
protected getCurrentPlayTimeString ( ) {
288
317
const time = this . videoPlayer . currentTime ( ) ;
289
318
const seconds = Math . floor ( time % 60 ) ;
@@ -295,10 +324,21 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy {
295
324
}
296
325
297
326
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 ( {
299
337
next : ( ) => {
300
338
this . progressTimedOut = null ;
301
339
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 ) ;
302
342
} ,
303
343
error : e => {
304
344
if ( e . status === 0 ) {
0 commit comments