Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ npm-debug.log*
# Sentry Config File
.env.sentry-build-plugin
.sentryclirc

# Local editor and tool settings
.claude/
76 changes: 76 additions & 0 deletions src/app/home/course/course.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
}
}
Loading