diff --git a/.gitignore b/.gitignore index 97f54ae..dcf7a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ npm-debug.log* # Sentry Config File .env.sentry-build-plugin .sentryclirc + +# Local editor and tool settings +.claude/ diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index f30584f..7564e21 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -41,6 +41,13 @@ import { AsyncPipe, DatePipe, DecimalPipe, NgClass } from '@angular/common'; import { ModalEvaluationComponent } from './modal-evaluation.component'; import { PomodoroTimerComponent } from '../../shared/pomodoro-timer.component'; +// The video.js SeekBar handlers wrapped by CoursePage.requireDragToSeekOnTouch() +interface SeekBarPointerHandlers { + handleMouseDown(event: Event): void; + handleMouseMove(event: Event, mouseDown?: boolean): void; + handleMouseUp(event: Event): void; +} + @Component({ selector: 'app-course', templateUrl: './course.page.html', @@ -194,6 +201,7 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { enableModifiersForNumbers: false, enableVolumeScroll: false, }); + this.requireDragToSeekOnTouch(); this.videoPlayer.on('ended', () => { this.updatePlayRecord(); if (!this.currentVideo.is_evaluated && !this.isEvaluated) { @@ -286,6 +294,74 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { this.stopPolling$.next(true); } + // video.js seeks as soon as a touch lands on the progress control, so a fingertip brushing + // the bar jumps the video and that position is saved as the play record (issue #64). + // Require the touch to move first. Mouse and keyboard handling is unchanged. + private requireDragToSeekOnTouch() { + const seekBar = this.videoPlayer.getChild('controlBar') + ?.getChild('progressControl') + ?.getChild('seekBar') as unknown as SeekBarPointerHandlers | undefined; + if (!seekBar) { + return; + } + + const dragThresholdPx = 8; + const originalMouseDown = seekBar.handleMouseDown.bind(seekBar); + const originalMouseMove = seekBar.handleMouseMove.bind(seekBar); + const originalMouseUp = seekBar.handleMouseUp.bind(seekBar); + const isTouch = (event: Event) => !!event && event.type.startsWith('touch'); + const touchPoint = (event: Event) => { + const touchEvent = event as TouchEvent; + return touchEvent.changedTouches?.[0] ?? touchEvent.touches?.[0] ?? null; + }; + + let touchOrigin: { x: number, y: number } | null = null; + let isDragging = false; + + seekBar.handleMouseDown = (event: Event) => { + if (!isTouch(event)) { + originalMouseDown(event); + return; + } + // Record the landing point only; the original handler pauses and seeks immediately. + // Do not stop propagation: ProgressControl attaches the document-level touchmove and + // touchend listeners that drive the handlers below. + const point = touchPoint(event); + touchOrigin = point ? { x: point.clientX, y: point.clientY } : null; + isDragging = false; + }; + + seekBar.handleMouseMove = (event: Event, mouseDown = false) => { + if (!isTouch(event) || isDragging) { + originalMouseMove(event, mouseDown); + return; + } + const origin = touchOrigin; + const point = touchPoint(event); + if (!origin || !point) { + return; + } + if (Math.abs(point.clientX - origin.x) < dragThresholdPx + && Math.abs(point.clientY - origin.y) < dragThresholdPx) { + return; // Within the slop of a stationary touch + } + // Hand over to video.js as if the drag started here. Set the flag first, + // since originalMouseDown calls back into handleMouseMove. + isDragging = true; + originalMouseDown(event); + }; + + seekBar.handleMouseUp = (event: Event) => { + const wasStrayTouch = isTouch(event) && !isDragging; + touchOrigin = null; + isDragging = false; + if (wasStrayTouch) { + return; // Nothing was started, so there is no scrub state to tear down + } + originalMouseUp(event); + }; + } + mergeVideoInfo(videos: CourseMembers, history: PlayHistory, evaluations: { [key: number]: EvaluationRecord }) { const progress = { viewed: 0, diff --git a/src/global.scss b/src/global.scss index 17b3d0f..3c81298 100644 --- a/src/global.scss +++ b/src/global.scss @@ -74,3 +74,34 @@ opacity: 1; transform: translate(-50%, 0); } + +/* video.js adds .vjs-touch-enabled based on touch *capability* (navigator.maxTouchPoints), which + stays true on touchscreen laptops even when the mouse is the input in use. Gate on + (hover: none) and (pointer: coarse) too so these rules only kick in when touch is actually the + primary input, not just available. */ +@media (hover: none) and (pointer: coarse) { + /* Keep the play/pause toggle off the player's bottom-left corner, an awkward target on a tablet. */ + .video-js.vjs-touch-enabled .vjs-control-bar .vjs-play-control { + margin-left: 1.5em; + } + + /* The stock 3em bar makes every control 30px tall, short of the 44px a fingertip needs. */ + .video-js.vjs-touch-enabled .vjs-control-bar { + height: 4.4em; + } + + /* video.js centres these with line-heights worked out against the 3em bar, so each has to be + restated for 4.4em. The progress control centres itself with flexbox and needs nothing. */ + .video-js.vjs-touch-enabled .vjs-control-bar .vjs-button > .vjs-icon-placeholder:before { + line-height: 2.4444; /* 1.8em glyph x 2.4444 */ + } + + .video-js.vjs-touch-enabled .vjs-control-bar .vjs-time-control, + .video-js.vjs-touch-enabled .vjs-control-bar .vjs-time-divider { + line-height: 4.4em; + } + + .video-js.vjs-touch-enabled .vjs-control-bar .vjs-playback-rate .vjs-playback-rate-value { + line-height: 2.9333; /* 1.5em digits x 2.9333 */ + } +}