Skip to content

Commit fbec296

Browse files
committed
feat: refine vod control panel
1 parent eda587a commit fbec296

7 files changed

Lines changed: 145 additions & 71 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ eCampus 페이지에서 과목별 학습 항목(과제/퀴즈/강의/토론/자
1616

1717
## 버전별 업데이트
1818

19+
### v1.2.1 (업데이트: 2026-03-31)
20+
21+
- VOD 패널에서 5초 이동 버튼을 제거해 UI를 단순화했습니다.
22+
- `1000배속 스킵` 버튼을 일반 배속 옵션과 분리해 별도 버튼으로 제공하고, 일반 배속 메뉴는 `0.75x ~ 4.0x`만 유지합니다.
23+
- `1000배속 스킵`은 플레이어의 실제 배속 한계를 우회하기 위해 초고속 앞으로 넘기기 방식으로 동작합니다.
24+
1925
### v1.2.0 (업데이트: 2026-03-18)
2026

2127
- 과목별 퀴즈 목록과 상세 정보를 함께 수집해 제목, 마감, 성적을 대시보드에서 바로 확인할 수 있습니다.

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "eHelper",
4-
"version": "1.2.0",
4+
"version": "1.2.1",
55
"description": "eCampus 과목별 과제/퀴즈/강의/토론/자료를 한눈에 보는 확장 프로그램",
66
"permissions": ["storage", "downloads"],
77
"host_permissions": ["https://ecampus.smu.ac.kr/*"],

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ecampus-dashboard-smu",
3-
"version": "1.2.0",
3+
"version": "1.2.1",
44
"private": true,
55
"type": "module",
66
"scripts": {

src/content/modules/vod.panel.template.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,13 @@ export function buildVodPanelMarkup(
3535
aria-label="강의 다운로드"
3636
><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3v11m0 0l-4-4m4 4l4-4M5 21h14"/></svg></button>
3737
</div>
38-
<div class="ecdash-vod-seek-actions">
39-
<button
40-
type="button"
41-
id="ecdash-vod-seek-back"
42-
class="ecdash-vod-seek-btn"
43-
aria-label="5초 뒤로 이동"
44-
title="5초 뒤로"
45-
><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M11 8L7 12l4 4M17 8l-4 4 4 4"/></svg></button>
46-
<button
47-
type="button"
48-
id="ecdash-vod-seek-forward"
49-
class="ecdash-vod-seek-btn"
50-
aria-label="5초 앞으로 이동"
51-
title="5초 앞으로"
52-
><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M13 8l4 4-4 4M7 8l4 4-4 4"/></svg></button>
53-
</div>
38+
<button
39+
type="button"
40+
id="ecdash-vod-speed-max"
41+
class="ecdash-vod-turbo-button"
42+
aria-label="1000배속 스킵 모드"
43+
title="1000배속 스킵 모드"
44+
>1000배속 스킵</button>
5445
<div id="${menuId}" class="ecdash-vod-speed-menu" role="listbox" hidden></div>
5546
<div class="ecdash-vod-speed-hint">Alt+, 느리게 · Alt+. 빠르게 · Alt+/ 기본속도</div>
5647
</div>

src/content/modules/vod.panel.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ import { buildVodPanelMarkup } from './vod.panel.template';
1414
const VOD_SPEED_MENU_ID = 'ecdash-vod-speed-menu';
1515
const VOD_PANEL_BODY_ID = 'ecdash-vod-panel-body';
1616
const VOD_PANEL_TOGGLE_ID = 'ecdash-vod-panel-toggle';
17+
const VOD_TURBO_RATE = 1000;
18+
19+
function formatVodSpeedLabel(rate) {
20+
const current = E.closestVodSpeedOption(rate);
21+
return Number.isInteger(current) ? `${current}x` : `${current.toFixed(2)}x`;
22+
}
23+
24+
function updateVodTurboButtonState(panel, rate) {
25+
const turboButton = panel?.querySelector('#ecdash-vod-speed-max');
26+
if (!turboButton) return;
27+
const isActive = E.closestVodSpeedOption(rate) === VOD_TURBO_RATE;
28+
turboButton.classList.toggle('is-active', isActive);
29+
turboButton.setAttribute('aria-pressed', isActive ? 'true' : 'false');
30+
}
1731

1832
E.updateVodSpeedPanelRate = function updateVodSpeedPanelRate(rate) {
1933
const panel = document.getElementById(VOD_SPEED_PANEL_ID);
@@ -22,8 +36,10 @@ import { buildVodPanelMarkup } from './vod.panel.template';
2236
const current = E.closestVodSpeedOption(rate);
2337
const label = panel.querySelector('#ecdash-vod-speed-current');
2438
if (!label) return;
25-
label.textContent = `${current.toFixed(2)}x`;
26-
label.setAttribute('aria-label', `현재 배속 ${current.toFixed(2)}x`);
39+
const speedLabel = formatVodSpeedLabel(current);
40+
label.textContent = speedLabel;
41+
label.setAttribute('aria-label', `현재 배속 ${speedLabel}`);
42+
updateVodTurboButtonState(panel, current);
2743
};
2844

2945
E.updateVodSpeedMenuSelection = function updateVodSpeedMenuSelection(rate) {
@@ -37,6 +53,7 @@ import { buildVodPanelMarkup } from './vod.panel.template';
3753
btn.classList.toggle('is-active', active);
3854
btn.setAttribute('aria-selected', active ? 'true' : 'false');
3955
});
56+
updateVodTurboButtonState(panel, current);
4057
};
4158

4259
E.setVodSpeedMenuOpen = function setVodSpeedMenuOpen(panel, isOpen) {
@@ -92,7 +109,7 @@ import { buildVodPanelMarkup } from './vod.panel.template';
92109
btn.dataset.rate = String(option);
93110
btn.setAttribute('role', 'option');
94111
btn.setAttribute('aria-selected', 'false');
95-
btn.textContent = `${option.toFixed(2)}x`;
112+
btn.textContent = formatVodSpeedLabel(option);
96113
menu.appendChild(btn);
97114
});
98115
}
@@ -119,7 +136,10 @@ import { buildVodPanelMarkup } from './vod.panel.template';
119136
};
120137

121138
const applyAndPersist = async (nextRate, persist = true) => {
122-
const { rate } = E.applyVodPlaybackRate(nextRate);
139+
const { rate } =
140+
typeof E.applyVodPlaybackMode === 'function'
141+
? E.applyVodPlaybackMode(nextRate)
142+
: E.applyVodPlaybackRate(nextRate);
123143
targetRate = E.closestVodSpeedOption(rate);
124144
E.updateVodSpeedPanelRate(targetRate);
125145
E.updateVodSpeedMenuSelection(targetRate);
@@ -153,6 +173,13 @@ import { buildVodPanelMarkup } from './vod.panel.template';
153173
void applyAndPersist(E.nextVodSpeedOption(targetRate, 1), true);
154174
});
155175

176+
panel
177+
.querySelector('#ecdash-vod-speed-max')
178+
?.addEventListener('click', () => {
179+
closeMenu();
180+
void applyAndPersist(VOD_TURBO_RATE, true);
181+
});
182+
156183
panel
157184
.querySelector('#ecdash-vod-speed-current')
158185
?.addEventListener('click', () => {
@@ -198,20 +225,6 @@ import { buildVodPanelMarkup } from './vod.panel.template';
198225
}
199226
});
200227

201-
panel
202-
.querySelector('#ecdash-vod-seek-back')
203-
?.addEventListener('click', () => {
204-
closeMenu();
205-
E.seekVodByDelta(-5);
206-
});
207-
208-
panel
209-
.querySelector('#ecdash-vod-seek-forward')
210-
?.addEventListener('click', () => {
211-
closeMenu();
212-
E.seekVodByDelta(5);
213-
});
214-
215228
document.addEventListener('click', (event) => {
216229
if (panel.contains(event.target)) return;
217230
closeMenu();
@@ -257,7 +270,10 @@ import { buildVodPanelMarkup } from './vod.panel.template';
257270
video.dataset.ecdashSpeedBound = '1';
258271

259272
const sync = () => {
260-
const applied = E.applyVodPlaybackRate(targetRate);
273+
const applied =
274+
typeof E.applyVodPlaybackMode === 'function'
275+
? E.applyVodPlaybackMode(targetRate)
276+
: E.applyVodPlaybackRate(targetRate);
261277
if (applied.applied) {
262278
E.updateVodSpeedPanelRate(applied.rate);
263279
E.updateVodSpeedMenuSelection(applied.rate);
@@ -274,7 +290,10 @@ import { buildVodPanelMarkup } from './vod.panel.template';
274290
if (vodRoot) {
275291
const speedObserver = new MutationObserver(() => {
276292
bindVideoListeners();
277-
const applied = E.applyVodPlaybackRate(targetRate);
293+
const applied =
294+
typeof E.applyVodPlaybackMode === 'function'
295+
? E.applyVodPlaybackMode(targetRate)
296+
: E.applyVodPlaybackRate(targetRate);
278297
if (applied.applied) {
279298
E.updateVodSpeedPanelRate(applied.rate);
280299
E.updateVodSpeedMenuSelection(applied.rate);
@@ -290,13 +309,19 @@ import { buildVodPanelMarkup } from './vod.panel.template';
290309
}
291310

292311
bindVideoListeners();
293-
const first = E.applyVodPlaybackRate(targetRate);
312+
const first =
313+
typeof E.applyVodPlaybackMode === 'function'
314+
? E.applyVodPlaybackMode(targetRate)
315+
: E.applyVodPlaybackRate(targetRate);
294316
if (!first.applied) {
295317
// 플레이어 초기화 지연 대비: 짧은 시간 동안 재시도
296318
let retries = 0;
297319
const timer = setInterval(() => {
298320
retries += 1;
299-
const next = E.applyVodPlaybackRate(targetRate);
321+
const next =
322+
typeof E.applyVodPlaybackMode === 'function'
323+
? E.applyVodPlaybackMode(targetRate)
324+
: E.applyVodPlaybackRate(targetRate);
300325
if (next.applied || retries >= 12) {
301326
clearInterval(timer);
302327
}

src/content/modules/vod.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
E.constants.VOD_SPEED_OPTIONS.length
99
? E.constants.VOD_SPEED_OPTIONS
1010
: [0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3, 3.5, 4];
11+
const VOD_TURBO_RATE = 1000;
1112

1213
E.isVodPlayerPage = function isVodPlayerPage() {
1314
return document.getElementById('vod_player') != null;
@@ -71,6 +72,7 @@
7172
E.normalizeVodPlaybackRate = function normalizeVodPlaybackRate(raw) {
7273
const value = Number(raw);
7374
if (!Number.isFinite(value)) return 1;
75+
if (value === VOD_TURBO_RATE) return VOD_TURBO_RATE;
7476
if (value < VOD_SPEED_OPTIONS[0]) return VOD_SPEED_OPTIONS[0];
7577
if (value > VOD_SPEED_OPTIONS[VOD_SPEED_OPTIONS.length - 1]) {
7678
return VOD_SPEED_OPTIONS[VOD_SPEED_OPTIONS.length - 1];
@@ -80,6 +82,7 @@
8082

8183
E.closestVodSpeedOption = function closestVodSpeedOption(raw) {
8284
const value = E.normalizeVodPlaybackRate(raw);
85+
if (value === VOD_TURBO_RATE) return VOD_TURBO_RATE;
8386
let best = VOD_SPEED_OPTIONS[0];
8487
let diff = Math.abs(value - best);
8588

@@ -95,7 +98,10 @@
9598
};
9699

97100
E.nextVodSpeedOption = function nextVodSpeedOption(raw, direction) {
98-
const current = E.closestVodSpeedOption(raw);
101+
const current =
102+
E.closestVodSpeedOption(raw) === VOD_TURBO_RATE
103+
? VOD_SPEED_OPTIONS[VOD_SPEED_OPTIONS.length - 1]
104+
: E.closestVodSpeedOption(raw);
99105
const idx = VOD_SPEED_OPTIONS.findIndex(
100106
(option) => Math.abs(option - current) < 0.001,
101107
);
@@ -379,6 +385,56 @@
379385
return { applied, rate };
380386
};
381387

388+
E.stopVodTurboMode = function stopVodTurboMode() {
389+
E.__vodTurboEnabled = false;
390+
if (E.__vodTurboTimer) {
391+
clearInterval(E.__vodTurboTimer);
392+
E.__vodTurboTimer = null;
393+
}
394+
};
395+
396+
E.startVodTurboMode = function startVodTurboMode() {
397+
E.__vodTurboEnabled = true;
398+
399+
// 플레이어 자체는 지원 최대치로 유지하고, 재생 중에는 주기적으로 앞으로 점프한다.
400+
E.applyVodPlaybackRate(4);
401+
402+
if (E.__vodTurboTimer) return { applied: true, rate: VOD_TURBO_RATE };
403+
404+
E.__vodTurboTimer = setInterval(() => {
405+
if (!E.__vodTurboEnabled) return;
406+
407+
const video = E.findVodVideoElement();
408+
if (video) {
409+
if (video.paused || video.ended || video.readyState < 1) return;
410+
} else {
411+
const player = E.getVodJwPlayerInstance();
412+
if (player && typeof player.getState === 'function') {
413+
try {
414+
const state = String(player.getState() || '').toLowerCase();
415+
if (state && state !== 'playing' && state !== 'buffering') return;
416+
} catch (_) {
417+
// 무시
418+
}
419+
}
420+
}
421+
422+
E.seekVodByDelta(200);
423+
}, 200);
424+
425+
return { applied: true, rate: VOD_TURBO_RATE };
426+
};
427+
428+
E.applyVodPlaybackMode = function applyVodPlaybackMode(rawRate) {
429+
const rate = E.closestVodSpeedOption(rawRate);
430+
if (rate === VOD_TURBO_RATE) {
431+
return E.startVodTurboMode();
432+
}
433+
434+
E.stopVodTurboMode();
435+
return E.applyVodPlaybackRate(rate);
436+
};
437+
382438
E.seekVodByDelta = function seekVodByDelta(rawDeltaSec) {
383439
const deltaSec = Number(rawDeltaSec);
384440
if (!Number.isFinite(deltaSec) || deltaSec === 0) return false;

src/content/styles/content.css

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,31 @@
127127
color: #e0f2fe;
128128
}
129129

130+
.ecdash-vod-turbo-button {
131+
width: 100%;
132+
height: 30px;
133+
margin-top: 6px;
134+
border: 1px solid rgba(251, 191, 36, 0.38);
135+
border-radius: 8px;
136+
background: rgba(245, 158, 11, 0.18);
137+
color: #fef3c7;
138+
font-size: 12px;
139+
font-weight: 700;
140+
letter-spacing: -0.01em;
141+
font-variant-numeric: tabular-nums;
142+
cursor: pointer;
143+
}
144+
145+
.ecdash-vod-turbo-button:hover {
146+
background: rgba(245, 158, 11, 0.28);
147+
}
148+
149+
.ecdash-vod-turbo-button.is-active {
150+
border-color: rgba(253, 224, 71, 0.8);
151+
background: rgba(245, 158, 11, 0.34);
152+
color: #fff7d6;
153+
}
154+
130155
.ecdash-vod-download {
131156
width: 34px;
132157
height: 30px;
@@ -145,33 +170,8 @@
145170
background: rgba(34, 197, 94, 0.3);
146171
}
147172

148-
.ecdash-vod-seek-actions {
149-
display: grid;
150-
grid-template-columns: 1fr 1fr;
151-
gap: 6px;
152-
margin-top: 6px;
153-
}
154-
155-
.ecdash-vod-seek-btn {
156-
height: 28px;
157-
border: 1px solid rgba(255, 255, 255, 0.22);
158-
border-radius: 8px;
159-
background: rgba(255, 255, 255, 0.1);
160-
color: #e5e7eb;
161-
font-size: 12px;
162-
font-weight: 700;
163-
cursor: pointer;
164-
padding: 0;
165-
font-variant-numeric: tabular-nums;
166-
display: inline-flex;
167-
align-items: center;
168-
justify-content: center;
169-
line-height: 1;
170-
}
171-
172173
.ecdash-vod-speed-actions button svg,
173-
.ecdash-vod-download svg,
174-
.ecdash-vod-seek-btn svg {
174+
.ecdash-vod-download svg {
175175
width: 15px;
176176
height: 15px;
177177
stroke: currentColor;
@@ -183,10 +183,6 @@
183183
display: block;
184184
}
185185

186-
.ecdash-vod-seek-btn:hover {
187-
background: rgba(255, 255, 255, 0.18);
188-
}
189-
190186
.ecdash-vod-download:disabled {
191187
opacity: 0.5;
192188
cursor: not-allowed;

0 commit comments

Comments
 (0)