fcad-core-dragon 2.1.0-beta.1 → 2.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG +40 -0
- package/package.json +30 -31
- package/src/components/AppBase.vue +167 -39
- package/src/components/AppBaseButton.test.js +0 -1
- package/src/components/AppBaseModule.vue +103 -116
- package/src/components/AppBasePage.vue +13 -13
- package/src/components/AppCompInputCheckBoxNx.vue +1 -1
- package/src/components/AppCompInputRadioNx.vue +1 -1
- package/src/components/AppCompMenu.vue +2 -1
- package/src/components/AppCompPlayBarNext.vue +157 -16
- package/src/components/AppCompPopUpNext.vue +3 -3
- package/src/components/AppCompQuizNext.vue +1 -1
- package/src/components/AppCompQuizRecall.vue +2 -3
- package/src/components/AppCompTableOfContent.vue +1 -1
- package/src/components/tests__/useTimer.spec.js +91 -0
- package/src/composables/useIdleDetector.js +56 -0
- package/src/composables/useQuiz.js +1 -1
- package/src/composables/useTimer.js +175 -0
- package/src/main.js +2 -0
- package/src/module/stores/appStore.js +10 -34
- package/src/module/xapi/ADL.js +1 -0
- package/src/plugins/analytics.js +34 -0
- package/src/plugins/i18n.js +9 -27
- package/src/router/index.js +6 -3
- package/src/components/AppCompPlayBarProgress.vue +0 -82
- package/src/mixins/$mediaMixins.js +0 -809
- package/src/mixins/timerMixin.js +0 -195
- package/src/module/xapi/wrapper copy.js +0 -1963
|
@@ -430,6 +430,10 @@ mediaMixins is used for all the methods/data shared between audio and video. In
|
|
|
430
430
|
</app-base-button>
|
|
431
431
|
</div>
|
|
432
432
|
</div>
|
|
433
|
+
<div v-if="appDebugMode" class="timer">
|
|
434
|
+
<!-- <div class="timer"> -->
|
|
435
|
+
{{ timer.getElapsedTime() }}
|
|
436
|
+
</div>
|
|
433
437
|
</div>
|
|
434
438
|
</div>
|
|
435
439
|
</template>
|
|
@@ -438,21 +442,24 @@ mediaMixins is used for all the methods/data shared between audio and video. In
|
|
|
438
442
|
import { mapState, mapActions } from 'pinia'
|
|
439
443
|
import { useAppStore } from '../module/stores/appStore'
|
|
440
444
|
import axios from 'axios'
|
|
441
|
-
|
|
445
|
+
import { Timer } from '../composables/useTimer'
|
|
446
|
+
import { reactive } from 'vue'
|
|
442
447
|
export default {
|
|
443
448
|
inject: ['userInteraction'],
|
|
444
449
|
props: {
|
|
445
450
|
mediaToPlay: { type: [Object, Boolean], default: false }
|
|
446
451
|
},
|
|
447
|
-
|
|
448
452
|
emits: ['resize-video'],
|
|
449
|
-
|
|
453
|
+
|
|
454
|
+
setup(props) {
|
|
450
455
|
const store = useAppStore()
|
|
451
|
-
|
|
456
|
+
const id = `plyr_${props.mediaToPlay.id}`
|
|
457
|
+
const timer = reactive(new Timer(id)) // Making Timer instance reactive to be able to track changes
|
|
458
|
+
|
|
459
|
+
return { id, store, timer }
|
|
452
460
|
},
|
|
453
461
|
data() {
|
|
454
462
|
return {
|
|
455
|
-
id: `plyr_${this.mediaToPlay.id}`,
|
|
456
463
|
//Playback animation
|
|
457
464
|
playClicked: false,
|
|
458
465
|
playBackAnim: true,
|
|
@@ -521,7 +528,14 @@ export default {
|
|
|
521
528
|
progressBar: false,
|
|
522
529
|
volumeSlider: false
|
|
523
530
|
},
|
|
524
|
-
otherVideoTranscriptShown: false
|
|
531
|
+
otherVideoTranscriptShown: false,
|
|
532
|
+
previousTime: 0,
|
|
533
|
+
viewedThreshold: 0.85,
|
|
534
|
+
viewedThresholdReached: false,
|
|
535
|
+
startedTime: 0,
|
|
536
|
+
playStmtSent: false,
|
|
537
|
+
mediaRawData: null
|
|
538
|
+
|
|
525
539
|
//==========================================================
|
|
526
540
|
}
|
|
527
541
|
},
|
|
@@ -539,8 +553,12 @@ export default {
|
|
|
539
553
|
'getMediaPlaybarValues',
|
|
540
554
|
'getPageInteraction',
|
|
541
555
|
'getUserInteraction',
|
|
542
|
-
'getMediaMuted'
|
|
556
|
+
'getMediaMuted',
|
|
557
|
+
'getAppDebugMode'
|
|
543
558
|
]),
|
|
559
|
+
appDebugMode() {
|
|
560
|
+
return this.getAppDebugMode
|
|
561
|
+
},
|
|
544
562
|
//==========================================================
|
|
545
563
|
//MediaElement
|
|
546
564
|
mediaElement() {
|
|
@@ -636,7 +654,7 @@ export default {
|
|
|
636
654
|
|
|
637
655
|
return txtA11Y
|
|
638
656
|
},
|
|
639
|
-
|
|
657
|
+
|
|
640
658
|
//Transcript
|
|
641
659
|
hasTranscript() {
|
|
642
660
|
if (!this.mediaToPlay) return
|
|
@@ -715,6 +733,18 @@ export default {
|
|
|
715
733
|
},
|
|
716
734
|
immediate: true
|
|
717
735
|
// deep: true
|
|
736
|
+
},
|
|
737
|
+
getCurrentPage: {
|
|
738
|
+
handler(newValue) {
|
|
739
|
+
if (!newValue.audiosData && !newValue.videosData) return
|
|
740
|
+
const { audiosData, videosData } = this.getCurrentPage
|
|
741
|
+
this.mediaRawData =
|
|
742
|
+
this.mediaToPlay.mType == 'video'
|
|
743
|
+
? videosData.find((el) => el.id == this.mediaToPlay.id)
|
|
744
|
+
: audiosData.find((el) => el.id == this.mediaToPlay.id)
|
|
745
|
+
},
|
|
746
|
+
immediate: true,
|
|
747
|
+
deep: true
|
|
718
748
|
}
|
|
719
749
|
},
|
|
720
750
|
|
|
@@ -792,7 +822,7 @@ export default {
|
|
|
792
822
|
//window handlers for playbar progress
|
|
793
823
|
window.addEventListener('mousemove', this.progressWindowMove)
|
|
794
824
|
window.addEventListener('mouseup', this.progressWindowUp)
|
|
795
|
-
|
|
825
|
+
|
|
796
826
|
//Update data when media as a timeupdate/ended
|
|
797
827
|
this.mediaElement.addEventListener(
|
|
798
828
|
'timeupdate',
|
|
@@ -835,22 +865,32 @@ export default {
|
|
|
835
865
|
},
|
|
836
866
|
/**
|
|
837
867
|
* @description - play or pause the media
|
|
838
|
-
*
|
|
868
|
+
* when the media starts playing, send a statement to the LRS.
|
|
869
|
+
* Statement is sent only once per media play
|
|
870
|
+
* @fires manage-media-players - to the Page the media in play
|
|
839
871
|
*/
|
|
840
|
-
togglePlay() {
|
|
872
|
+
async togglePlay() {
|
|
841
873
|
//If the progressBar is at the end, restart the media
|
|
842
874
|
if (this.progressBarEnded) {
|
|
843
875
|
this.mediaElement.currentTime = 0
|
|
844
876
|
this.currentTime = 0
|
|
845
877
|
}
|
|
846
|
-
//
|
|
878
|
+
//Play or Pause the media
|
|
847
879
|
this.isPlaying ? this.mediaElement.pause() : this.mediaElement.play()
|
|
880
|
+
this.startedTime = this.mediaElement.currentTime
|
|
881
|
+
// start or pause the timer depending of the state of the media
|
|
882
|
+
!this.isPlaying ? this.timer.start() : this.timer.pause()
|
|
883
|
+
|
|
884
|
+
if (!this.playStmtSent) {
|
|
885
|
+
this.sendMediaStatement('play', this.startedTime, null)
|
|
886
|
+
this.playStmtSent = true
|
|
887
|
+
}
|
|
848
888
|
//Data
|
|
849
889
|
if (!this.isPlaying) {
|
|
850
890
|
//Signal to set this mediaElement as the last playing
|
|
851
891
|
this.$bus.$emit('manage-media-players', this.mediaToPlay)
|
|
892
|
+
this.previousTime = Math.round(this.mediaElement.currentTime)
|
|
852
893
|
}
|
|
853
|
-
|
|
854
894
|
this.isPlaying = !this.isPlaying
|
|
855
895
|
this.canReplay = false
|
|
856
896
|
},
|
|
@@ -878,6 +918,15 @@ export default {
|
|
|
878
918
|
if (this.mediaElement && this.isPlaying) {
|
|
879
919
|
this.mediaElement.pause()
|
|
880
920
|
this.isPlaying = false
|
|
921
|
+
|
|
922
|
+
const expectedViewTime = Number(
|
|
923
|
+
this.timer.getElapsedTime() / Math.trunc(this.mediaDuration)
|
|
924
|
+
) //relation view time to media duration
|
|
925
|
+
|
|
926
|
+
this.timer.stop()
|
|
927
|
+
|
|
928
|
+
this.sendMediaStatement('end', this.startedTime, expectedViewTime)
|
|
929
|
+
this.playStmtSent = false //reset the play statement for next play
|
|
881
930
|
}
|
|
882
931
|
this.$bus.$emit('media-viewed', this.mediaToPlay.id)
|
|
883
932
|
this.canReplay = true
|
|
@@ -1123,6 +1172,11 @@ export default {
|
|
|
1123
1172
|
updateProgressBarTime(e) {
|
|
1124
1173
|
//Get currentTime from the event
|
|
1125
1174
|
this.currentTime = e.target.currentTime
|
|
1175
|
+
|
|
1176
|
+
//Set viewedThresholdReached to true when the threshold is reached
|
|
1177
|
+
if (Math.trunc(this.progressBarPercentage) == this.viewedThreshold * 100)
|
|
1178
|
+
this.viewedThresholdReached = true
|
|
1179
|
+
|
|
1126
1180
|
//Update strings
|
|
1127
1181
|
this.setProgressBarA11Y()
|
|
1128
1182
|
//If replay is true and the progressIndicator is not at the end, set canReplay to false
|
|
@@ -1542,7 +1596,6 @@ export default {
|
|
|
1542
1596
|
}
|
|
1543
1597
|
|
|
1544
1598
|
if (this.transcriptEnabled && this.transcriptToShow) {
|
|
1545
|
-
//this.$bus.$emit('resize-media', 'sm')
|
|
1546
1599
|
//Resize video container
|
|
1547
1600
|
this.$emit('resize-video', 'sm')
|
|
1548
1601
|
//Open sidebar with the transcript
|
|
@@ -1555,7 +1608,6 @@ export default {
|
|
|
1555
1608
|
|
|
1556
1609
|
// Send close signal for the side bar when transcipt state is not enabled
|
|
1557
1610
|
if (!this.transcriptEnabled) {
|
|
1558
|
-
//this.$bus.$emit('resize-media', 'lg')
|
|
1559
1611
|
//Resize video container
|
|
1560
1612
|
this.$emit('resize-video', 'lg')
|
|
1561
1613
|
//Open sidebar with the transcript
|
|
@@ -1612,7 +1664,8 @@ export default {
|
|
|
1612
1664
|
},
|
|
1613
1665
|
|
|
1614
1666
|
/**
|
|
1615
|
-
* @description Show the media controler
|
|
1667
|
+
* @description Show the media controler
|
|
1668
|
+
*/
|
|
1616
1669
|
showControls() {
|
|
1617
1670
|
this.showControlsValue = true
|
|
1618
1671
|
if (this.hideTimer) clearTimeout(this.hideTimer) //cancel existing timer
|
|
@@ -1629,6 +1682,94 @@ export default {
|
|
|
1629
1682
|
this.hideTimer = setTimeout(() => {
|
|
1630
1683
|
this.showControlsValue = false
|
|
1631
1684
|
}, this.delayUntilHide)
|
|
1685
|
+
},
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* @description - Send xAPI statement for media play and end
|
|
1689
|
+
* @param {String} action - play or end
|
|
1690
|
+
* @param {Number} startTime - time in seconds when the media start to be played
|
|
1691
|
+
* @param {Number} endTime - time in seconds when the media stop to be played
|
|
1692
|
+
*/
|
|
1693
|
+
async sendMediaStatement(action, startTime = null, endTime = null) {
|
|
1694
|
+
const expectedActions = ['play', 'end']
|
|
1695
|
+
if (!action || !expectedActions.includes(action)) return
|
|
1696
|
+
|
|
1697
|
+
// const { audiosData, videosData } = this.getCurrentPage
|
|
1698
|
+
const media = this.mediaRawData
|
|
1699
|
+
// this.mediaToPlay.mType == 'video'
|
|
1700
|
+
// ? videosData.find((el) => el.id == this.mediaToPlay.id)
|
|
1701
|
+
// : audiosData.find((el) => el.id == this.mediaToPlay.id)
|
|
1702
|
+
|
|
1703
|
+
if (!media || !media.mSources || !media.mSources.length) return
|
|
1704
|
+
|
|
1705
|
+
const { mTitle, mSources } = media
|
|
1706
|
+
const { mType } = this.mediaToPlay
|
|
1707
|
+
|
|
1708
|
+
const URIBase = `media/${mType}/`
|
|
1709
|
+
|
|
1710
|
+
const stmt = {
|
|
1711
|
+
id: (() => `${URIBase}${mSources[0].src.split('/').toReversed()[0]}`)(),
|
|
1712
|
+
verb: null,
|
|
1713
|
+
definition: mTitle || mSources[0].src,
|
|
1714
|
+
description: null,
|
|
1715
|
+
type: `https://w3id.org/xapi/${mType}/activity-type/${mType}`,
|
|
1716
|
+
context: {
|
|
1717
|
+
contextActivities: {
|
|
1718
|
+
category: [{ id: `https://w3id.org/xapi/${mType}` }]
|
|
1719
|
+
},
|
|
1720
|
+
extensions: {
|
|
1721
|
+
'https://w3id.org/xapi/video/extensions/session-id':
|
|
1722
|
+
this.getModuleInfo.id
|
|
1723
|
+
}
|
|
1724
|
+
},
|
|
1725
|
+
result: null
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
switch (action) {
|
|
1729
|
+
case 'play': {
|
|
1730
|
+
stmt.verb = 'played'
|
|
1731
|
+
stmt.description = `${mType} ${stmt.id.replace(URIBase, '')}`
|
|
1732
|
+
stmt.result = {
|
|
1733
|
+
extensions: {
|
|
1734
|
+
'https://w3id.org/xapi/video/extensions/time':
|
|
1735
|
+
Math.round(startTime),
|
|
1736
|
+
'https://w3id.org/xapi/video/extensions/progress': Math.round(
|
|
1737
|
+
(startTime / this.mediaDuration) * 100
|
|
1738
|
+
)
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
break
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
case 'end': {
|
|
1745
|
+
stmt.verb = 'completed'
|
|
1746
|
+
stmt.description = `${mType} ${stmt.id.replace(URIBase, '')}`
|
|
1747
|
+
stmt.result = {
|
|
1748
|
+
extensions: {
|
|
1749
|
+
'https://w3id.org/xapi/video/extensions/progress': endTime * 100,
|
|
1750
|
+
'https://w3id.org/xapi/video/extensions/time-from':
|
|
1751
|
+
Math.round(startTime),
|
|
1752
|
+
'https://w3id.org/xapi/video/extensions/time-to': this.currentTime
|
|
1753
|
+
},
|
|
1754
|
+
completion:
|
|
1755
|
+
endTime >= this.viewedThreshold && this.viewedThresholdReached
|
|
1756
|
+
}
|
|
1757
|
+
stmt.duration = this.timer.ISOTimeParser(endTime * this.mediaDuration)
|
|
1758
|
+
break
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/* send play/end events to Google Analytics */
|
|
1763
|
+
const analyticsEventName = `fcad_${mType}_${action}`
|
|
1764
|
+
const analyticsEventParams = {
|
|
1765
|
+
title: stmt.definition,
|
|
1766
|
+
url: mSources[0].src
|
|
1767
|
+
}
|
|
1768
|
+
if (action === 'end') {
|
|
1769
|
+
analyticsEventParams.viewed = this.viewedThresholdReached
|
|
1770
|
+
}
|
|
1771
|
+
this.$analytics.sendEvent(analyticsEventName, analyticsEventParams)
|
|
1772
|
+
this.$bus.$emit('send-xapi-statement', stmt)
|
|
1632
1773
|
}
|
|
1633
1774
|
}
|
|
1634
1775
|
}
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
centered
|
|
44
44
|
@after-leave="$close(pContent.cb_$close)"
|
|
45
45
|
>
|
|
46
|
-
<focus-trap v-model:active="dialogue">
|
|
46
|
+
<focus-trap v-model:active="dialogue" :prevent-scroll="true">
|
|
47
47
|
<div class="pop-outside">
|
|
48
48
|
<div class="pop-header">
|
|
49
49
|
<app-base-button
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
v-bind="pArgs"
|
|
86
86
|
@after-leave="$close(pContent.cb_$close)"
|
|
87
87
|
>
|
|
88
|
-
<focus-trap v-model:active="dialogue">
|
|
88
|
+
<focus-trap v-model:active="dialogue" :prevent-scroll="true">
|
|
89
89
|
<div class="pop-outside">
|
|
90
90
|
<div class="pop-header">
|
|
91
91
|
<app-base-button
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
v-bind="pArgs"
|
|
193
193
|
@after-leave="$close(pContent.cb_$close)"
|
|
194
194
|
>
|
|
195
|
-
<focus-trap v-model:active="dialogue">
|
|
195
|
+
<focus-trap v-model:active="dialogue" :prevent-scroll="true">
|
|
196
196
|
<component :is="{ template: pContent.template }"></component>
|
|
197
197
|
</focus-trap>
|
|
198
198
|
</v-dialog>
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
v-if="quizRecall.title"
|
|
20
20
|
class="quizRecall-title"
|
|
21
21
|
>
|
|
22
|
-
{{ quizRecall.title }}
|
|
22
|
+
{{ quizRecall.title }}
|
|
23
23
|
</component>
|
|
24
24
|
<!--Quiz answer conditionning-->
|
|
25
25
|
<app-base-error-display
|
|
@@ -60,7 +60,6 @@
|
|
|
60
60
|
//Recall mixins has the necessary datas and functions to give back quizRecall statu
|
|
61
61
|
import { mapState } from 'pinia'
|
|
62
62
|
import { useAppStore } from '../module/stores/appStore'
|
|
63
|
-
import { nextTick } from 'vue'
|
|
64
63
|
|
|
65
64
|
export default {
|
|
66
65
|
name: 'AppCompQuizRecall',
|
|
@@ -198,7 +197,7 @@ export default {
|
|
|
198
197
|
},
|
|
199
198
|
//Get datas from quizRecallData and userData and add them to quizRecall object
|
|
200
199
|
async getQuizRecallAnswer(userData) {
|
|
201
|
-
await nextTick() //wait for the DOM to update
|
|
200
|
+
await this.$nextTick() //wait for the DOM to update
|
|
202
201
|
const { quizId, hypertext_done, hypertext_undone, title, titletag } =
|
|
203
202
|
this.quizRecallData
|
|
204
203
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
<template>
|
|
11
11
|
<div v-if="error" id="sidebar-submenu" :class="{ isOpen: isOpened }">
|
|
12
|
-
<focus-trap :active="isOpened">
|
|
12
|
+
<focus-trap :active="isOpened" :prevent-scroll="true">
|
|
13
13
|
<div ref="target">
|
|
14
14
|
<!-- <div class="submenu-header"> -->
|
|
15
15
|
<app-base-button
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { Timer } from '../../composables/useTimer'
|
|
3
|
+
|
|
4
|
+
// vitest vi utility ref: https://vitest.dev/api/vi.html
|
|
5
|
+
// vi fakeTimers ref: https://vitest.dev/api/vi.html#vi-usefaketimers
|
|
6
|
+
|
|
7
|
+
describe('Timer class', () => {
|
|
8
|
+
let timer
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
Timer.timers.clear() // Clear existing timers before each test
|
|
12
|
+
timer = new Timer()
|
|
13
|
+
vi.useFakeTimers() // Enable fake timers before each test
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
timer.destroy() // destroy created timer after each test
|
|
18
|
+
vi.useRealTimers() // Restore real timers after each test
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should create a Timer instance with a unique ID', () => {
|
|
22
|
+
const timer2 = new Timer() //create a second instance
|
|
23
|
+
expect(timer2.getTimerID()).not.toBe(timer.getTimerID())
|
|
24
|
+
expect(Timer.timers.size).toBe(2) // Two timers should be registered
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should return a timer instance by it ID', () => {
|
|
28
|
+
const timer2 = new Timer('timer22') //create a second instance with custom ID
|
|
29
|
+
expect(timer.getTimer('timer22')).toBeInstanceOf(Timer)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should throw error when a timer instance doesn`t exist', () => {
|
|
33
|
+
const id = 'noExistingTimer'
|
|
34
|
+
expect(() => timer.getTimer(id)).toThrowError(
|
|
35
|
+
`Timer with ID ${id} does not exist.`
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should initialize timer at 0 seconds', () => {
|
|
40
|
+
expect(timer.getTime()).toBe(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should start the timer and increment time every second', () => {
|
|
44
|
+
timer.start()
|
|
45
|
+
expect(timer.getTimerState()).toBe('started')
|
|
46
|
+
|
|
47
|
+
vi.advanceTimersByTime(5000) // Fast-forward 3 seconds
|
|
48
|
+
expect(timer.getTime()).toBe(5) // Should be approximately 5 seconds
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should display time in correct format when converting to ISOformats', () => {
|
|
52
|
+
timer.start()
|
|
53
|
+
expect(timer.getTimerState()).toBe('started')
|
|
54
|
+
|
|
55
|
+
vi.advanceTimersByTime(65000) // Fast-forward 3 seconds
|
|
56
|
+
|
|
57
|
+
const fullISOFormat = '1970-01-01T00:01:05.000Z'
|
|
58
|
+
const isoTimeStr = '00:01:05'
|
|
59
|
+
|
|
60
|
+
expect(timer.getTime()).toBe(65) // Should be approximately 1 min 5 seconds (00:01:05)
|
|
61
|
+
expect(timer.formatToISOString(timer.getTime())).toBe(fullISOFormat)
|
|
62
|
+
expect(timer.ISOTimeParser(timer.getTime())).toBe(isoTimeStr)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should pause the timer', () => {
|
|
66
|
+
timer.start()
|
|
67
|
+
vi.advanceTimersByTime(2000)
|
|
68
|
+
timer.pause()
|
|
69
|
+
const current = timer.getTime()
|
|
70
|
+
|
|
71
|
+
// Advance time but timer should stay paused
|
|
72
|
+
vi.advanceTimersByTime(3000)
|
|
73
|
+
expect(timer.getTime()).toBe(current) // about 2 seconds
|
|
74
|
+
expect(timer.ISOTimeParser(timer.getTime())).toBe('00:00:02')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should reset the timer to 0 when timer stops', () => {
|
|
78
|
+
timer.start()
|
|
79
|
+
vi.advanceTimersByTime(4000)
|
|
80
|
+
timer.stop()
|
|
81
|
+
expect(timer.getTime()).toBe(0)
|
|
82
|
+
expect(timer.getTimerState()).toBe('stopped')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should destroy timer', () => {
|
|
86
|
+
const id = timer.getTimerID()
|
|
87
|
+
timer.destroy()
|
|
88
|
+
expect(Timer.timers.get(id)).toBeUndefined()
|
|
89
|
+
expect(Timer.timers.size).toBe(0)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export class IdleDetector {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.idleCounter = 0
|
|
4
|
+
this.idleTimeoutID = null
|
|
5
|
+
this.detectorState = 'stopped' // can be 'pause' or 'stop'
|
|
6
|
+
this.idleTimer = null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @description Start the idle timer -
|
|
11
|
+
* creates an interval to track idle time and a timeout to trigger actions
|
|
12
|
+
* after a specified period of inactivity.
|
|
13
|
+
* @param {Function} action - a callback function to execute when the idle timeout is reached.
|
|
14
|
+
* @param {Number} timeout - the duration in milliseconds before the action is triggered.
|
|
15
|
+
*/
|
|
16
|
+
startIdleTimer(action = null, timeout = 0) {
|
|
17
|
+
this.idleTimer = setInterval(() => {
|
|
18
|
+
this.idleCounter += 1
|
|
19
|
+
}, 1000)
|
|
20
|
+
this.detectorState = 'started'
|
|
21
|
+
|
|
22
|
+
if (!action || typeof action !== 'function') return
|
|
23
|
+
|
|
24
|
+
this.idleTimeoutID = setTimeout(() => action(), timeout)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @description Reset the idle timer -
|
|
29
|
+
* clears the current idle timer and timeout,
|
|
30
|
+
*/
|
|
31
|
+
stopIdleTimer() {
|
|
32
|
+
this.idleCounter = 0
|
|
33
|
+
clearInterval(this.idleTimer)
|
|
34
|
+
clearTimeout(this.idleTimeoutID)
|
|
35
|
+
this.detectorState = 'stopped'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @description Get the current state of the timer
|
|
40
|
+
*
|
|
41
|
+
* @returns {String} - timer state, can be 'started' or 'stopped'
|
|
42
|
+
*/
|
|
43
|
+
getDectorState() {
|
|
44
|
+
return this.detectorState
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @description Get the elapsed time for the idle timer.
|
|
49
|
+
* This is the time the idle detector has been running since it was started.
|
|
50
|
+
*
|
|
51
|
+
* @returns {Number} - the time count in seconds
|
|
52
|
+
*/
|
|
53
|
+
getElapsedTime() {
|
|
54
|
+
return this.idleCounter
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//This composable
|
|
1
|
+
//This composable should Extend the functionality of a the quiz
|
|
2
2
|
|
|
3
3
|
import i18n from '@/i18n' //Must import directly the local from project app because vue-18in does not work in legacy mode with composable
|
|
4
4
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export class Timer {
|
|
2
|
+
static timers = new Map() // Store all timers by ID
|
|
3
|
+
|
|
4
|
+
constructor(timerID) {
|
|
5
|
+
this.timerID = timerID || this.createTimerID() //timerID of the timer
|
|
6
|
+
this.timeCounter = 0 //time counter
|
|
7
|
+
this.elapsedCounter = 0 //elapsed time counter how lofg the timer is running
|
|
8
|
+
this.interval = null //interval for the timer
|
|
9
|
+
this.timerState = 'stopped' //state of the timer, can be 'started' or 'stopped'
|
|
10
|
+
Timer.timers.set(this.timerID, this) // Store the timer in the static map
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @description Start the timer
|
|
15
|
+
*/
|
|
16
|
+
start() {
|
|
17
|
+
if (this.getTimerState() == 'stopped') {
|
|
18
|
+
this.interval = setInterval(() => {
|
|
19
|
+
this.timeCounter += 1
|
|
20
|
+
this.elapsedCounter += 1
|
|
21
|
+
}, 1000)
|
|
22
|
+
this.timerState = 'started'
|
|
23
|
+
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @description Pause the timer */
|
|
28
|
+
pause() {
|
|
29
|
+
if (this.getTimerState() == 'started') {
|
|
30
|
+
clearInterval(this.interval)
|
|
31
|
+
this.timerState = 'stopped'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @description Stop the timer and reset the timer to zero */
|
|
36
|
+
stop() {
|
|
37
|
+
if (this.getTimerState() == 'started') {
|
|
38
|
+
clearInterval(this.interval)
|
|
39
|
+
this.timeCounter = 0
|
|
40
|
+
this.elapsedCounter = 0
|
|
41
|
+
this.timerState = 'stopped'
|
|
42
|
+
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @description Destroy the timer instance */
|
|
47
|
+
destroy() {
|
|
48
|
+
this.pause()
|
|
49
|
+
Timer.timers.delete(this.timerID)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @description Get the timer ID */
|
|
53
|
+
getTimerID() {
|
|
54
|
+
return this.timerID
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
*
|
|
59
|
+
* @description retrive a timer instance by its ID
|
|
60
|
+
* If no ID is provided, it returns the current timer instance.
|
|
61
|
+
* @param {String} id - the ID of the timer to retrieve
|
|
62
|
+
* If no ID is provided, it returns the current timer instance.
|
|
63
|
+
*
|
|
64
|
+
* @throws {Error} - if the timer with the specified ID does not exist
|
|
65
|
+
*
|
|
66
|
+
* @returns {Timer} - the timer instance
|
|
67
|
+
*/
|
|
68
|
+
getTimer(id=null) {
|
|
69
|
+
if (!id) {
|
|
70
|
+
console.warn('No timer ID provided. Returning the current timer.')
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
if (!Timer.timers.has(id)) throw new Error(`Timer with ID ${id} does not exist.`)
|
|
74
|
+
return Timer.timers.get(id)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @description Get the current time counter in seconds
|
|
79
|
+
*
|
|
80
|
+
* @returns {Number} - the time counter in seconds
|
|
81
|
+
*/
|
|
82
|
+
getTime() {
|
|
83
|
+
return this.timeCounter
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* @description Get the current state of the timer
|
|
87
|
+
*
|
|
88
|
+
* @returns {String} - timer state, can be 'started' or 'stopped'
|
|
89
|
+
*/
|
|
90
|
+
getTimerState() {
|
|
91
|
+
return this.timerState
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* @description Get the elapsed time for the timer
|
|
95
|
+
* This is the time the timer has been running since it was started.
|
|
96
|
+
* It is not reset when the timer is paused.
|
|
97
|
+
*
|
|
98
|
+
* @returns {Number} - the time count in seconds
|
|
99
|
+
*/
|
|
100
|
+
getElapsedTime() {
|
|
101
|
+
return this.elapsedCounter
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @description Get all existing timers
|
|
107
|
+
*
|
|
108
|
+
* @returns {Map} - a map of all timer instances with their IDs as keys
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
static getAllTimers() {
|
|
112
|
+
return Timer.timers
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @description - This method generates a unique timer ID
|
|
117
|
+
*
|
|
118
|
+
* @returns {String} - a unique timer ID
|
|
119
|
+
*/
|
|
120
|
+
createTimerID() {
|
|
121
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
122
|
+
let str = ''
|
|
123
|
+
const n = 2
|
|
124
|
+
for (let i = 0; i < n; i++) {
|
|
125
|
+
str = `${str}${alphabet[Math.floor(Math.random() * alphabet.length)]}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const id = `Tm$${str}${Math.floor(Math.random() * 1000)}`
|
|
129
|
+
|
|
130
|
+
return id
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @description Format seconds to ISO 8601 format
|
|
136
|
+
* Example of ISO 8601 time format of 24 chars: "1970-01-04T14:50:00.000Z"
|
|
137
|
+
* @param {Number} seconds - time in seconds
|
|
138
|
+
*
|
|
139
|
+
* @returns {String} - ISO 8601 formatted time string // YYYY-MM-DDTHH:mm:ss.sssZ
|
|
140
|
+
*/
|
|
141
|
+
formatToISOString(seconds) {
|
|
142
|
+
let d = new Date(null) //create a default date ref
|
|
143
|
+
d.setSeconds(seconds) //set the time with passed numbers of seconds
|
|
144
|
+
|
|
145
|
+
let ISOTime = d.toISOString()
|
|
146
|
+
|
|
147
|
+
return ISOTime
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @description gives time period in the format of HH:mm:ss
|
|
152
|
+
* @param {Number} seconds - time in seconds
|
|
153
|
+
*
|
|
154
|
+
* @returns {String} - time period in the format of HH:mm:ss
|
|
155
|
+
*/
|
|
156
|
+
ISOTimeParser(seconds) {
|
|
157
|
+
let ISOTimePeriod = this.formatToISOString(seconds).substring(8, 19) // only the time portion of it
|
|
158
|
+
ISOTimePeriod = ISOTimePeriod.split('T')
|
|
159
|
+
const DDToHrs = (parseInt(ISOTimePeriod[0]) - 1) * 24 // convert xxT to hrs
|
|
160
|
+
const periodOfTime = ISOTimePeriod[1] ? ISOTimePeriod[1].split(':') : null
|
|
161
|
+
|
|
162
|
+
if (!periodOfTime) return '00:00:00'
|
|
163
|
+
|
|
164
|
+
let timeString = ''
|
|
165
|
+
let HH = (DDToHrs + parseInt(periodOfTime[0])).toString()
|
|
166
|
+
let mm = periodOfTime[1]
|
|
167
|
+
let ss = periodOfTime[2]
|
|
168
|
+
|
|
169
|
+
if (HH.length === 1) HH = `0${HH}`
|
|
170
|
+
|
|
171
|
+
timeString = `${HH}:${mm}:${ss}`
|
|
172
|
+
|
|
173
|
+
return timeString
|
|
174
|
+
}
|
|
175
|
+
}
|