Skip to content

Commit f74e0c1

Browse files
authored
feat: improved UX for settings menu integration (#12)
* feat: improved UX for settings menu integration * chore: update descriptions
1 parent 3a7634c commit f74e0c1

File tree

19 files changed

+346
-94
lines changed

19 files changed

+346
-94
lines changed
File renamed without changes.

constants/modes.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { TranslateMode } from '@/types/TranslateMode';
2+
import { KeyReturn, SquareSplitHorizontal, SubtitlesSlash, Translate } from '@phosphor-icons/react';
3+
4+
export const MODES = {
5+
DUAL: {
6+
name: 'Side-by-side',
7+
value: TranslateMode.Enabled,
8+
description: 'Show both the original and translated subtitles next to each other.',
9+
icon: SquareSplitHorizontal
10+
},
11+
KEY_PRESS: {
12+
name: 'Translate on keypress',
13+
value: TranslateMode.KeyPress,
14+
description: 'Translate subtitles when the specified key is pressed.',
15+
icon: KeyReturn
16+
},
17+
TRANSLATION_ONLY: {
18+
name: 'Only translation',
19+
value: TranslateMode.TranslationOnly,
20+
description: 'Show only the translated subtitles.',
21+
icon: Translate
22+
},
23+
DISABLED: {
24+
name: 'Disabled',
25+
value: TranslateMode.Disabled,
26+
description: 'Translated subtitles are not shown.',
27+
icon: SubtitlesSlash
28+
}
29+
};

constants/selectors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const SETTINGS_MODAL_QUERY_SELECTOR = 'player-settings-dialog';
2+
export const SUBTITLES_CONTAINER_QUERY_SELECTOR = 'tv-player-subtitles';
3+
export const SUBTITLE_WRAPPER_QUERY_SELECTOR = 'tv-player-subtitles div';
4+
export const SUBTITLE_TEXT_QUERY_SELECTOR = '.tv-player-subtitle-text';
5+
export const TRANSLATED_SUBTITLE_TEXT_QUERY_SELECTOR = '*[data-translated="true"]';
6+
export const VIDEO_PLAYER_QUERY_SELECTOR = 'tv-player video';

constants/sync.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const SYNC_KEY_SETTINGS_LANGUAGE = 'sync:settings-language';
2+
export const SYNC_KEY_SETTINGS_MODE = 'sync:settings-mode';
3+
export const SYNC_KEY_SETTINGS_ACTIVATION_KEY = 'sync:settings-activation-key';

entrypoints/content.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import {
2+
SUBTITLE_TEXT_QUERY_SELECTOR,
3+
SUBTITLE_WRAPPER_QUERY_SELECTOR,
4+
TRANSLATED_SUBTITLE_TEXT_QUERY_SELECTOR,
5+
VIDEO_PLAYER_QUERY_SELECTOR
6+
} from '@/constants/selectors';
17
import { settings$ } from '@/observables/settings';
8+
import { settingsModalState$ } from '@/observables/settings-modal';
29
import { subtitles$ } from '@/observables/subtitles';
3-
import { ExtensionMessage, ExtensionMessageAction, ExtensionReply } from '@/types/ExtensionMessage';
10+
import { ExtensionMessage, ExtensionMessageAction } from '@/types/ExtensionMessage';
411
import { TranslateMode } from '@/types/TranslateMode';
5-
6-
const ACTIVATION_KEY = 't';
12+
import { settingsModal } from './inject/settings-modal/main';
713

814
export default defineContentScript({
915
matches: ['*://tv.nrk.no/*', '*://clients5.google.com/*'],
@@ -12,30 +18,34 @@ export default defineContentScript({
1218
async main() {
1319
let mode: TranslateMode | undefined;
1420
let language: string | undefined;
21+
let activationKey: string | undefined;
1522
let activationKeyPressed = false;
1623

1724
function shiftOriginalSubtitle() {
18-
const subtitleContainer = document.querySelector('tv-player-subtitles div') as HTMLDivElement;
25+
const subtitleContainer = document.querySelector(SUBTITLE_WRAPPER_QUERY_SELECTOR) as HTMLDivElement;
26+
if (!subtitleContainer) return;
1927
subtitleContainer.style.width = '50%';
2028
subtitleContainer.style.left = '0%';
2129
}
2230

2331
function hideOriginalSubtitle() {
24-
const subtitleContainer = document.querySelector('tv-player-subtitles div') as HTMLDivElement;
32+
const subtitleContainer = document.querySelector(SUBTITLE_WRAPPER_QUERY_SELECTOR) as HTMLDivElement;
33+
if (!subtitleContainer) return;
2534
subtitleContainer.style.opacity = '0';
2635
}
2736

2837
function resetOriginalSubtitle() {
29-
const subtitleContainer = document.querySelector('tv-player-subtitles div') as HTMLDivElement;
38+
const subtitleContainer = document.querySelector(SUBTITLE_WRAPPER_QUERY_SELECTOR) as HTMLDivElement;
39+
if (!subtitleContainer) return;
3040
subtitleContainer.style.width = '100%';
3141
subtitleContainer.style.opacity = '1';
3242
subtitleContainer.style.left = 'unset';
3343
}
3444

3545
function appendTranslatedSubtitle(content: string, width = '50%') {
36-
const subtitleContainer = document.querySelector('tv-player-subtitles div') as HTMLDivElement;
46+
const subtitleContainer = document.querySelector(SUBTITLE_WRAPPER_QUERY_SELECTOR) as HTMLDivElement;
3747
const translateContainer = subtitleContainer.cloneNode(true) as HTMLDivElement;
38-
const translateSubtitleText = translateContainer.querySelector('.tv-player-subtitle-text') as HTMLSpanElement;
48+
const translateSubtitleText = translateContainer.querySelector(SUBTITLE_TEXT_QUERY_SELECTOR) as HTMLSpanElement;
3949
if (!translateSubtitleText) return;
4050
translateSubtitleText.innerText = content;
4151
translateContainer.style.left = 'unset';
@@ -47,39 +57,44 @@ export default defineContentScript({
4757
}
4858

4959
function removeTranslatedSubtitle() {
50-
const node = document.querySelector('*[data-translated="true"]') as HTMLDivElement;
60+
const node = document.querySelector(TRANSLATED_SUBTITLE_TEXT_QUERY_SELECTOR) as HTMLDivElement;
61+
if (!node) return;
5162
node.remove();
5263
}
5364

5465
function hideTranslatedSubtitle() {
55-
const node = document.querySelector('*[data-translated="true"]') as HTMLDivElement;
66+
const node = document.querySelector(TRANSLATED_SUBTITLE_TEXT_QUERY_SELECTOR) as HTMLDivElement;
67+
if (!node) return;
5668
node.style.opacity = '0';
5769
resetOriginalSubtitle();
5870
}
5971

6072
function showTranslatedSubtitle() {
61-
const node = document.querySelector('*[data-translated="true"]') as HTMLDivElement;
73+
const node = document.querySelector(TRANSLATED_SUBTITLE_TEXT_QUERY_SELECTOR) as HTMLDivElement;
74+
if (!node) return;
6275
node.style.opacity = '1';
6376
shiftOriginalSubtitle();
6477
}
6578

6679
async function translateSubtitle(subtitle: string[]): Promise<string> {
67-
const translation = (await browser.runtime.sendMessage({
80+
const translation = await browser.runtime.sendMessage({
6881
action: ExtensionMessageAction.Translate,
6982
payload: {
7083
source_lang: 'no',
7184
target_lang: language,
7285
text: subtitle.join('\n')
7386
}
74-
} as ExtensionMessage)) as ExtensionReply<ExtensionMessageAction.Translate>;
87+
} as ExtensionMessage);
7588
if (translation.error) throw translation.error;
7689
return translation.response;
7790
}
7891

7992
document.addEventListener('keydown', (e) => {
80-
if (e.key === ACTIVATION_KEY) {
93+
if (e.key === activationKey) {
94+
e.preventDefault();
95+
e.stopPropagation();
8196
activationKeyPressed = true;
82-
const video = document.querySelector('tv-player video') as HTMLVideoElement;
97+
const video = document.querySelector(VIDEO_PLAYER_QUERY_SELECTOR) as HTMLVideoElement;
8398
switch (mode) {
8499
case TranslateMode.KeyPress:
85100
showTranslatedSubtitle();
@@ -96,9 +111,11 @@ export default defineContentScript({
96111
});
97112

98113
document.addEventListener('keyup', (e) => {
99-
if (e.key === ACTIVATION_KEY) {
114+
if (e.key === activationKey) {
115+
e.preventDefault();
116+
e.stopPropagation();
100117
activationKeyPressed = false;
101-
const video = document.querySelector('tv-player video') as HTMLVideoElement;
118+
const video = document.querySelector(VIDEO_PLAYER_QUERY_SELECTOR) as HTMLVideoElement;
102119
switch (mode) {
103120
case TranslateMode.KeyPress:
104121
hideTranslatedSubtitle();
@@ -115,8 +132,16 @@ export default defineContentScript({
115132
}
116133
});
117134

135+
// Listen for settings changes
118136
settings$.language.forEach((newLanguage) => (language = newLanguage));
119137
settings$.mode.forEach((newMode) => (mode = newMode));
138+
settings$.activationKey.forEach((newActivationKey) => (activationKey = newActivationKey));
139+
140+
// Listen for player settings modal open state
141+
settingsModalState$.forEach((isOpen) => {
142+
settingsModal[isOpen ? 'mount' : 'unmount']();
143+
});
144+
120145
subtitles$.forEach(async (subtitle) => {
121146
switch (mode) {
122147
case TranslateMode.Enabled: {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { LANGUAGES } from '@/constants/languages';
2+
import { MODES } from '@/constants/modes';
3+
import { SYNC_KEY_SETTINGS_ACTIVATION_KEY, SYNC_KEY_SETTINGS_LANGUAGE, SYNC_KEY_SETTINGS_MODE } from '@/constants/sync';
4+
import { settings$ } from '@/observables/settings';
5+
import { TranslateMode } from '@/types/TranslateMode';
6+
import { Heart } from '@phosphor-icons/react';
7+
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
8+
import { Link } from './components/Link';
9+
import { Select } from './components/Select';
10+
import { Tab } from './components/Tab';
11+
import { TabList } from './components/TabList';
12+
13+
export const Settings: React.FC = () => {
14+
// State variables to hold the current settings
15+
const [currentLanguage, setCurrentLanguage] = useState<string>();
16+
const [currentMode, setCurrentMode] = useState<TranslateMode>();
17+
const [currentActivationKey, setCurrentActivationKey] = useState<string>();
18+
19+
// Bind the settings to the state
20+
useEffect(() => void settings$.language.forEach(setCurrentLanguage), []);
21+
useEffect(() => void settings$.mode.forEach(setCurrentMode), []);
22+
useEffect(() => void settings$.activationKey.forEach(setCurrentActivationKey), []);
23+
24+
const handleLanguageChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
25+
storage.setItem<string>(SYNC_KEY_SETTINGS_LANGUAGE, e.target.value);
26+
}, []);
27+
28+
const handleActivationKeyChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
29+
storage.setItem<string>(SYNC_KEY_SETTINGS_ACTIVATION_KEY, e.target.value.toLowerCase() || '');
30+
}, []);
31+
32+
const handleModeChange = useCallback(
33+
(mode: TranslateMode) => () => {
34+
storage.setItem<string>(SYNC_KEY_SETTINGS_MODE, mode);
35+
},
36+
[]
37+
);
38+
39+
return (
40+
<form
41+
method="dialog"
42+
onSubmit={(e) => e.preventDefault()}
43+
className="display-flex position-relative flex-direction-column"
44+
>
45+
<div className="display-flex padding-x-m">
46+
<p style={{ marginBottom: '0.5em' }}>
47+
Translated subtitles by{' '}
48+
<Link href="https://github.com/rioam2/nrktv-dual-subs">
49+
nrktv-dual-subs
50+
<Heart size={16} weight="light" />
51+
</Link>
52+
</p>
53+
</div>
54+
<TabList>
55+
{Object.values(MODES).map((mode) => (
56+
<Tab
57+
key={mode.value}
58+
title={mode.description}
59+
aria-label={mode.description}
60+
data-selected={mode.value === currentMode}
61+
aria-selected={mode.value === currentMode}
62+
onClick={handleModeChange(mode.value)}
63+
>
64+
<mode.icon size={24} />
65+
{mode.name}
66+
</Tab>
67+
))}
68+
</TabList>
69+
{currentMode === TranslateMode.KeyPress && (
70+
<div className="padding-x-m">
71+
<Select value={currentActivationKey} onChange={handleActivationKeyChange}>
72+
{Array.from({ length: 26 }).map((_, idx) => {
73+
const charCode = 65 + idx;
74+
const char = String.fromCharCode(charCode).toLowerCase();
75+
return (
76+
<option key={char} value={char}>
77+
{char}
78+
</option>
79+
);
80+
})}
81+
</Select>
82+
</div>
83+
)}
84+
{currentMode !== TranslateMode.Disabled && (
85+
<div className="padding-x-m">
86+
<Select value={currentLanguage} onChange={handleLanguageChange}>
87+
{LANGUAGES.map((lang) => (
88+
<option key={lang.value} value={lang.value}>
89+
{lang.name}
90+
</option>
91+
))}
92+
</Select>
93+
</div>
94+
)}
95+
</form>
96+
);
97+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export const Link: React.FC<React.AnchorHTMLAttributes<HTMLAnchorElement>> = (props) => {
2+
return (
3+
<a
4+
rel="noopener"
5+
target="_blank"
6+
style={{
7+
color: 'inherit',
8+
textDecorationStyle: 'dotted',
9+
fontStyle: 'italic',
10+
display: 'inline-flex',
11+
alignItems: 'center',
12+
gap: 4
13+
}}
14+
{...props}
15+
>
16+
{props.children}
17+
</a>
18+
);
19+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export const Select: React.FC<React.SelectHTMLAttributes<HTMLSelectElement>> = (props) => {
2+
return (
3+
<select
4+
className="nrk-input"
5+
style={{
6+
width: '100%',
7+
backgroundColor: 'transparent',
8+
color: 'inherit',
9+
border: '1px solid #f0f0f0',
10+
padding: '0.5em 1em',
11+
borderRadius: '9999px',
12+
marginBottom: '1.5em',
13+
backgroundImage:
14+
'linear-gradient(45deg, rgba(0, 0, 0, 0) 46%, currentcolor 47%, currentcolor 49%, rgba(0, 0, 0, 0) 51%), linear-gradient(-45deg, rgba(0, 0, 0, 0) 46%, currentcolor 47%, currentcolor 49%, rgba(0, 0, 0, 0) 51%)',
15+
backgroundSize: '.36em 100%, .36em 100%',
16+
backgroundRepeat: 'no-repeat',
17+
backgroundPositionX: 'calc(100% - 1.35em), calc(100% - 1em)',
18+
backgroundPositionY: 0,
19+
WebkitAppearance: 'none',
20+
MozAppearance: 'none'
21+
}}
22+
{...props}
23+
>
24+
{props.children}
25+
</select>
26+
);
27+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const Tab: React.FC<React.SelectHTMLAttributes<HTMLButtonElement>> = (props) => {
2+
return (
3+
<button
4+
type="button"
5+
role="tab"
6+
autoFocus
7+
aria-controls="player-settings-dialog-subtitles-tabpanel"
8+
className="selectable-button text-style-subhead1 selectable-button--icon-left margin-y-xxs"
9+
{...props}
10+
>
11+
{props.children}
12+
</button>
13+
);
14+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const TabList: React.FC<React.PropsWithChildren> = (props) => {
2+
return (
3+
<>
4+
{/* @ts-expect-error */}
5+
<player-settings-dialog-tabs
6+
className="display-block width-100% position-relative margin-bottom-m max-width-100%"
7+
style={{
8+
'--horizontal-list-gap': 0,
9+
'--horizontal-list-padding-left': 'var(--size-m)',
10+
'--horizontal-list-padding-right': 'var(--size-m)'
11+
}}
12+
role="tablist"
13+
>
14+
<div data-horizontal-list>
15+
{/* @ts-expect-error */}
16+
<u-tablist
17+
data-scroll-container
18+
role="tablist"
19+
className="display-grid grid-auto-flow-column overflow-auto overflow-y-hidden scrollbar-width-none"
20+
style={{
21+
'--horizontal-list-number-of-items': Array.isArray(props.children) ? props.children.length : 0,
22+
width: '100%',
23+
maxWidth: '500px',
24+
display: 'flex',
25+
flexWrap: 'wrap',
26+
gap: 4
27+
}}
28+
>
29+
{props.children}
30+
{/* @ts-expect-error */}
31+
</u-tablist>
32+
</div>
33+
{/* @ts-expect-error */}
34+
</player-settings-dialog-tabs>
35+
</>
36+
);
37+
};

0 commit comments

Comments
 (0)