diff --git a/packages/react/src/components/layouts/default/ui/menus/font-menu.tsx b/packages/react/src/components/layouts/default/ui/menus/font-menu.tsx index 234c31bfa..816efdf6e 100644 --- a/packages/react/src/components/layouts/default/ui/menus/font-menu.tsx +++ b/packages/react/src/components/layouts/default/ui/menus/font-menu.tsx @@ -1,10 +1,22 @@ import * as React from 'react'; -import type { DefaultLayoutTranslations } from 'vidstack'; +import { useSignal } from 'maverick.js/react'; +import { camelToKebabCase } from 'maverick.js/std'; +import { + FONT_COLOR_OPTION, + FONT_FAMILY_OPTION, + FONT_OPACITY_OPTION, + FONT_SIGNALS, + FONT_SIZE_OPTION, + FONT_TEXT_SHADOW_OPTION, + onFontReset, + type DefaultFontSettingProps, + type FontRadioOption, + type FontSliderOption, +} from 'vidstack'; import { useMediaPlayer } from '../../../../../hooks/use-media-player'; import { useMediaState } from '../../../../../hooks/use-media-state'; -import type { MediaPlayerInstance } from '../../../../primitives/instances'; import * as Menu from '../../../../ui/menu'; import * as Slider from '../../../../ui/sliders/slider'; import { i18n, useDefaultLayoutContext, useDefaultLayoutWord } from '../../context'; @@ -18,49 +30,6 @@ import { } from './items/menu-items'; import { DefaultMenuSliderItem, DefaultSliderParts, DefaultSliderSteps } from './items/menu-slider'; -/* ------------------------------------------------------------------------------------------------- - * Options - * -----------------------------------------------------------------------------------------------*/ - -const COLOR_OPTION = { - type: 'color', - } as const, - FONT_FAMILY_OPTION = { - type: 'radio', - values: { - 'Monospaced Serif': 'mono-serif', - 'Proportional Serif': 'pro-serif', - 'Monospaced Sans-Serif': 'mono-sans', - 'Proportional Sans-Serif': 'pro-sans', - Casual: 'casual', - Cursive: 'cursive', - 'Small Capitals': 'capitals', - }, - } as const, - FONT_SIZE_OPTION = { - type: 'slider', - min: 0, - max: 400, - step: 25, - } as const, - OPACITY_OPTION = { - type: 'slider', - min: 0, - max: 100, - step: 5, - } as const, - TEXT_SHADOW_OPTION = { - type: 'radio', - values: ['None', 'Drop Shadow', 'Raised', 'Depressed', 'Outline'] as string[], - } as const; - -interface FontReset { - all: Set<() => void>; -} - -const FontResetContext = React.createContext({ all: new Set() }); -FontResetContext.displayName = 'FontResetContext'; - /* ------------------------------------------------------------------------------------------------- * DefaultFontMenu * -----------------------------------------------------------------------------------------------*/ @@ -68,7 +37,6 @@ FontResetContext.displayName = 'FontResetContext'; function DefaultFontMenu() { const label = useDefaultLayoutWord('Caption Styles'), $hasCaptions = useMediaState('hasCaptions'), - resets = React.useMemo(() => ({ all: new Set() }), []), fontSectionLabel = useDefaultLayoutWord('Font'), textSectionLabel = useDefaultLayoutWord('Text'), textBgSectionLabel = useDefaultLayoutWord('Text Background'), @@ -77,37 +45,35 @@ function DefaultFontMenu() { if (!$hasCaptions) return null; return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } @@ -119,22 +85,7 @@ export { DefaultFontMenu }; * -----------------------------------------------------------------------------------------------*/ function DefaultFontFamilyMenu() { - return ( - - ); -} - -function getFontFamilyCSSVarValue(value: string, player: MediaPlayerInstance) { - const fontVariant = value === 'capitals' ? 'small-caps' : ''; - player.el?.style.setProperty('--media-user-font-variant', fontVariant); - return getFontFamily(value); + return ; } DefaultFontFamilyMenu.displayName = 'DefaultFontFamilyMenu'; @@ -144,21 +95,14 @@ DefaultFontFamilyMenu.displayName = 'DefaultFontFamilyMenu'; * -----------------------------------------------------------------------------------------------*/ function DefaultFontSizeSlider() { - const { icons: Icons } = useDefaultLayoutContext(); - return ( - - ); + const { icons: Icons } = useDefaultLayoutContext(), + option: FontSliderOption = { + ...FONT_SIZE_OPTION, + upIcon: Icons.Menu.FontSizeUp, + downIcon: Icons.Menu.FontSizeDown, + }; + + return ; } DefaultFontSizeSlider.displayName = 'DefaultFontSizeSlider'; @@ -168,18 +112,7 @@ DefaultFontSizeSlider.displayName = 'DefaultFontSizeSlider'; * -----------------------------------------------------------------------------------------------*/ function DefaultTextColorInput() { - return ( - { - return `rgb(${hexToRGB(value)} / var(--media-user-text-opacity, 1))`; - }} - /> - ); + return ; } DefaultTextColorInput.displayName = 'DefaultTextColorInput'; @@ -189,21 +122,13 @@ DefaultTextColorInput.displayName = 'DefaultTextColorInput'; * -----------------------------------------------------------------------------------------------*/ function DefaultTextOpacitySlider() { - const { icons: Icons } = useDefaultLayoutContext(); - return ( - - ); + const { icons: Icons } = useDefaultLayoutContext(), + option = { + ...FONT_OPACITY_OPTION, + upIcon: Icons.Menu.OpacityUp, + downIcon: Icons.Menu.OpacityDown, + }; + return ; } DefaultTextOpacitySlider.displayName = 'DefaultTextOpacitySlider'; @@ -213,16 +138,7 @@ DefaultTextOpacitySlider.displayName = 'DefaultTextOpacitySlider'; * -----------------------------------------------------------------------------------------------*/ function DefaultTextShadowMenu() { - return ( - - ); + return ; } DefaultTextShadowMenu.displayName = 'DefaultTextShadowMenu'; @@ -232,18 +148,7 @@ DefaultTextShadowMenu.displayName = 'DefaultTextShadowMenu'; * -----------------------------------------------------------------------------------------------*/ function DefaultTextBgInput() { - return ( - { - return `rgb(${hexToRGB(value)} / var(--media-user-text-bg-opacity, 1))`; - }} - /> - ); + return ; } DefaultTextBgInput.displayName = 'DefaultTextBgInput'; @@ -253,17 +158,14 @@ DefaultTextBgInput.displayName = 'DefaultTextBgInput'; * -----------------------------------------------------------------------------------------------*/ function DefaultTextBgOpacitySlider() { - const { icons: Icons } = useDefaultLayoutContext(); - return ( - - ); + const { icons: Icons } = useDefaultLayoutContext(), + option = { + ...FONT_OPACITY_OPTION, + upIcon: Icons.Menu.OpacityUp, + downIcon: Icons.Menu.OpacityDown, + }; + + return ; } DefaultTextBgOpacitySlider.displayName = 'DefaultTextBgOpacitySlider'; @@ -273,18 +175,7 @@ DefaultTextBgOpacitySlider.displayName = 'DefaultTextBgOpacitySlider'; * -----------------------------------------------------------------------------------------------*/ function DefaultDisplayBgInput() { - return ( - { - return `rgb(${hexToRGB(value)} / var(--media-user-display-bg-opacity, 1))`; - }} - /> - ); + return ; } DefaultDisplayBgInput.displayName = 'DefaultDisplayBgInput'; @@ -294,21 +185,14 @@ DefaultDisplayBgInput.displayName = 'DefaultDisplayBgInput'; * -----------------------------------------------------------------------------------------------*/ function DefaultDisplayBgOpacitySlider() { - const { icons: Icons } = useDefaultLayoutContext(); - return ( - - ); + const { icons: Icons } = useDefaultLayoutContext(), + option = { + ...FONT_OPACITY_OPTION, + upIcon: Icons.Menu.OpacityUp, + downIcon: Icons.Menu.OpacityDown, + }; + + return ; } DefaultDisplayBgOpacitySlider.displayName = 'DefaultDisplayBgOpacitySlider'; @@ -317,61 +201,11 @@ DefaultDisplayBgOpacitySlider.displayName = 'DefaultDisplayBgOpacitySlider'; * DefaultFontSetting * -----------------------------------------------------------------------------------------------*/ -interface FontRadioOption { - type: 'radio'; - values: string[] | Record; -} - -interface FontSliderOption { - type: 'slider'; - min: number; - max: number; - step: number; - UpIcon?: DefaultLayoutIcon; - DownIcon?: DefaultLayoutIcon; -} - -interface FontColorOption { - type: 'color'; -} - -type FontOption = FontRadioOption | FontSliderOption | FontColorOption; - -interface DefaultFontSettingProps { - group: string; - label: keyof DefaultLayoutTranslations; - option: FontOption; - cssVarName: string; - getCssVarValue?(value: string, player: MediaPlayerInstance): string; - defaultValue: string; -} - -function DefaultFontSetting({ - group, - label, - option, - cssVarName, - getCssVarValue, - defaultValue, -}: DefaultFontSettingProps) { +function DefaultFontSetting({ label, option, type }: DefaultFontSettingProps) { const player = useMediaPlayer(), - id = `${group}-${label.toLowerCase()}`, - translatedLabel = useDefaultLayoutWord(label), - resets = React.useContext(FontResetContext); - - const [value, setValue] = React.useState(defaultValue); - - const update = React.useCallback( - (newValue: string) => { - setValue(newValue); - localStorage.setItem(`vds-player:${id}`, newValue); - player?.el?.style.setProperty( - `--media-user-${cssVarName}`, - getCssVarValue?.(newValue, player) ?? newValue, - ); - }, - [player], - ); + $currentValue = FONT_SIGNALS[type], + $value = useSignal($currentValue), + translatedLabel = useDefaultLayoutWord(label); const notify = React.useCallback(() => { player?.dispatchEvent(new Event('vds-font-change')); @@ -379,26 +213,12 @@ function DefaultFontSetting({ const onChange = React.useCallback( (newValue: string) => { - update(newValue); + $currentValue.set(newValue); notify(); }, - [update, notify], + [$currentValue, notify], ); - const onReset = React.useCallback(() => { - onChange(defaultValue); - }, [onChange]); - - React.useEffect(() => { - const savedValue = localStorage.getItem(`vds-player:${id}`); - if (savedValue) update(savedValue); - }, []); - - React.useEffect(() => { - resets.all.add(onReset); - return () => void resets.all.delete(onReset); - }, [onReset]); - if (option.type === 'color') { function onColorChange(event) { onChange(event.target.value); @@ -406,13 +226,13 @@ function DefaultFontSetting({ return ( - + ); } if (option.type === 'slider') { - const { min, max, step, UpIcon, DownIcon } = option; + const { min, max, step, upIcon, downIcon } = option; function onSliderValueChange(value) { onChange(value + '%'); @@ -421,11 +241,11 @@ function DefaultFontSetting({ return ( @@ -499,66 +319,13 @@ DefaultFontRadioGroup.displayName = 'DefaultFontRadioGroup'; * -----------------------------------------------------------------------------------------------*/ function DefaultResetMenuItem() { - const resetText = useDefaultLayoutWord('Reset'), - resets = React.useContext(FontResetContext); - - function onClick() { - resets.all.forEach((reset) => reset()); - } + const resetText = useDefaultLayoutWord('Reset'); return ( - ); } DefaultResetMenuItem.displayName = 'DefaultResetMenuItem'; - -/* ------------------------------------------------------------------------------------------------- - * Utils - * -----------------------------------------------------------------------------------------------*/ - -function percentToRatio(value: string) { - return (parseInt(value) / 100).toString(); -} - -function hexToRGB(hex: string) { - const { style } = new Option(); - style.color = hex; - return style.color.match(/\((.*?)\)/)![1].replace(/,/g, ' '); -} - -function getFontFamily(value: string) { - switch (value) { - case 'mono-serif': - return '"Courier New", Courier, "Nimbus Mono L", "Cutive Mono", monospace'; - case 'mono-sans': - return '"Deja Vu Sans Mono", "Lucida Console", Monaco, Consolas, "PT Mono", monospace'; - case 'pro-sans': - return 'Roboto, "Arial Unicode Ms", Arial, Helvetica, Verdana, "PT Sans Caption", sans-serif'; - case 'casual': - return '"Comic Sans MS", Impact, Handlee, fantasy'; - case 'cursive': - return '"Monotype Corsiva", "URW Chancery L", "Apple Chancery", "Dancing Script", cursive'; - case 'capitals': - return '"Arial Unicode Ms", Arial, Helvetica, Verdana, "Marcellus SC", sans-serif + font-variant=small-caps'; - default: - return '"Times New Roman", Times, Georgia, Cambria, "PT Serif Caption", serif'; - } -} - -function getTextShadowCssVarValue(value: string) { - switch (value) { - case 'drop shadow': - return 'rgb(34, 34, 34) 1.86389px 1.86389px 2.79583px, rgb(34, 34, 34) 1.86389px 1.86389px 3.72778px, rgb(34, 34, 34) 1.86389px 1.86389px 4.65972px'; - case 'raised': - return 'rgb(34, 34, 34) 1px 1px, rgb(34, 34, 34) 2px 2px'; - case 'depressed': - return 'rgb(204, 204, 204) 1px 1px, rgb(34, 34, 34) -1px -1px'; - case 'outline': - return 'rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px'; - default: - return ''; - } -} diff --git a/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx b/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx index feaeed343..313c69a21 100644 --- a/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx +++ b/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import { flushSync } from 'react-dom'; +import { updateFontCssVars } from 'vidstack'; import { useMediaState } from '../../../../../hooks/use-media-state'; +import { useScoped } from '../../../../../hooks/use-signals'; import * as Menu from '../../../../ui/menu'; import type * as Tooltip from '../../../../ui/tooltip'; import { useDefaultLayoutContext, useDefaultLayoutWord } from '../../context'; @@ -41,6 +43,8 @@ function DefaultSettingsMenu({ colorSchemeClass = useColorSchemeClass(colorScheme), [isOpen, setIsOpen] = React.useState(false); + useScoped(updateFontCssVars); + function onOpen() { flushSync(() => { setIsOpen(true); diff --git a/packages/vidstack/src/core/font/font-options.ts b/packages/vidstack/src/core/font/font-options.ts new file mode 100644 index 000000000..de38b1b01 --- /dev/null +++ b/packages/vidstack/src/core/font/font-options.ts @@ -0,0 +1,106 @@ +import { signal, type WriteSignal } from 'maverick.js'; +import { camelToKebabCase, isString } from 'maverick.js/std'; + +import type { DefaultLayoutTranslations } from '../../components'; + +export const FONT_COLOR_OPTION: FontOption = { + type: 'color', +}; + +export const FONT_FAMILY_OPTION: FontOption = { + type: 'radio', + values: { + 'Monospaced Serif': 'mono-serif', + 'Proportional Serif': 'pro-serif', + 'Monospaced Sans-Serif': 'mono-sans', + 'Proportional Sans-Serif': 'pro-sans', + Casual: 'casual', + Cursive: 'cursive', + 'Small Capitals': 'capitals', + }, +}; + +export const FONT_SIZE_OPTION: FontSliderOption = { + type: 'slider', + min: 0, + max: 400, + step: 25, + upIcon: null, + downIcon: null, +}; + +export const FONT_OPACITY_OPTION: FontSliderOption = { + type: 'slider', + min: 0, + max: 100, + step: 5, + upIcon: null, + downIcon: null, +}; + +export const FONT_TEXT_SHADOW_OPTION: FontOption = { + type: 'radio', + values: ['None', 'Drop Shadow', 'Raised', 'Depressed', 'Outline'], +}; + +export const FONT_DEFAULTS = { + fontFamily: 'pro-sans', + fontSize: '100%', + textColor: '#ffffff', + textOpacity: '100%', + textShadow: 'none', + textBg: '#000000', + textBgOpacity: '100%', + displayBg: '#000000', + displayBgOpacity: '0%', +} as const; + +export const FONT_SIGNALS = Object.keys(FONT_DEFAULTS).reduce( + (prev, type) => ({ + ...prev, + [type]: signal(FONT_DEFAULTS[type]), + }), + {} as Record>, +); + +export type FontSignal = keyof typeof FONT_DEFAULTS; + +if (!__SERVER__) { + for (const type of Object.keys(FONT_SIGNALS)) { + const value = localStorage.getItem(`vds-player:${camelToKebabCase(type)}`); + if (isString(value)) FONT_SIGNALS[type].set(value); + } +} + +export function onFontReset() { + for (const type of Object.keys(FONT_SIGNALS)) { + const defaultValue = FONT_DEFAULTS[type]; + FONT_SIGNALS[type].set(defaultValue); + } +} + +export interface FontRadioOption { + type: 'radio'; + values: string[] | Record; +} + +export interface FontSliderOption { + type: 'slider'; + min: number; + max: number; + step: number; + upIcon: unknown; + downIcon: unknown; +} + +export interface FontColorOption { + type: 'color'; +} + +export type FontOption = FontRadioOption | FontSliderOption | FontColorOption; + +export interface DefaultFontSettingProps { + label: keyof DefaultLayoutTranslations; + type: FontSignal; + option: FontOption; +} diff --git a/packages/vidstack/src/core/font/font-vars.ts b/packages/vidstack/src/core/font/font-vars.ts new file mode 100644 index 000000000..c0a532ac2 --- /dev/null +++ b/packages/vidstack/src/core/font/font-vars.ts @@ -0,0 +1,107 @@ +import { effect, onDispose, scoped } from 'maverick.js'; +import { camelToKebabCase, keysOf } from 'maverick.js/std'; + +import type { MediaPlayer } from '../../components'; +import { hexToRgb } from '../../utils/color'; +import { useMediaContext } from '../api/media-context'; +import { FONT_DEFAULTS, FONT_SIGNALS, type FontSignal } from './font-options'; + +let isWatchingVars = false, + players = new Set(); + +export function updateFontCssVars() { + if (__SERVER__) return; + + const { player } = useMediaContext(); + players.add(player); + onDispose(() => players.delete(player)); + + if (!isWatchingVars) { + scoped(() => { + for (const type of keysOf(FONT_SIGNALS)) { + const $value = FONT_SIGNALS[type], + defaultValue = FONT_DEFAULTS[type], + varName = `--media-user-${camelToKebabCase(type)}`, + storageKey = `vds-player:${camelToKebabCase(type)}`; + + effect(() => { + const value = $value(), + isDefaultVarValue = value === defaultValue, + varValue = !isDefaultVarValue ? getCssVarValue(player, type, value) : null; + + for (const player of players) { + player.el?.style.setProperty(varName, varValue); + } + + if (isDefaultVarValue) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, value); + } + }); + } + }, null); + + isWatchingVars = true; + } +} + +function getCssVarValue(player: MediaPlayer, type: FontSignal, value: string) { + switch (type) { + case 'fontFamily': + const fontVariant = value === 'capitals' ? 'small-caps' : ''; + player.el?.style.setProperty('--media-user-font-variant', fontVariant); + return getFontFamilyCSSVarValue(value); + case 'fontSize': + case 'textOpacity': + case 'textBgOpacity': + case 'displayBgOpacity': + return percentToRatio(value); + case 'textColor': + return `rgb(${hexToRgb(value)} / var(--media-user-text-opacity, 1))`; + case 'textShadow': + return getTextShadowCssVarValue(value); + case 'textBg': + return `rgb(${hexToRgb(value)} / var(--media-user-text-bg-opacity, 1))`; + case 'displayBg': + return `rgb(${hexToRgb(value)} / var(--media-user-display-bg-opacity, 1))`; + } +} + +function percentToRatio(value: string) { + return (parseInt(value) / 100).toString(); +} + +function getFontFamilyCSSVarValue(value: string) { + switch (value) { + case 'mono-serif': + return '"Courier New", Courier, "Nimbus Mono L", "Cutive Mono", monospace'; + case 'mono-sans': + return '"Deja Vu Sans Mono", "Lucida Console", Monaco, Consolas, "PT Mono", monospace'; + case 'pro-sans': + return 'Roboto, "Arial Unicode Ms", Arial, Helvetica, Verdana, "PT Sans Caption", sans-serif'; + case 'casual': + return '"Comic Sans MS", Impact, Handlee, fantasy'; + case 'cursive': + return '"Monotype Corsiva", "URW Chancery L", "Apple Chancery", "Dancing Script", cursive'; + case 'capitals': + return '"Arial Unicode Ms", Arial, Helvetica, Verdana, "Marcellus SC", sans-serif + font-variant=small-caps'; + default: + return '"Times New Roman", Times, Georgia, Cambria, "PT Serif Caption", serif'; + } +} + +function getTextShadowCssVarValue(value: string) { + switch (value) { + case 'drop shadow': + return 'rgb(34, 34, 34) 1.86389px 1.86389px 2.79583px, rgb(34, 34, 34) 1.86389px 1.86389px 3.72778px, rgb(34, 34, 34) 1.86389px 1.86389px 4.65972px'; + case 'raised': + return 'rgb(34, 34, 34) 1px 1px, rgb(34, 34, 34) 2px 2px'; + case 'depressed': + return 'rgb(204, 204, 204) 1px 1px, rgb(34, 34, 34) -1px -1px'; + case 'outline': + return 'rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px'; + default: + return ''; + } +} diff --git a/packages/vidstack/src/core/index.ts b/packages/vidstack/src/core/index.ts index 032c415b7..9c295f678 100644 --- a/packages/vidstack/src/core/index.ts +++ b/packages/vidstack/src/core/index.ts @@ -28,3 +28,7 @@ export * from './quality/utils'; export type * from './keyboard/types'; export { MEDIA_KEY_SHORTCUTS } from './keyboard/controller'; export { ARIAKeyShortcuts } from './keyboard/aria-shortcuts'; + +// Font +export * from './font/font-options'; +export { updateFontCssVars } from './font/font-vars'; diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/menu/font-menu.ts b/packages/vidstack/src/elements/define/layouts/default/ui/menu/font-menu.ts index 2ae966a8a..98dbdd97f 100644 --- a/packages/vidstack/src/elements/define/layouts/default/ui/menu/font-menu.ts +++ b/packages/vidstack/src/elements/define/layouts/default/ui/menu/font-menu.ts @@ -1,12 +1,20 @@ import { html } from 'lit-html'; -import { createContext, onDispose, provideContext, signal, useContext } from 'maverick.js'; -import { isString } from 'maverick.js/std'; +import { tick } from 'maverick.js'; +import { camelToKebabCase, isString } from 'maverick.js/std'; -import { type DefaultLayoutTranslations, type MediaPlayer } from '../../../../../../components'; import { useDefaultLayoutContext } from '../../../../../../components/layouts/default/context'; import { i18n } from '../../../../../../components/layouts/default/translations'; import { useMediaContext, useMediaState } from '../../../../../../core/api/media-context'; -import { hexToRgb } from '../../../../../../utils/color'; +import { + FONT_COLOR_OPTION, + FONT_FAMILY_OPTION, + FONT_OPACITY_OPTION, + FONT_SIGNALS, + FONT_SIZE_OPTION, + FONT_TEXT_SHADOW_OPTION, + onFontReset, + type DefaultFontSettingProps, +} from '../../../../../../core/font/font-options'; import { $signal } from '../../../../../lit/directives/signal'; import { $i18n } from '../utils'; import { @@ -18,45 +26,17 @@ import { } from './items/menu-items'; import { DefaultMenuSliderItem, DefaultSliderParts, DefaultSliderSteps } from './items/menu-slider'; -const COLOR_OPTION: FontOption = { - type: 'color', - }, - FONT_FAMILY_OPTION: FontOption = { - type: 'radio', - values: { - 'Monospaced Serif': 'mono-serif', - 'Proportional Serif': 'pro-serif', - 'Monospaced Sans-Serif': 'mono-sans', - 'Proportional Sans-Serif': 'pro-sans', - Casual: 'casual', - Cursive: 'cursive', - 'Small Capitals': 'capitals', - }, - }, - FONT_SIZE_OPTION: FontOption = { - type: 'slider', - min: 0, - max: 400, - step: 25, - upIcon: 'menu-font-size-up', - downIcon: 'menu-font-size-down', - }, - OPACITY_OPTION: FontOption = { - type: 'slider', - min: 0, - max: 100, - step: 5, - upIcon: 'menu-opacity-up', - downIcon: 'menu-opacity-down', - }, - TEXT_SHADOW_OPTION: FontOption = { - type: 'radio', - values: ['None', 'Drop Shadow', 'Raised', 'Depressed', 'Outline'], - }; +const FONT_SIZE_OPTION_WITH_ICONS = { + ...FONT_SIZE_OPTION, + upIcon: 'menu-opacity-up', + downIcon: 'menu-opacity-down', +}; -const resetContext = createContext<{ - all: Set<() => void>; -}>(); +const FONT_OPACITY_OPTION_WITH_ICONS = { + ...FONT_OPACITY_OPTION, + upIcon: 'menu-opacity-up', + downIcon: 'menu-opacity-down', +}; export function DefaultFontMenu() { return $signal(() => { @@ -65,10 +45,6 @@ export function DefaultFontMenu() { if (!hasCaptions()) return null; - provideContext(resetContext, { - all: new Set<() => void>(), - }); - return html` ${DefaultMenuButton({ @@ -108,200 +84,101 @@ export function DefaultFontMenu() { function DefaultFontFamilyMenu() { return DefaultFontSetting({ - group: 'font', label: 'Family', option: FONT_FAMILY_OPTION, - defaultValue: 'pro-sans', - cssVarName: 'font-family', - getCssVarValue(value, player) { - const fontVariant = value === 'capitals' ? 'small-caps' : ''; - player.el?.style.setProperty('--media-user-font-variant', fontVariant); - return getFontFamilyCSSVarValue(value); - }, + type: 'fontFamily', }); } function DefaultFontSizeSlider() { return DefaultFontSetting({ - group: 'font', label: 'Size', - option: FONT_SIZE_OPTION, - defaultValue: '100%', - cssVarName: 'font-size', - getCssVarValue: percentToRatio, + option: FONT_SIZE_OPTION_WITH_ICONS, + type: 'fontSize', }); } function DefaultTextColorInput() { return DefaultFontSetting({ - group: 'text', label: 'Color', - option: COLOR_OPTION, - defaultValue: '#ffffff', - cssVarName: 'text-color', - getCssVarValue(value) { - return `rgb(${hexToRgb(value)} / var(--media-user-text-opacity, 1))`; - }, + option: FONT_COLOR_OPTION, + type: 'textColor', }); } function DefaultTextOpacitySlider() { return DefaultFontSetting({ - group: 'text', label: 'Opacity', - option: OPACITY_OPTION, - defaultValue: '100%', - cssVarName: 'text-opacity', - getCssVarValue: percentToRatio, + option: FONT_OPACITY_OPTION_WITH_ICONS, + type: 'textOpacity', }); } function DefaultTextShadowMenu() { return DefaultFontSetting({ - group: 'text', label: 'Shadow', - option: TEXT_SHADOW_OPTION, - defaultValue: 'none', - cssVarName: 'text-shadow', - getCssVarValue: getTextShadowCssVarValue, + option: FONT_TEXT_SHADOW_OPTION, + type: 'textShadow', }); } function DefaultTextBgInput() { return DefaultFontSetting({ - group: 'text-bg', label: 'Color', - option: COLOR_OPTION, - defaultValue: '#000000', - cssVarName: 'text-bg', - getCssVarValue(value) { - return `rgb(${hexToRgb(value)} / var(--media-user-text-bg-opacity, 1))`; - }, + option: FONT_COLOR_OPTION, + type: 'textBg', }); } function DefaultTextBgOpacitySlider() { return DefaultFontSetting({ - group: 'text-bg', label: 'Opacity', - option: OPACITY_OPTION, - defaultValue: '100%', - cssVarName: 'text-bg-opacity', - getCssVarValue: percentToRatio, + option: FONT_OPACITY_OPTION_WITH_ICONS, + type: 'textBgOpacity', }); } function DefaultDisplayBgInput() { return DefaultFontSetting({ - group: 'display', label: 'Color', - option: COLOR_OPTION, - defaultValue: '#000000', - cssVarName: 'display-bg', - getCssVarValue(value) { - return `rgb(${hexToRgb(value)} / var(--media-user-display-bg-opacity, 1))`; - }, + option: FONT_COLOR_OPTION, + type: 'displayBg', }); } function DefaultDisplayOpacitySlider() { return DefaultFontSetting({ - group: 'display', label: 'Opacity', - option: OPACITY_OPTION, - defaultValue: '0%', - cssVarName: 'display-bg-opacity', - getCssVarValue: percentToRatio, + option: FONT_OPACITY_OPTION_WITH_ICONS, + type: 'displayBgOpacity', }); } function DefaultResetMenuItem() { const { translations } = useDefaultLayoutContext(), - $label = () => i18n(translations, 'Reset'), - resets = useContext(resetContext); - - function onClick() { - resets.all.forEach((reset) => reset()); - } + $label = () => i18n(translations, 'Reset'); return html` - `; } -interface FontRadioOption { - type: 'radio'; - values: string[] | Record; -} - -interface FontSliderOption { - type: 'slider'; - min: number; - max: number; - step: number; - upIcon: string; - downIcon: string; -} - -interface FontColorOption { - type: 'color'; -} - -type FontOption = FontRadioOption | FontSliderOption | FontColorOption; - -interface DefaultFontSettingProps { - group: string; - label: keyof DefaultLayoutTranslations; - option: FontOption; - cssVarName: string; - getCssVarValue?(value: string, player: MediaPlayer): string; - defaultValue: string; -} - -function DefaultFontSetting({ - group, - label, - option, - defaultValue, - cssVarName, - getCssVarValue, -}: DefaultFontSettingProps) { +function DefaultFontSetting({ label, option, type }: DefaultFontSettingProps) { const { player } = useMediaContext(), { translations } = useDefaultLayoutContext(), - resets = useContext(resetContext), - key = `${group}-${label.toLowerCase()}`, - $value = signal(defaultValue), + $currentValue = FONT_SIGNALS[type], $label = () => i18n(translations, label); - const savedValue = localStorage.getItem(`vds-player:${key}`); - if (savedValue) onValueChange(savedValue); - - function onValueChange(value: string) { - $value.set(value); - localStorage.setItem(`vds-player:${key}`, value); - player.el?.style.setProperty( - `--media-user-${cssVarName}`, - getCssVarValue?.(value, player) ?? value, - ); - } - - resets.all.add(onReset); - onDispose(() => void resets.all.delete(onReset)); - - function onReset() { - onValueChange(defaultValue); - notify(); - } - function notify() { + tick(); player.dispatchEvent(new Event('vds-font-change')); } if (option.type === 'color') { function onColorChange(event) { - onValueChange(event.target.value); + $currentValue.set(event.target.value); notify(); } @@ -311,7 +188,7 @@ function DefaultFontSetting({ `, @@ -322,17 +199,17 @@ function DefaultFontSetting({ const { min, max, step, upIcon, downIcon } = option; function onSliderValueChange(event) { - onValueChange(event.detail + '%'); + $currentValue.set(event.detail + '%'); notify(); } return DefaultMenuSliderItem({ label: $signal($label), - value: $signal($value), + value: $signal($currentValue), upIcon, downIcon, - isMin: () => $value() === min + '%', - isMax: () => $value() === max + '%', + isMin: () => $currentValue() === min + '%', + isMax: () => $currentValue() === max + '%', children: html` parseInt($value()))} + .value=${$signal(() => parseInt($currentValue()))} aria-label=${$signal($label)} @value-change=${onSliderValueChange} @drag-value-change=${onSliderValueChange} @@ -353,20 +230,20 @@ function DefaultFontSetting({ const radioOptions = createRadioOptions(option.values), $hint = () => { - const value = $value(), + const value = $currentValue(), label = radioOptions.find((radio) => radio.value === value)?.label || ''; return i18n(translations, isString(label) ? label : label()); }; return html` - + ${DefaultMenuButton({ label: $label, hint: $hint })} ${DefaultRadioGroup({ - value: $value, + value: $currentValue, options: radioOptions, onChange({ detail: value }) { - onValueChange(value); + $currentValue.set(value); notify(); }, })} @@ -374,41 +251,3 @@ function DefaultFontSetting({ `; } - -function percentToRatio(value: string) { - return (parseInt(value) / 100).toString(); -} - -function getFontFamilyCSSVarValue(value: string) { - switch (value) { - case 'mono-serif': - return '"Courier New", Courier, "Nimbus Mono L", "Cutive Mono", monospace'; - case 'mono-sans': - return '"Deja Vu Sans Mono", "Lucida Console", Monaco, Consolas, "PT Mono", monospace'; - case 'pro-sans': - return 'Roboto, "Arial Unicode Ms", Arial, Helvetica, Verdana, "PT Sans Caption", sans-serif'; - case 'casual': - return '"Comic Sans MS", Impact, Handlee, fantasy'; - case 'cursive': - return '"Monotype Corsiva", "URW Chancery L", "Apple Chancery", "Dancing Script", cursive'; - case 'capitals': - return '"Arial Unicode Ms", Arial, Helvetica, Verdana, "Marcellus SC", sans-serif + font-variant=small-caps'; - default: - return '"Times New Roman", Times, Georgia, Cambria, "PT Serif Caption", serif'; - } -} - -function getTextShadowCssVarValue(value: string) { - switch (value) { - case 'drop shadow': - return 'rgb(34, 34, 34) 1.86389px 1.86389px 2.79583px, rgb(34, 34, 34) 1.86389px 1.86389px 3.72778px, rgb(34, 34, 34) 1.86389px 1.86389px 4.65972px'; - case 'raised': - return 'rgb(34, 34, 34) 1px 1px, rgb(34, 34, 34) 2px 2px'; - case 'depressed': - return 'rgb(204, 204, 204) 1px 1px, rgb(34, 34, 34) -1px -1px'; - case 'outline': - return 'rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px, rgb(34, 34, 34) 0px 0px 1.86389px'; - default: - return ''; - } -} diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts b/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts index 5ba702013..b38eaa3a6 100644 --- a/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts +++ b/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts @@ -6,6 +6,7 @@ import { useDefaultLayoutContext } from '../../../../../../components/layouts/de import type { MenuPlacement } from '../../../../../../components/ui/menu/menu-items'; import type { TooltipPlacement } from '../../../../../../components/ui/tooltip/tooltip-content'; import { useMediaState } from '../../../../../../core/api/media-context'; +import { updateFontCssVars } from '../../../../../../core/font/font-vars'; import { $signal } from '../../../../../lit/directives/signal'; import { IconSlot } from '../../slots'; import { $i18n } from '../utils'; @@ -41,6 +42,8 @@ export function DefaultSettingsMenu({ ), $isOpen = signal(false); + updateFontCssVars(); + function onOpen() { $isOpen.set(true); } @@ -56,7 +59,10 @@ export function DefaultSettingsMenu({ offset=${$signal($offset)} > ${$signal(() => { - if (!$isOpen()) return null; + if (!$isOpen()) { + return null; + } + return [ DefaultPlaybackMenu(), DefaultAccessibilityMenu(),