diff --git a/js/components/ClipPaths.tsx b/js/components/ClipPaths.tsx new file mode 100644 index 00000000..beb823a5 --- /dev/null +++ b/js/components/ClipPaths.tsx @@ -0,0 +1,34 @@ +import React, { useMemo, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; + +type Props = { + children: { [id: string]: string[] }; +}; + +// this.props.children should be an object containing arrays of strings. The +// keys are ids, and the arrays are arrays of polygon point strings +export default function ClipPaths({ children }: Props) { + const paths = useMemo(() => { + return document.createElement("div"); + }, []); + + useLayoutEffect(() => { + document.body.appendChild(paths); + return () => paths.remove(); + }, [paths]); + + return createPortal( + + + {Object.keys(children).map(id => ( + + {children[id].map((points, i) => ( + + ))} + + ))} + + , + paths + ); +} diff --git a/js/components/Css.tsx b/js/components/Css.tsx new file mode 100644 index 00000000..97672e56 --- /dev/null +++ b/js/components/Css.tsx @@ -0,0 +1,23 @@ +import { createPortal } from "react-dom"; +import { useMemo, useLayoutEffect } from "react"; + +type Props = { + children: string; + id: string; +}; + +export default function Css({ children, id }: Props) { + const style = useMemo(() => { + const s = document.createElement("style"); + s.type = "text/css"; + s.id = id; + return s; + }, [id]); + + useLayoutEffect(() => { + document.head.appendChild(style); + return () => style.remove(); + }, [style]); + + return createPortal(children, style); +} diff --git a/js/components/Skin.js b/js/components/Skin.js deleted file mode 100644 index eb3d8133..00000000 --- a/js/components/Skin.js +++ /dev/null @@ -1,171 +0,0 @@ -// Dynamically set the css background images for all the sprites -import React from "react"; -import { createPortal } from "react-dom"; -import { connect } from "react-redux"; - -import { LETTERS } from "../constants"; -import { imageSelectors, cursorSelectors } from "../skinSelectors"; - -const CSS_PREFIX = "#webamp"; - -const mapRegionNamesToIds = { - normal: "mainWindowClipPath", - windowshade: "shadeMainWindowClipPath", - equalizer: "equalizerWindowClipPath", - equalizerws: "shadeEqualizerWindowClipPath", -}; - -const mapRegionNamesToMatcher = { - normal: "#main-window:not(.shade)", - windowshade: "#main-window.shade", - equalizer: "#equalizer-window:not(.shade)", - equalizerws: "#equalizer-window.shade", -}; - -const numExIsUsed = skinImages => !!skinImages.DIGIT_0_EX; - -function Css({ children }) { - const style = React.useMemo(() => { - const s = document.createElement("style"); - s.type = "text/css"; - s.id = "webamp-skin"; - return s; - }, []); - - React.useLayoutEffect(() => { - document.head.appendChild(style); - return () => style.remove(); - }, [style]); - - return createPortal(children, style); -} -// this.props.children should be an object containing arrays of strings. The -// keys are ids, and the arrays are arrays of polygon point strings -function ClipPaths({ children }) { - const paths = React.useMemo(() => { - return document.createElement("div"); - }, []); - - React.useLayoutEffect(() => { - document.body.appendChild(paths); - return () => paths.remove(); - }, [paths]); - - return createPortal( - - - {Object.keys(children).map(id => ( - - {children[id].map((points, i) => ( - - ))} - - ))} - - , - paths - ); -} - -const FALLBACKS = { - MAIN_BALANCE_BACKGROUND: "MAIN_VOLUME_BACKGROUND", - MAIN_BALANCE_THUMB: "MAIN_VOLUME_THUMB", - MAIN_BALANCE_THUMB_ACTIVE: "MAIN_VOLUME_THUMB_SELECTED", -}; - -function cssRulesFromProps(props) { - const { skinImages, skinCursors, skinGenLetterWidths } = props; - if (!skinImages || !skinCursors) { - return null; - } - const cssRules = []; - Object.keys(imageSelectors).forEach(imageName => { - const imageUrl = skinImages[imageName] || skinImages[FALLBACKS[imageName]]; - if (imageUrl) { - imageSelectors[imageName].forEach(selector => { - cssRules.push( - `${CSS_PREFIX} ${selector} {background-image: url(${imageUrl})}` - ); - }); - } - }); - - if (skinGenLetterWidths != null) { - LETTERS.forEach(letter => { - const width = skinGenLetterWidths[`GEN_TEXT_${letter}`]; - const selectedWidth = skinGenLetterWidths[`GEN_TEXT_SELECTED_${letter}`]; - cssRules.push( - `${CSS_PREFIX} .gen-text-${letter.toLowerCase()} {width: ${width}px;}` - ); - cssRules.push( - `${CSS_PREFIX} .selected .gen-text-${letter.toLowerCase()} {width: ${selectedWidth}px;}` - ); - }); - } - Object.keys(cursorSelectors).forEach(cursorName => { - const cursorUrl = skinCursors[cursorName]; - if (cursorUrl) { - cursorSelectors[cursorName].forEach(selector => { - cssRules.push( - `${ - // TODO: Fix this hack - // Maybe our CSS name spacing should be based on some other class/id - // than the one we use for defining the main div. - // That way it could be shared by both the player and the context menu. - selector.startsWith("#webamp-context-menu") ? "" : CSS_PREFIX - } ${selector} {cursor: url(${cursorUrl}), auto}` - ); - }); - } - }); - - if (numExIsUsed(skinImages)) { - // This alternate number file requires that the minus sign be - // formatted differently. - cssRules.push( - `${CSS_PREFIX} .status #time #minus-sign { top: 0px; left: -1px; width: 9px; height: 13px; }` - ); - } - - for (const [regionName, polygons] of Object.entries(props.skinRegion)) { - if (polygons) { - const matcher = mapRegionNamesToMatcher[regionName]; - const id = mapRegionNamesToIds[regionName]; - cssRules.push(`${CSS_PREFIX} ${matcher} { clip-path: url(#${id}); }`); - } - } - - return cssRules; -} - -function clipPathsFromProps(props) { - const clipPaths = {}; - for (const [regionName, polygons] of Object.entries(props.skinRegion)) { - if (polygons) { - const id = mapRegionNamesToIds[regionName]; - clipPaths[id] = polygons; - } - } - return clipPaths; -} - -const Skin = props => { - const cssRules = cssRulesFromProps(props); - if (cssRules == null) { - return null; - } - const clipPaths = clipPathsFromProps(props); - return ( -
- {cssRules.join("\n")} - {clipPaths} -
- ); -}; - -export default connect(state => ({ - skinImages: state.display.skinImages, - skinCursors: state.display.skinCursors, - skinRegion: state.display.skinRegion, - skinGenLetterWidths: state.display.skinGenLetterWidths, -}))(Skin); diff --git a/js/components/Skin.tsx b/js/components/Skin.tsx new file mode 100644 index 00000000..16fd00d6 --- /dev/null +++ b/js/components/Skin.tsx @@ -0,0 +1,132 @@ +// Dynamically set the css background images for all the sprites +import React from "react"; + +import { LETTERS } from "../constants"; +import { imageSelectors, cursorSelectors } from "../skinSelectors"; +import { useTypedSelector } from "../hooks"; +import * as Selectors from "../selectors"; +import { SkinImages } from "../types"; +import { createSelector } from "reselect"; +import Css from "./Css"; +import ClipPaths from "./ClipPaths"; + +const CSS_PREFIX = "#webamp"; + +const mapRegionNamesToIds: { [key: string]: string } = { + normal: "mainWindowClipPath", + windowshade: "shadeMainWindowClipPath", + equalizer: "equalizerWindowClipPath", + equalizerws: "shadeEqualizerWindowClipPath", +}; + +const mapRegionNamesToMatcher: { [key: string]: string } = { + normal: "#main-window:not(.shade)", + windowshade: "#main-window.shade", + equalizer: "#equalizer-window:not(.shade)", + equalizerws: "#equalizer-window.shade", +}; + +const numExIsUsed = (skinImages: SkinImages) => Boolean(skinImages.DIGIT_0_EX); + +const FALLBACKS: { [key: string]: string } = { + MAIN_BALANCE_BACKGROUND: "MAIN_VOLUME_BACKGROUND", + MAIN_BALANCE_THUMB: "MAIN_VOLUME_THUMB", + MAIN_BALANCE_THUMB_ACTIVE: "MAIN_VOLUME_THUMB_SELECTED", +}; + +const getCssRules = createSelector( + Selectors.getSkinImages, + Selectors.getSkinCursors, + Selectors.getSkinLetterWidths, + Selectors.getSkinRegion, + (skinImages, skinCursors, skinGenLetterWidths, skinRegion): string | null => { + if (!skinImages || !skinCursors) { + return null; + } + const cssRules = []; + Object.keys(imageSelectors).forEach(imageName => { + const imageUrl = + skinImages[imageName] || skinImages[FALLBACKS[imageName]]; + if (imageUrl) { + imageSelectors[imageName].forEach(selector => { + cssRules.push( + `${CSS_PREFIX} ${selector} {background-image: url(${imageUrl})}` + ); + }); + } + }); + + if (skinGenLetterWidths != null) { + LETTERS.forEach(letter => { + const width = skinGenLetterWidths[`GEN_TEXT_${letter}`]; + const selectedWidth = + skinGenLetterWidths[`GEN_TEXT_SELECTED_${letter}`]; + cssRules.push( + `${CSS_PREFIX} .gen-text-${letter.toLowerCase()} {width: ${width}px;}` + ); + cssRules.push( + `${CSS_PREFIX} .selected .gen-text-${letter.toLowerCase()} {width: ${selectedWidth}px;}` + ); + }); + } + Object.keys(cursorSelectors).forEach(cursorName => { + const cursorUrl = skinCursors[cursorName]; + if (cursorUrl) { + cursorSelectors[cursorName].forEach(selector => { + cssRules.push( + `${ + // TODO: Fix this hack + // Maybe our CSS name spacing should be based on some other class/id + // than the one we use for defining the main div. + // That way it could be shared by both the player and the context menu. + selector.startsWith("#webamp-context-menu") ? "" : CSS_PREFIX + } ${selector} {cursor: url(${cursorUrl}), auto}` + ); + }); + } + }); + + if (numExIsUsed(skinImages)) { + // This alternate number file requires that the minus sign be + // formatted differently. + cssRules.push( + `${CSS_PREFIX} .status #time #minus-sign { top: 0px; left: -1px; width: 9px; height: 13px; }` + ); + } + + for (const [regionName, polygons] of Object.entries(skinRegion)) { + if (polygons) { + const matcher = mapRegionNamesToMatcher[regionName]; + const id = mapRegionNamesToIds[regionName]; + cssRules.push(`${CSS_PREFIX} ${matcher} { clip-path: url(#${id}); }`); + } + } + + return cssRules.join("\n"); + } +); + +const getClipPaths = createSelector(Selectors.getSkinRegion, skinRegion => { + const clipPaths: { [id: string]: string[] } = {}; + for (const [regionName, polygons] of Object.entries(skinRegion)) { + if (polygons) { + const id = mapRegionNamesToIds[regionName]; + clipPaths[id] = polygons; + } + } + return clipPaths; +}); + +export default function Skin() { + const cssRules = useTypedSelector(getCssRules); + const clipPaths = useTypedSelector(getClipPaths); + if (cssRules == null) { + return null; + } + return ( + <> + {cssRules} + {clipPaths} + + ); +} diff --git a/js/selectors.ts b/js/selectors.ts index 980cdfe2..ae6ef6d7 100644 --- a/js/selectors.ts +++ b/js/selectors.ts @@ -10,6 +10,10 @@ import { TransitionType, MediaStatus, TimeMode, + SkinImages, + Cursors, + SkinRegion, + GenLetterWidths, } from "./types"; import { createSelector, defaultMemoize } from "reselect"; import * as Utils from "./utils"; @@ -677,6 +681,22 @@ export function getRandomizePresets(state: AppState): boolean { return state.milkdrop.randomize; } +export function getSkinImages(state: AppState): SkinImages { + return state.display.skinImages; +} + +export function getSkinCursors(state: AppState): Cursors | null { + return state.display.skinCursors; +} + +export function getSkinRegion(state: AppState): SkinRegion { + return state.display.skinRegion; +} + +export function getSkinLetterWidths(state: AppState): GenLetterWidths | null { + return state.display.skinGenLetterWidths; +} + export function getPreampLineUrl(state: AppState): string | null { return state.display.skinImages.EQ_PREAMP_LINE; }