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
14 changes: 14 additions & 0 deletions src/adapters/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// 트랙 목록은 기기 간에 공유할 만한 값이 아니기 때문이다.
const KEY_PREFIX = 'video:'

// 표시 설정은 영상이 아니라 사람에게 붙는다. 영상별 키와 섞이지 않도록 따로 둔다.
const SETTINGS_KEY = 'settings'

function toStorageKey(videoId) {
return `${KEY_PREFIX}${videoId}`
}
Expand Down Expand Up @@ -31,6 +34,17 @@ export async function readVideoState(videoId) {
}
}

// 저장된 값이 성한지는 여기서 따지지 않는다. 무엇이 올바른 값인지는 ui가 안다.
export async function readSettings() {
const stored = await chrome.storage.local.get(SETTINGS_KEY)

return stored[SETTINGS_KEY]
}

export async function writeSettings({ floatingHidden, floatingExpanded }) {
await chrome.storage.local.set({ [SETTINGS_KEY]: { floatingHidden, floatingExpanded } })
}

// Set은 그대로 저장되지 않으므로 경계에서 배열로 바꾼다.
export async function writeVideoState(videoId, { entries, disabledStartSeconds, loopEnabled }) {
if (videoId === null) {
Expand Down
1 change: 1 addition & 0 deletions src/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
fullscreen: 'src/adapters/fullscreen.js',
storage: 'src/adapters/storage.js',
panel: 'src/ui/panel.js',
floatingState: 'src/ui/floatingState.js',
panelReveal: 'src/ui/panelReveal.js',
floating: 'src/ui/floating.js'
}
Expand Down
32 changes: 13 additions & 19 deletions src/ui/floating.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,13 @@ import { createButton } from './elements.js'

const FLOATING_ID = 'timeline-skip-floating'

// 접힘이 기본이다. 아이콘과 카드는 CSS로 숨기는 대신 서로 갈아끼운다.
// 화면에 있는 것이 곧 상태라야, 눌렀을 때 무엇이 달라졌는지가 눈에 보인다.
let isExpanded = false

// 재생 중에는 그리라는 요청이 쉬지 않고 들어온다. 보이는 것이 같으면 건너뛴다.
let lastSignature = null
let lastView = null

export function render(view) {
lastView = view

// 목록이 없는 영상에서까지 떠 있으면 그냥 거슬리는 물건이 된다.
// 전체화면은 영상만 보겠다는 뜻이라 마찬가지로 치운다. 건너뛰기는 위젯 없이도 돈다.
if (view.tracks.length === 0 || view.isFullscreen) {
if (view.tracks.length === 0 || view.isFullscreen || view.floatingHidden) {
removeWidget()

return
Expand All @@ -34,29 +27,30 @@ export function render(view) {
drawInto(root ?? createRoot(), view, playingTitle)
}

// 아이콘과 카드는 CSS로 숨기는 대신 서로 갈아끼운다.
// 화면에 있는 것이 곧 상태라야, 눌렀을 때 무엇이 달라졌는지가 눈에 보인다.
function drawInto(root, view, playingTitle) {
root.classList.toggle('is-expanded', isExpanded)
root.replaceChildren(isExpanded ? createCard(view, playingTitle, () => setExpanded(false)) : createIcon())
root.classList.toggle('is-expanded', view.floatingExpanded)
root.replaceChildren(
view.floatingExpanded
? createCard(view, playingTitle, () => view.onSetFloatingExpanded(false))
: createIcon(view)
)

// 제목 폭은 붙인 뒤에야 잴 수 있다.
startTitleMarquee(root)
}

function createIcon() {
function createIcon(view) {
return createButton({
label: '♪',
className: 'timeline-skip-floating-icon',
title: '플레이어 펼치기',
ariaLabel: '플레이어 펼치기',
onClick: () => setExpanded(true)
onClick: () => view.onSetFloatingExpanded(true)
})
}

function setExpanded(nextExpanded) {
isExpanded = nextExpanded
render(lastView)
}

function createRoot() {
const root = document.createElement('div')
root.id = FLOATING_ID
Expand All @@ -77,6 +71,6 @@ function findPlayingTitle({ tracks, playingStartSeconds }) {
}

// 위젯에 보이는 것은 이 넷뿐이다. 목록이 바뀌어도 이 넷이 그대로면 다시 그릴 이유가 없다.
function toSignature({ isPaused, loopEnabled }, playingTitle) {
return `${playingTitle}#${isPaused}#${loopEnabled}#${isExpanded}`
function toSignature({ isPaused, loopEnabled, floatingExpanded }, playingTitle) {
return `${playingTitle}#${isPaused}#${loopEnabled}#${floatingExpanded}`
}
14 changes: 14 additions & 0 deletions src/ui/floatingState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 위젯을 어떤 모습으로 둘지는 사람마다 다르고, 한 번 정하면 잘 바꾸지 않는다.
// 그래서 영상이 아니라 설정에 저장되고, 저장된 값은 다음에 그대로 돌아온다.
//
// 보임과 크기는 서로 다른 버튼이 다룬다. 값 하나로 합치면 한쪽을 바꿀 때 다른 쪽이 함께
// 덮여서, 펼쳐둔 채 숨긴 위젯이 접힌 모습으로 되돌아온다. 숨기기의 반대는 원상복구다.

// 저장된 값은 예전 버전이 쓰던 것일 수도, 사람이 직접 고친 것일 수도 있다.
// 참이라고 분명히 적힌 것만 참으로 본다. 나머지는 전부 기본값(보이는 접힘)이 된다.
export function toValidSettings(stored) {
return {
floatingHidden: stored?.floatingHidden === true,
floatingExpanded: stored?.floatingExpanded === true
}
}
4 changes: 2 additions & 2 deletions src/ui/panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ function submitAdd(entry) {
lastView.onAdd(entry)
}

function toSignature({ tracks, disabledStartSeconds, isPaused, loopEnabled, playingStartSeconds }) {
function toSignature({ tracks, disabledStartSeconds, isPaused, loopEnabled, playingStartSeconds, floatingHidden }) {
const trackPart = tracks.map((track) => `${track.startSeconds}:${track.title}`).join('|')

return `${trackPart}#${[...disabledStartSeconds].join(',')}#${isPaused}#${loopEnabled}#${playingStartSeconds}`
return `${trackPart}#${[...disabledStartSeconds].join(',')}#${isPaused}#${loopEnabled}#${playingStartSeconds}#${floatingHidden}`
}

function createPanel(container) {
Expand Down
11 changes: 9 additions & 2 deletions src/ui/parts/panelHeader.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createButton } from '../elements.js'

export function createHeader({ tracks, disabledStartSeconds, onEnableAll, onDisableAll, onClear }) {
export function createHeader({ tracks, disabledStartSeconds, floatingHidden, onEnableAll, onDisableAll, onClear, onSetFloatingHidden }) {
const header = document.createElement('div')
header.className = 'timeline-skip-header'

Expand All @@ -12,7 +12,8 @@ export function createHeader({ tracks, disabledStartSeconds, onEnableAll, onDisa
actions.append(
createActionButton('전체 선택', onEnableAll, tracks.length === 0),
createActionButton('전체 해제', onDisableAll, tracks.length === 0),
createActionButton('비우기', onClear, false)
createActionButton('비우기', onClear, false),
createActionButton(toFloatingLabel(floatingHidden), () => onSetFloatingHidden(!floatingHidden), false)
)

header.append(title, actions)
Expand All @@ -27,6 +28,12 @@ function createActionButton(label, onClick, isDisabled) {
return createButton({ label, className: 'timeline-skip-action', isDisabled, onClick })
}

// 숨긴 위젯을 되돌리는 길은 여기 하나뿐이다. 목록이 비어도 눌러둘 수 있어야
// 다음 영상에서 위젯이 뜬다.
function toFloatingLabel(floatingHidden) {
return floatingHidden ? '위젯 보이기' : '위젯 숨기기'
}

function toHeaderLabel(tracks, disabledStartSeconds) {
if (tracks.length === 0) {
return '타임라인'
Expand Down
31 changes: 26 additions & 5 deletions src/wiring.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ import { createTrackActions } from './trackActions.js'

// core / adapters / ui를 연결한다. 계산은 core에, DOM은 adapters와 ui에 있다.
export function start(modules) {
const { builder, player, storage, panel, floating, fullscreen } = modules
const { builder, player, storage, panel, floating, fullscreen, floatingState } = modules

const state = {
videoId: null,
entries: [],
disabledStartSeconds: new Set(),
loopEnabled: false,
tracks: []
tracks: [],

// 저장된 설정을 읽어 오기 전까지 쓸 값. 저장분이 없을 때와 같은 모습이어야 한다.
...floatingState.toValidSettings(undefined)
}

const draw = () => {
state.tracks = builder.buildTracks(state.entries, player.getDurationSeconds())

// 패널과 플로팅 위젯은 같은 것을 보여준다. view를 나눠 가져야 둘이 어긋나지 않는다.
const view = toView(modules, state, actions)
const view = toView(modules, state, actions, commitSettings)

panel.render(view)
floating.render(view)
Expand All @@ -28,14 +31,28 @@ export function start(modules) {
draw()
}

// 표시 설정은 영상이 아니라 사람에게 붙는다. 저장하는 곳이 달라서 commit과 나눈다.
const commitSettings = (changes) => {
Object.assign(state, changes)
storage.writeSettings(state)
draw()
}

const actions = createTrackActions({ state, modules, commit })

bindPage(modules, state, actions, draw)
bindPlayback(modules, state, draw)
fullscreen.onFullscreenChanged(draw)
loadSettings(modules, state, draw)
}

// 설정을 읽는 동안에도 화면은 떠 있어야 한다. 읽고 나서 다시 그린다.
async function loadSettings({ storage, floatingState }, state, draw) {
Object.assign(state, floatingState.toValidSettings(await storage.readSettings()))
draw()
}

function toView(modules, state, actions) {
function toView(modules, state, actions, commitSettings) {
const { player, playing, panelReveal, fullscreen } = modules

return {
Expand All @@ -45,6 +62,8 @@ function toView(modules, state, actions) {
playingStartSeconds: playing.findPlayingStartSeconds(state.tracks, player.getCurrentTimeSeconds()),
isPaused: player.isPaused(),
isFullscreen: fullscreen.isFullscreen(),
floatingHidden: state.floatingHidden,
floatingExpanded: state.floatingExpanded,
getCurrentTimeSeconds: player.getCurrentTimeSeconds,
onSeek: player.seekTo,
onTogglePlay: player.togglePlay,
Expand All @@ -58,7 +77,9 @@ function toView(modules, state, actions) {
onDisableAll: () => actions.setAllTracks(false),
onPrevious: () => goToAdjacentTrack(modules, state, 'previous'),
onNext: () => goToAdjacentTrack(modules, state, 'next'),
onRevealPanel: panelReveal.reveal
onRevealPanel: panelReveal.reveal,
onSetFloatingHidden: (floatingHidden) => commitSettings({ floatingHidden }),
onSetFloatingExpanded: (floatingExpanded) => commitSettings({ floatingExpanded })
}
}

Expand Down
67 changes: 67 additions & 0 deletions tests/e2e/floating.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const CONTROLS = `${FLOATING} .timeline-skip-controls`
const COLLAPSE = `${FLOATING} .timeline-skip-floating-collapse`
const JUMP = `${FLOATING} .timeline-skip-floating-jump`

const HIDE_BUTTON = '#timeline-skip-panel button:has-text("위젯 숨기기")'
const SHOW_BUTTON = '#timeline-skip-panel button:has-text("위젯 보이기")'

test.describe('플로팅 위젯', () => {
test('트랙이 없으면 위젯이 뜨지 않는다', async ({ openWatchPage }) => {
const { page } = await openWatchPage({ commentTexts: [] })
Expand Down Expand Up @@ -147,6 +150,63 @@ test.describe('플로팅 위젯', () => {

await expect(page.locator('#timeline-skip-panel')).toHaveClass(/is-revealed/)
})

test('패널의 숨기기 버튼을 누르면 위젯이 사라진다', async ({ openWatchPage }) => {
const { page } = await openWatchPage({ commentTexts: [TIMELINE_COMMENT] })
await loadTimeline(page)

await expect(page.locator(FLOATING)).toBeAttached()

await page.locator(HIDE_BUTTON).click()

await expect(page.locator(FLOATING)).not.toBeAttached()
})

test('숨긴 뒤 보이기 버튼을 누르면 다시 나타난다', async ({ openWatchPage }) => {
const { page } = await openWatchPage({ commentTexts: [TIMELINE_COMMENT] })
await loadTimeline(page)
await page.locator(HIDE_BUTTON).click()

await expect(page.locator(FLOATING)).not.toBeAttached()

await page.locator(SHOW_BUTTON).click()

await expect(page.locator(ICON)).toBeVisible()
})

test('펼쳐둔 위젯을 숨겼다 보이면 펼친 채로 돌아온다', async ({ openWatchPage }) => {
const { page } = await openWatchPage({ commentTexts: [TIMELINE_COMMENT] })
await loadTimeline(page)
await expandWidget(page)

await page.locator(HIDE_BUTTON).click()
await expect(page.locator(FLOATING)).not.toBeAttached()

await page.locator(SHOW_BUTTON).click()

await expect(page.locator(CARD)).toBeVisible()
})

test('펼쳐둔 상태는 새로고침한 뒤에도 유지된다', async ({ openWatchPage }) => {
const { page } = await openWatchPage({ commentTexts: [TIMELINE_COMMENT] })
await loadTimeline(page)
await expandWidget(page)

await reloadWatchPage(page)

await expect(page.locator(CARD)).toBeVisible()
})

test('숨긴 상태는 새로고침한 뒤에도 유지된다', async ({ openWatchPage }) => {
const { page } = await openWatchPage({ commentTexts: [TIMELINE_COMMENT] })
await loadTimeline(page)
await page.locator(HIDE_BUTTON).click()
await expect(page.locator(FLOATING)).not.toBeAttached()

await reloadWatchPage(page)

await expect(page.locator(FLOATING)).not.toBeAttached()
})
})

// requestFullscreen은 사용자 조작이 있어야 불린다. Playwright의 클릭은 진짜 입력이라 그 조건을 채운다.
Expand All @@ -161,6 +221,13 @@ async function enterFullscreen(page) {
await expect.poll(() => page.evaluate(() => document.fullscreenElement !== null)).toBe(true)
}

// 설정은 저장이 끝나기를 기다려 주지 않는다. 목록이 되살아난 것을 본 다음에 검증해야
// 저장이 늦은 것과 상태가 틀린 것이 구분된다.
async function reloadWatchPage(page) {
await page.reload()
await expect(page.locator('.timeline-skip-row').first()).toBeVisible()
}

async function loadTimeline(page) {
await page.locator('.timeline-skip-load-button').first().click()
await expect(page.locator('.timeline-skip-row').first()).toBeVisible()
Expand Down
29 changes: 29 additions & 0 deletions tests/ui/floatingState.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'

import { toValidSettings } from '../../src/ui/floatingState.js'

describe('toValidSettings', () => {
it('저장된 값이 없으면 보이는 접힘 상태로 시작한다', () => {
expect(toValidSettings(undefined)).toEqual({ floatingHidden: false, floatingExpanded: false })
expect(toValidSettings(null)).toEqual({ floatingHidden: false, floatingExpanded: false })
expect(toValidSettings({})).toEqual({ floatingHidden: false, floatingExpanded: false })
})

it('저장된 값이 참이면 그대로 쓴다', () => {
expect(toValidSettings({ floatingHidden: true, floatingExpanded: true }))
.toEqual({ floatingHidden: true, floatingExpanded: true })
})

it('보임과 크기는 서로를 건드리지 않는다', () => {
expect(toValidSettings({ floatingExpanded: true }))
.toEqual({ floatingHidden: false, floatingExpanded: true })

expect(toValidSettings({ floatingHidden: true }))
.toEqual({ floatingHidden: true, floatingExpanded: false })
})

it('참이 아닌 값은 거짓으로 본다', () => {
expect(toValidSettings({ floatingHidden: 'true', floatingExpanded: 1 }))
.toEqual({ floatingHidden: false, floatingExpanded: false })
})
})
Loading