yt-grabber 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +29 -0
- package/.prettierrc +19 -0
- package/.vscode/extensions.json +7 -0
- package/.vscode/settings.json +23 -0
- package/.yarnrc.yml +13 -0
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/package.json +115 -0
- package/public/index.html +20 -0
- package/public/screenshots/cutting.png +0 -0
- package/public/screenshots/downloading.png +0 -0
- package/public/screenshots/editing.png +0 -0
- package/public/screenshots/errors.png +0 -0
- package/public/screenshots/settings.png +0 -0
- package/public/screenshots/tracklist.png +0 -0
- package/src/@types/global.d.ts +7 -0
- package/src/@types/i18next-scanner-webpack.d.ts +1 -0
- package/src/@types/stylus.d.ts +4 -0
- package/src/@types/svg.d.ts +1 -0
- package/src/App.styl +24 -0
- package/src/App.tsx +31 -0
- package/src/bootstrap.tsx +30 -0
- package/src/common/CancellablePromise.ts +22 -0
- package/src/common/ComponentDisplayMode.ts +8 -0
- package/src/common/Delay.ts +3 -0
- package/src/common/FileSystem.ts +171 -0
- package/src/common/Helpers.ts +270 -0
- package/src/common/Mappings.ts +14 -0
- package/src/common/PuppeteerOptions.ts +45 -0
- package/src/common/Selectors.ts +21 -0
- package/src/common/Store.ts +108 -0
- package/src/common/Theme.ts +4 -0
- package/src/common/Youtube.ts +80 -0
- package/src/components/appBar/AppBar.styl +22 -0
- package/src/components/appBar/AppBar.tsx +73 -0
- package/src/components/directoryPicker/DirectoryPicker.tsx +44 -0
- package/src/components/fileField/FileField.styl +3 -0
- package/src/components/fileField/FileField.tsx +152 -0
- package/src/components/languagePicker/LanguagePicker.styl +38 -0
- package/src/components/languagePicker/LanguagePicker.tsx +145 -0
- package/src/components/logo/Logo.tsx +15 -0
- package/src/components/modals/DetailsModal.styl +9 -0
- package/src/components/modals/DetailsModal.tsx +85 -0
- package/src/components/numberField/NumberField.styl +13 -0
- package/src/components/numberField/NumberField.tsx +154 -0
- package/src/components/progress/Progress.styl +15 -0
- package/src/components/progress/Progress.tsx +18 -0
- package/src/components/splitButton/SplitButton.styl +0 -0
- package/src/components/splitButton/SplitButton.tsx +125 -0
- package/src/components/themePicker/ThemePicker.styl +19 -0
- package/src/components/themePicker/ThemePicker.tsx +65 -0
- package/src/components/themeSwitcher/ThemeSwitcher.styl +10 -0
- package/src/components/themeSwitcher/ThemeSwitcher.tsx +43 -0
- package/src/components/youtube/formatSelector/FormatSelector.styl +3 -0
- package/src/components/youtube/formatSelector/FormatSelector.tsx +202 -0
- package/src/components/youtube/inputPanel/InputPanel.styl +7 -0
- package/src/components/youtube/inputPanel/InputPanel.tsx +189 -0
- package/src/components/youtube/mediaInfoPanel/MediaInfoPanel.styl +80 -0
- package/src/components/youtube/mediaInfoPanel/MediaInfoPanel.tsx +113 -0
- package/src/components/youtube/trackList/TrackList.styl +64 -0
- package/src/components/youtube/trackList/TrackList.tsx +258 -0
- package/src/enums/DataResponse.ts +5 -0
- package/src/enums/Media.ts +16 -0
- package/src/enums/MediaFormat.ts +10 -0
- package/src/enums/MimeTypes.ts +14 -0
- package/src/hooks/useCancellablePromises.ts +25 -0
- package/src/hooks/useClickCounter.ts +24 -0
- package/src/hooks/useData.ts +61 -0
- package/src/hooks/useMultiClickHandler.ts +41 -0
- package/src/i18next.ts +33 -0
- package/src/index.ts +65 -0
- package/src/react/actions/Action.ts +3 -0
- package/src/react/actions/AppActions.ts +41 -0
- package/src/react/contexts/AppContext.tsx +51 -0
- package/src/react/contexts/AppThemeContext.tsx +38 -0
- package/src/react/contexts/DataContext copy.tsx +76 -0
- package/src/react/contexts/DataContext.tsx +41 -0
- package/src/react/hooks/useAppTheme.ts +14 -0
- package/src/react/reducers/AppReducer.tsx +45 -0
- package/src/react/reducers/Reducer.ts +7 -0
- package/src/react/states/AppState.ts +29 -0
- package/src/react/states/State.ts +29 -0
- package/src/renderer.tsx +13 -0
- package/src/resources/bin/yt-dlp.exe +0 -0
- package/src/resources/fonts/Baloo-Regular.ttf +0 -0
- package/src/resources/fonts/Lato-Black.ttf +0 -0
- package/src/resources/fonts/Lato-BlackItalic.ttf +0 -0
- package/src/resources/fonts/Lato-Bold.ttf +0 -0
- package/src/resources/fonts/Lato-BoldItalic.ttf +0 -0
- package/src/resources/fonts/Lato-Italic.ttf +0 -0
- package/src/resources/fonts/Lato-Light.ttf +0 -0
- package/src/resources/fonts/Lato-LightItalic.ttf +0 -0
- package/src/resources/fonts/Lato-Regular.ttf +0 -0
- package/src/resources/fonts/Lato-Thin.ttf +0 -0
- package/src/resources/fonts/Lato-ThinItalic.ttf +0 -0
- package/src/resources/fonts/Material-Icons.woff2 +0 -0
- package/src/resources/icons/favicon-16x16.png +0 -0
- package/src/resources/icons/favicon-32x32.png +0 -0
- package/src/resources/icons/favicon.ico +0 -0
- package/src/resources/icons/logo-shape.png +0 -0
- package/src/resources/icons/logo-shape.svg +59 -0
- package/src/resources/images/loading.svg +28 -0
- package/src/resources/images/logo.png +0 -0
- package/src/resources/locales/de-DE/flag.svg +1 -0
- package/src/resources/locales/de-DE/translation.json +44 -0
- package/src/resources/locales/en-GB/flag.svg +43 -0
- package/src/resources/locales/en-GB/translation.json +44 -0
- package/src/resources/locales/pl-PL/flag.svg +36 -0
- package/src/resources/locales/pl-PL/translation.json +44 -0
- package/src/styles/MaterialThemes.ts +331 -0
- package/src/styles/fonts.styl +71 -0
- package/src/styles/mixins.styl +22 -0
- package/src/tests/CompleteTracksMock.ts +17384 -0
- package/src/tests/MissingDetailsTracksMock.ts +7737 -0
- package/src/theme/ColorThemes.ts +190 -0
- package/src/theme/Colors.ts +92 -0
- package/src/theme/Shadows.ts +9 -0
- package/src/theme/Shape.ts +7 -0
- package/src/theme/Theme.ts +24 -0
- package/src/theme/Typography.ts +56 -0
- package/src/views/development/DevelopmentView.styl +22 -0
- package/src/views/development/DevelopmentView.tsx +57 -0
- package/src/views/home/HomeView.styl +60 -0
- package/src/views/home/HomeView.tsx +505 -0
- package/src/views/settings/SettingsView.styl +27 -0
- package/src/views/settings/SettingsView.tsx +255 -0
- package/tsconfig.json +20 -0
- package/webpack.config.ts +226 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import _find from "lodash/find";
|
|
2
|
+
import _first from "lodash/first";
|
|
3
|
+
import _get from "lodash/get";
|
|
4
|
+
import _isFunction from "lodash/isFunction";
|
|
5
|
+
import _map from "lodash/map";
|
|
6
|
+
import React, {useEffect} from "react";
|
|
7
|
+
|
|
8
|
+
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
|
|
9
|
+
import Button from "@mui/material/Button";
|
|
10
|
+
import ButtonGroup, {ButtonGroupProps} from "@mui/material/ButtonGroup";
|
|
11
|
+
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
|
12
|
+
import Grow from "@mui/material/Grow";
|
|
13
|
+
import MenuItem from "@mui/material/MenuItem";
|
|
14
|
+
import MenuList from "@mui/material/MenuList";
|
|
15
|
+
import Paper from "@mui/material/Paper";
|
|
16
|
+
import Popper from "@mui/material/Popper";
|
|
17
|
+
|
|
18
|
+
export type SplitButtonProps = ButtonGroupProps & {
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
actions?: SplitButtonAction[];
|
|
21
|
+
selectedAction?: string;
|
|
22
|
+
onSelectedActionChange?: (action: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SplitButtonAction = {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
handler: (...args: any[]) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SplitButton = (props: SplitButtonProps) => {
|
|
32
|
+
const {loading, selectedAction, actions, onSelectedActionChange, ...rest} = props;
|
|
33
|
+
const [open, setOpen] = React.useState(false);
|
|
34
|
+
const anchorRef = React.useRef<HTMLDivElement>(null);
|
|
35
|
+
|
|
36
|
+
const handleMenuItemClick = (action: SplitButtonAction) => {
|
|
37
|
+
if (_isFunction(onSelectedActionChange)) {
|
|
38
|
+
onSelectedActionChange(action.id);
|
|
39
|
+
}
|
|
40
|
+
setOpen(false);
|
|
41
|
+
action.handler();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleSelectedActionClick = () => {
|
|
45
|
+
const action = _find(actions, ["id", selectedAction]);
|
|
46
|
+
|
|
47
|
+
action.handler();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleToggle = () => {
|
|
51
|
+
setOpen((prevOpen) => !prevOpen);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleClose = (event: Event) => {
|
|
55
|
+
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setOpen(false);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (selectedAction) return;
|
|
64
|
+
if (!actions) return;
|
|
65
|
+
|
|
66
|
+
onSelectedActionChange(_get(_first(actions), "id"));
|
|
67
|
+
}, [actions]);
|
|
68
|
+
|
|
69
|
+
const getSelectedActionLabel = () => {
|
|
70
|
+
const action = _find(actions, ["id", selectedAction]);
|
|
71
|
+
|
|
72
|
+
return action?.label;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<ButtonGroup
|
|
78
|
+
variant="contained"
|
|
79
|
+
ref={anchorRef}
|
|
80
|
+
{...rest}
|
|
81
|
+
>
|
|
82
|
+
<Button loading={loading} onClick={handleSelectedActionClick}>{getSelectedActionLabel()}</Button>
|
|
83
|
+
<Button disabled={loading} size="small" onClick={handleToggle} sx={{width: "auto"}}>
|
|
84
|
+
<ArrowDropDownIcon />
|
|
85
|
+
</Button>
|
|
86
|
+
</ButtonGroup>
|
|
87
|
+
<Popper
|
|
88
|
+
sx={{zIndex: 1, width: anchorRef.current?.clientWidth ?? "auto"}}
|
|
89
|
+
open={open}
|
|
90
|
+
anchorEl={anchorRef.current}
|
|
91
|
+
role={undefined}
|
|
92
|
+
transition
|
|
93
|
+
disablePortal
|
|
94
|
+
>
|
|
95
|
+
{({TransitionProps, placement}) => (
|
|
96
|
+
<Grow
|
|
97
|
+
{...TransitionProps}
|
|
98
|
+
style={{
|
|
99
|
+
transformOrigin:
|
|
100
|
+
placement === "bottom" ? "center top" : "center bottom",
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<Paper>
|
|
104
|
+
<ClickAwayListener onClickAway={handleClose}>
|
|
105
|
+
<MenuList autoFocusItem>
|
|
106
|
+
{_map(actions, (action) => (
|
|
107
|
+
<MenuItem
|
|
108
|
+
key={action.id}
|
|
109
|
+
selected={action.id === selectedAction}
|
|
110
|
+
onClick={() => handleMenuItemClick(action)}
|
|
111
|
+
>
|
|
112
|
+
{action.label}
|
|
113
|
+
</MenuItem>
|
|
114
|
+
))}
|
|
115
|
+
</MenuList>
|
|
116
|
+
</ClickAwayListener>
|
|
117
|
+
</Paper>
|
|
118
|
+
</Grow>
|
|
119
|
+
)}
|
|
120
|
+
</Popper>
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export default SplitButton;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import classnames from "classnames";
|
|
2
|
+
import _map from "lodash/map";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import {useTranslation} from "react-i18next";
|
|
5
|
+
|
|
6
|
+
import SystemModeIcon from "@mui/icons-material/ComputerRounded";
|
|
7
|
+
import DarkModeIcon from "@mui/icons-material/DarkModeRounded";
|
|
8
|
+
import LightModeIcon from "@mui/icons-material/LightModeRounded";
|
|
9
|
+
import {
|
|
10
|
+
FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, SelectProps
|
|
11
|
+
} from "@mui/material";
|
|
12
|
+
import {useColorScheme} from "@mui/material/styles";
|
|
13
|
+
|
|
14
|
+
import Styles from "./ThemePicker.styl";
|
|
15
|
+
|
|
16
|
+
export type IThemePickerProps = SelectProps;
|
|
17
|
+
|
|
18
|
+
export type ThemeMode = "dark" | "light" | "system";
|
|
19
|
+
|
|
20
|
+
export const ThemePicker = (props: IThemePickerProps) => {
|
|
21
|
+
const {className, ...rest} = props;
|
|
22
|
+
const {t} = useTranslation();
|
|
23
|
+
const {mode, setMode} = useColorScheme();
|
|
24
|
+
const themeModes: ThemeMode[] = ["light", "dark", "system"];
|
|
25
|
+
|
|
26
|
+
const resolveIcon = (mode: ThemeMode) => {
|
|
27
|
+
if (mode === "dark") {
|
|
28
|
+
return <DarkModeIcon className={Styles.icon} />;
|
|
29
|
+
} else if (mode === "light") {
|
|
30
|
+
return <LightModeIcon className={Styles.icon} />;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return <SystemModeIcon className={Styles.icon} />;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const onChangeMode = (event: SelectChangeEvent<ThemeMode>) => {
|
|
37
|
+
setMode(event.target.value as ThemeMode);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<FormControl fullWidth className={classnames(Styles.themePicker, className)}>
|
|
42
|
+
<InputLabel id="theme-mode-picker-label">{t("themeMode")}</InputLabel>
|
|
43
|
+
<Select
|
|
44
|
+
SelectDisplayProps={{
|
|
45
|
+
className: Styles.select
|
|
46
|
+
}}
|
|
47
|
+
labelId="theme-mode-picker-label"
|
|
48
|
+
value={mode}
|
|
49
|
+
label={t("themeMode")}
|
|
50
|
+
onChange={onChangeMode}
|
|
51
|
+
MenuProps={{
|
|
52
|
+
disablePortal: true
|
|
53
|
+
}}
|
|
54
|
+
{...rest}
|
|
55
|
+
>
|
|
56
|
+
{_map(themeModes, (item) => <MenuItem key={item} value={item} className={Styles.menuItem}>
|
|
57
|
+
{resolveIcon(item)}
|
|
58
|
+
<div>{item}</div>
|
|
59
|
+
</MenuItem>)}
|
|
60
|
+
</Select>
|
|
61
|
+
</FormControl>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default ThemePicker;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import classnames from "classnames";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import {useTranslation} from "react-i18next";
|
|
4
|
+
|
|
5
|
+
import DarkModeIcon from "@mui/icons-material/DarkModeRounded";
|
|
6
|
+
import LightModeIcon from "@mui/icons-material/LightModeRounded";
|
|
7
|
+
import {ButtonProps, IconButton} from "@mui/material";
|
|
8
|
+
import {useColorScheme} from "@mui/material/styles";
|
|
9
|
+
|
|
10
|
+
import Styles from "./ThemeSwitcher.styl";
|
|
11
|
+
|
|
12
|
+
export type IThemeSwitcherProps = ButtonProps
|
|
13
|
+
|
|
14
|
+
export const ThemeSwitcher = (props: IThemeSwitcherProps) => {
|
|
15
|
+
const {className, ...rest} = props;
|
|
16
|
+
const {t} = useTranslation();
|
|
17
|
+
const {mode, systemMode, setMode} = useColorScheme();
|
|
18
|
+
const resolvedMode = (systemMode || mode) as "light" | "dark";
|
|
19
|
+
|
|
20
|
+
const icon = {
|
|
21
|
+
dark: <LightModeIcon />,
|
|
22
|
+
light: <DarkModeIcon />,
|
|
23
|
+
}[resolvedMode];
|
|
24
|
+
|
|
25
|
+
const tooltip = {
|
|
26
|
+
light: t("dark"),
|
|
27
|
+
dark: t("light"),
|
|
28
|
+
}[resolvedMode];
|
|
29
|
+
|
|
30
|
+
const onChangeMode = () => {
|
|
31
|
+
const nextMode = mode === "dark" ? "light" : "dark";
|
|
32
|
+
|
|
33
|
+
setMode(nextMode);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className={classnames(Styles.themeSwitcher, className)}>
|
|
38
|
+
<IconButton size="small" color="inherit" className={Styles.button} title={tooltip} onClick={onChangeMode} {...rest}>{icon}</IconButton>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default ThemeSwitcher;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import _capitalize from "lodash/capitalize";
|
|
2
|
+
import _filter from "lodash/filter";
|
|
3
|
+
import _first from "lodash/first";
|
|
4
|
+
import _get from "lodash/get";
|
|
5
|
+
import _includes from "lodash/includes";
|
|
6
|
+
import _isFunction from "lodash/isFunction";
|
|
7
|
+
import _isNumber from "lodash/isNumber";
|
|
8
|
+
import _last from "lodash/last";
|
|
9
|
+
import _map from "lodash/map";
|
|
10
|
+
import _uniq from "lodash/uniq";
|
|
11
|
+
import _values from "lodash/values";
|
|
12
|
+
import React, {useEffect, useState} from "react";
|
|
13
|
+
import {useTranslation} from "react-i18next";
|
|
14
|
+
|
|
15
|
+
import {FormControl, InputLabel, MenuItem, Select, SelectChangeEvent} from "@mui/material";
|
|
16
|
+
import Grid from "@mui/material/Grid2";
|
|
17
|
+
|
|
18
|
+
import {ApplicationOptions} from "../../../common/Store";
|
|
19
|
+
import {FormatInfo} from "../../../common/Youtube";
|
|
20
|
+
import {AudioType, MediaFormat, VideoType} from "../../../enums/Media";
|
|
21
|
+
import {useDataState} from "../../../react/contexts/DataContext";
|
|
22
|
+
import NumberField from "../../numberField/NumberField";
|
|
23
|
+
import Styles from "./FormatSelector.styl";
|
|
24
|
+
|
|
25
|
+
export type Format = {
|
|
26
|
+
type?: MediaFormat;
|
|
27
|
+
extension?: AudioType | VideoType;
|
|
28
|
+
videoQuality?: string;
|
|
29
|
+
audioQuality?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FormatSelectorProps = {
|
|
33
|
+
value?: Format;
|
|
34
|
+
onSelected?: (format: Format) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const FormatSelector: React.FC<FormatSelectorProps> = (props) => {
|
|
38
|
+
const {value, onSelected} = props;
|
|
39
|
+
const [appOptions] = useState<ApplicationOptions>(global.store.get("application"));
|
|
40
|
+
const {tracks} = useDataState();
|
|
41
|
+
|
|
42
|
+
const audioExtensions = Object.values(AudioType);
|
|
43
|
+
const videoExtensions = Object.values(VideoType);
|
|
44
|
+
const [formats, setFormats] = useState<Array<AudioType | VideoType>>(value.type === MediaFormat.Audio ? audioExtensions : videoExtensions);
|
|
45
|
+
const [resolutions, setResolutions] = useState<string[]>();
|
|
46
|
+
|
|
47
|
+
const [selectedMediaType, setSelectedMediaType] = useState<MediaFormat>(value.type ?? MediaFormat.Audio);
|
|
48
|
+
const [selectedAudioExtension, setSelectedAudioExtension] = useState<AudioType>(value.type === MediaFormat.Audio ? value.extension as AudioType :_first(audioExtensions));
|
|
49
|
+
const [selectedVideoExtension, setSelectedVideoExtension] = useState<VideoType>(value.type === MediaFormat.Video ? value.extension as VideoType :_first(videoExtensions));
|
|
50
|
+
const [selectedFormat, setSelectedFormat] = useState<AudioType | VideoType>(value.extension ?? _first(formats));
|
|
51
|
+
const [selectedResolution, setSelectedResolution] = useState<string>(value.videoQuality);
|
|
52
|
+
const [selectedQuality, setSelectedQuality] = useState(value.audioQuality ?? appOptions.quality);
|
|
53
|
+
const [currentValue, setCurrentValue] = useState<Format>(value);
|
|
54
|
+
const {t} = useTranslation();
|
|
55
|
+
|
|
56
|
+
const isFormatValid = (val: Format) => {
|
|
57
|
+
return val && val.type && val.extension && (
|
|
58
|
+
(val.type === MediaFormat.Video && val.videoQuality && _includes(videoExtensions, val.extension)) ||
|
|
59
|
+
(val.type === MediaFormat.Audio && _isNumber(val.audioQuality) && _includes(audioExtensions, val.extension))
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const resolveResolutionText = (val: FormatInfo) => {
|
|
64
|
+
const [, height] = _map(val.resolution.match(/\d+/g), Number);
|
|
65
|
+
|
|
66
|
+
return `${val.resolution} (${height}p)`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!_isFunction(onSelected) || !isFormatValid(currentValue)) return;
|
|
71
|
+
|
|
72
|
+
onSelected(currentValue);
|
|
73
|
+
}, [currentValue]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
setCurrentValue({
|
|
77
|
+
type: selectedMediaType,
|
|
78
|
+
extension: selectedFormat,
|
|
79
|
+
videoQuality: selectedResolution,
|
|
80
|
+
audioQuality: selectedQuality,
|
|
81
|
+
});
|
|
82
|
+
}, [selectedMediaType, selectedFormat, selectedVideoExtension, selectedResolution, selectedQuality]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const formats = _get(tracks, "0.formats");
|
|
86
|
+
const nextResolutions = _uniq(_map(_filter(formats, (f) => f.vcodec !== "none" && !!f.resolution), resolveResolutionText));
|
|
87
|
+
|
|
88
|
+
setResolutions(nextResolutions);
|
|
89
|
+
setSelectedResolution(value.videoQuality ?? _last(nextResolutions));
|
|
90
|
+
}, [tracks]);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const extensionsByMediaType = {
|
|
94
|
+
[MediaFormat.Audio]: audioExtensions,
|
|
95
|
+
[MediaFormat.Video]: videoExtensions,
|
|
96
|
+
};
|
|
97
|
+
const selectedExtensionByMediaType = {
|
|
98
|
+
[MediaFormat.Audio]: selectedAudioExtension,
|
|
99
|
+
[MediaFormat.Video]: selectedVideoExtension,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
setFormats(extensionsByMediaType[selectedMediaType]);
|
|
103
|
+
setSelectedFormat(selectedExtensionByMediaType[selectedMediaType]);
|
|
104
|
+
}, [selectedMediaType]);
|
|
105
|
+
|
|
106
|
+
// useEffect(() => {
|
|
107
|
+
// const selectedExtensionByMediaType = {
|
|
108
|
+
// [MediaFormat.Audio]: selectedAudioExtension,
|
|
109
|
+
// [MediaFormat.Video]: selectedVideoExtension,
|
|
110
|
+
// };
|
|
111
|
+
|
|
112
|
+
// setSelectedFormat(selectedExtensionByMediaType[selectedMediaType]);
|
|
113
|
+
// }, [formats]);
|
|
114
|
+
|
|
115
|
+
const handleMediaTypeChange = (event: SelectChangeEvent<MediaFormat>) => {
|
|
116
|
+
setSelectedMediaType(event.target.value as MediaFormat);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleFormatChange = (event: SelectChangeEvent<AudioType | VideoType>) => {
|
|
120
|
+
if (selectedMediaType === MediaFormat.Audio) {
|
|
121
|
+
setSelectedAudioExtension(event.target.value as AudioType);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (selectedMediaType === MediaFormat.Video) {
|
|
125
|
+
setSelectedVideoExtension(event.target.value as VideoType);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setSelectedFormat(event.target.value as AudioType | VideoType);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleResolutionChange = (event: SelectChangeEvent<string>) => {
|
|
132
|
+
setSelectedResolution(event.target.value);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onQualityChange = (value: number) => {
|
|
136
|
+
setSelectedQuality(value);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Grid className={Styles.formatSelector} container spacing={2} padding={2}>
|
|
141
|
+
<Grid size={4}>
|
|
142
|
+
<FormControl fullWidth>
|
|
143
|
+
<InputLabel id="media-type-label">{t("mediaType")}</InputLabel>
|
|
144
|
+
<Select<MediaFormat>
|
|
145
|
+
labelId="media-type-label"
|
|
146
|
+
value={selectedMediaType}
|
|
147
|
+
label={t("mediaType")}
|
|
148
|
+
onChange={handleMediaTypeChange}
|
|
149
|
+
>
|
|
150
|
+
{_map(_values(MediaFormat), (f) => <MenuItem key={f} value={f}>{_capitalize(f)}</MenuItem>)}
|
|
151
|
+
</Select>
|
|
152
|
+
</FormControl>
|
|
153
|
+
</Grid>
|
|
154
|
+
<Grid size={4}>
|
|
155
|
+
<FormControl fullWidth>
|
|
156
|
+
<InputLabel id="format-label">{t("format")}</InputLabel>
|
|
157
|
+
<Select<string>
|
|
158
|
+
labelId="format-label"
|
|
159
|
+
value={selectedFormat}
|
|
160
|
+
label={t("format")}
|
|
161
|
+
onChange={handleFormatChange}
|
|
162
|
+
>
|
|
163
|
+
{_map(formats, (item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
|
|
164
|
+
</Select>
|
|
165
|
+
</FormControl>
|
|
166
|
+
</Grid>
|
|
167
|
+
{selectedMediaType === MediaFormat.Video &&
|
|
168
|
+
<Grid size={4}>
|
|
169
|
+
<FormControl fullWidth>
|
|
170
|
+
<InputLabel id="resolution-label">{t("resolution")}</InputLabel>
|
|
171
|
+
<Select<string>
|
|
172
|
+
labelId="resolution-label"
|
|
173
|
+
value={selectedResolution}
|
|
174
|
+
label={t("resolution")}
|
|
175
|
+
onChange={handleResolutionChange}
|
|
176
|
+
>
|
|
177
|
+
{_map(resolutions, (item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
|
|
178
|
+
</Select>
|
|
179
|
+
</FormControl>
|
|
180
|
+
</Grid>
|
|
181
|
+
}
|
|
182
|
+
{selectedMediaType === MediaFormat.Audio &&
|
|
183
|
+
<Grid size={4}>
|
|
184
|
+
<NumberField
|
|
185
|
+
fullWidth
|
|
186
|
+
label={t("audioQuality")}
|
|
187
|
+
id="audioQuality"
|
|
188
|
+
variant="outlined"
|
|
189
|
+
onChange={onQualityChange}
|
|
190
|
+
value={selectedQuality}
|
|
191
|
+
decimalScale={0}
|
|
192
|
+
step={1}
|
|
193
|
+
min={0}
|
|
194
|
+
max={10}
|
|
195
|
+
/>
|
|
196
|
+
</Grid>
|
|
197
|
+
}
|
|
198
|
+
</Grid>
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export default FormatSelector;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import _filter from "lodash/filter";
|
|
2
|
+
import _isEmpty from "lodash/isEmpty";
|
|
3
|
+
import _isFunction from "lodash/isFunction";
|
|
4
|
+
import _map from "lodash/map";
|
|
5
|
+
import React, {useMemo, useRef, useState} from "react";
|
|
6
|
+
import {useTranslation} from "react-i18next";
|
|
7
|
+
|
|
8
|
+
import ClearIcon from "@mui/icons-material/Clear";
|
|
9
|
+
import DownloadIcon from "@mui/icons-material/Download";
|
|
10
|
+
import FolderIcon from "@mui/icons-material/Folder";
|
|
11
|
+
import InfoIcon from "@mui/icons-material/Info";
|
|
12
|
+
import ReplayIcon from "@mui/icons-material/Replay";
|
|
13
|
+
import {
|
|
14
|
+
Autocomplete, AutocompleteRenderInputParams, Button, Chip, IconButton, Stack, TextField
|
|
15
|
+
} from "@mui/material";
|
|
16
|
+
import Grid from "@mui/material/Grid2";
|
|
17
|
+
|
|
18
|
+
import {ApplicationOptions} from "../../../common/Store";
|
|
19
|
+
import {useDataState} from "../../../react/contexts/DataContext";
|
|
20
|
+
import Styles from "./InputPanel.styl";
|
|
21
|
+
|
|
22
|
+
export type InputPanelProps = {
|
|
23
|
+
value?: string;
|
|
24
|
+
values?: string[];
|
|
25
|
+
mode?: "single" | "multi";
|
|
26
|
+
loading?: boolean;
|
|
27
|
+
onChange?: (value: string | string[]) => void;
|
|
28
|
+
onDownload: (...args: any[]) => void;
|
|
29
|
+
onDownloadFailed: () => void;
|
|
30
|
+
onLoadInfo: (...args: any[]) => void;
|
|
31
|
+
onClear: () => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const InputPanel: React.FC<InputPanelProps> = (props: InputPanelProps) => {
|
|
35
|
+
const {value, values, mode = "single", loading, onChange, onDownload, onDownloadFailed, onLoadInfo, onClear} = props;
|
|
36
|
+
const [appOptions] = useState<ApplicationOptions>(global.store.get("application"));
|
|
37
|
+
const {album, trackStatus} = useDataState();
|
|
38
|
+
const [validationError, setValidationError] = useState<string>();
|
|
39
|
+
const {t} = useTranslation();
|
|
40
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
41
|
+
|
|
42
|
+
const validateUrl = (value: string) => {
|
|
43
|
+
const youtubeRegex = /^(?:https?:\/\/)?(?:www\.)?(?:m\.)?(?:music\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/|live\/|playlist\?list=)|youtu\.be\/)([\w-]{11})/;
|
|
44
|
+
|
|
45
|
+
const valid = appOptions.debugMode ? true : youtubeRegex.test(value);
|
|
46
|
+
|
|
47
|
+
if (!valid) {
|
|
48
|
+
setValidationError(t("invalidYoutubeUrl"));
|
|
49
|
+
} else {
|
|
50
|
+
setValidationError(null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return valid;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleDelete = (valueToDelete: any) => {
|
|
57
|
+
if (_isFunction(onChange)) {
|
|
58
|
+
onChange(_filter(values, (v) => v !== valueToDelete));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const showDownloadFailed = useMemo(() => {
|
|
63
|
+
return !_isEmpty(_filter(trackStatus, "error"));
|
|
64
|
+
}, [trackStatus]);
|
|
65
|
+
|
|
66
|
+
const onSingleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
67
|
+
validateUrl(event.target.value);
|
|
68
|
+
|
|
69
|
+
if (_isFunction(onChange)) {
|
|
70
|
+
onChange(event.target.value);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const onMultiValueChange = (event: React.ChangeEvent<HTMLInputElement>, newValue: string[]) => {
|
|
75
|
+
if (_isFunction(onChange)) {
|
|
76
|
+
onChange(newValue);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
81
|
+
if (event.key === "Enter") {
|
|
82
|
+
onDownload(value);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleButtonClick = () => {
|
|
87
|
+
fileInputRef.current?.click();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const onSelectFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
91
|
+
const file = event.target.files?.[0];
|
|
92
|
+
|
|
93
|
+
if (!file) return;
|
|
94
|
+
|
|
95
|
+
const reader = new FileReader();
|
|
96
|
+
|
|
97
|
+
reader.onload = (e) => {
|
|
98
|
+
const content = e.target?.result as string;
|
|
99
|
+
const lines = content.split("\n");
|
|
100
|
+
|
|
101
|
+
if (_isFunction(onChange)) {
|
|
102
|
+
onChange(lines);
|
|
103
|
+
}
|
|
104
|
+
// setValues(lines);
|
|
105
|
+
};
|
|
106
|
+
reader.readAsText(file);
|
|
107
|
+
event.target.value = "";
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Grid className={Styles.inputPanel} container spacing={2} padding={2}>
|
|
112
|
+
<Grid size="grow">
|
|
113
|
+
{mode === "single" && <TextField
|
|
114
|
+
onKeyUp={onKeyUp}
|
|
115
|
+
fullWidth
|
|
116
|
+
label={t("youtubeUrl")}
|
|
117
|
+
variant="outlined"
|
|
118
|
+
value={value}
|
|
119
|
+
onChange={onSingleValueChange}
|
|
120
|
+
helperText={validationError}
|
|
121
|
+
error={!!validationError}
|
|
122
|
+
/>}
|
|
123
|
+
{mode === "multi" &&
|
|
124
|
+
<Autocomplete
|
|
125
|
+
multiple
|
|
126
|
+
freeSolo
|
|
127
|
+
fullWidth
|
|
128
|
+
limitTags={2}
|
|
129
|
+
options={[]}
|
|
130
|
+
value={values}
|
|
131
|
+
// onChange={(event, newValue) => setValues(newValue)}
|
|
132
|
+
onChange={onMultiValueChange}
|
|
133
|
+
defaultValue={[]}
|
|
134
|
+
renderTags={(value, getTagProps) => _map(value, (option, index) => (
|
|
135
|
+
<Chip
|
|
136
|
+
key={index}
|
|
137
|
+
label={option}
|
|
138
|
+
{...getTagProps({ index })}
|
|
139
|
+
onDelete={() => handleDelete(option)}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
renderInput={(params: AutocompleteRenderInputParams) => (
|
|
143
|
+
<TextField
|
|
144
|
+
{...params}
|
|
145
|
+
fullWidth
|
|
146
|
+
variant="outlined"
|
|
147
|
+
label={t("youtubeUrl")}
|
|
148
|
+
slotProps={{
|
|
149
|
+
input: {
|
|
150
|
+
...params.InputProps,
|
|
151
|
+
startAdornment: <>
|
|
152
|
+
<IconButton color="primary" onClick={handleButtonClick}>
|
|
153
|
+
<FolderIcon />
|
|
154
|
+
</IconButton>
|
|
155
|
+
{params.InputProps.startAdornment}
|
|
156
|
+
<input ref={fileInputRef} type="file" hidden onChange={onSelectFile} accept=".txt" />
|
|
157
|
+
</>
|
|
158
|
+
}
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
/>
|
|
163
|
+
}
|
|
164
|
+
</Grid>
|
|
165
|
+
<Grid>
|
|
166
|
+
<Stack direction="row" spacing={1}>
|
|
167
|
+
{album &&
|
|
168
|
+
<Button disabled={loading || _isEmpty(value) || !!validationError} title={t("clear")} variant="contained" disableElevation color="secondary" onClick={onClear}>
|
|
169
|
+
<ClearIcon />
|
|
170
|
+
</Button>
|
|
171
|
+
}
|
|
172
|
+
<Button disabled={loading || _isEmpty(value) || !!validationError} title={t("loadInfo")} variant="contained" disableElevation color="secondary" onClick={() => onLoadInfo(value)}>
|
|
173
|
+
<InfoIcon/>
|
|
174
|
+
</Button>
|
|
175
|
+
{showDownloadFailed &&
|
|
176
|
+
<Button disabled={loading || _isEmpty(value) || !!validationError} title={t("downloadFailed")} variant="contained" disableElevation color="secondary" onClick={onDownloadFailed}>
|
|
177
|
+
<ReplayIcon />
|
|
178
|
+
</Button>
|
|
179
|
+
}
|
|
180
|
+
<Button disabled={loading || _isEmpty(value) || !!validationError} title={t("download")} variant="contained" disableElevation color="secondary" onClick={() => onDownload(value)}>
|
|
181
|
+
<DownloadIcon/>
|
|
182
|
+
</Button>
|
|
183
|
+
</Stack>
|
|
184
|
+
</Grid>
|
|
185
|
+
</Grid>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export default InputPanel;
|