├── .env
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
├── stale.yml
└── workflows
│ ├── deploy-to-github.yml
│ └── release-to-amo.yml.disabled
├── .gitignore
├── .release-it.json
├── LICENSE
├── README.md
├── craco.config.js
├── package-lock.json
├── package.json
├── public
├── .nojekyll
├── _locales
│ └── en
│ │ └── messages.json
├── content.js
├── favicon.ico
├── icons
│ ├── 128.png
│ └── 256.png
├── index.html
├── listener.js
├── manifest.firefox.json
├── manifest.json
└── robots.txt
├── screenshots
├── 1280x800
│ ├── channels.png
│ ├── home-dark.png
│ ├── home.png
│ └── settings.png
├── channels.jpg
├── old
│ ├── all-channels-view.png
│ ├── first-launch.png
│ ├── popup.png
│ ├── settings.png
│ └── solo-channel-view.png
├── settings.jpg
├── soon.gif
├── video-player.jpg
├── youtube-viewer-dark.jpg
├── youtube-viewer.gif
└── youtube-viewer.jpg
├── scripts
└── sign-addon.js
├── src
├── App.tsx
├── __tests__
│ └── App.test.tsx
├── helpers
│ ├── file.ts
│ ├── logger.ts
│ ├── react.ts
│ ├── storage.ts
│ ├── utils.ts
│ └── webext.ts
├── hooks
│ ├── index.ts
│ ├── useDialog.ts
│ ├── useDidMountEffect.ts
│ ├── useForwardedRef.ts
│ ├── useGetChannelVideos
│ │ ├── index.ts
│ │ └── utils.ts
│ ├── useGrid.ts
│ ├── useInterval.ts
│ ├── usePrevious.ts
│ ├── useSelectorRef.ts
│ ├── useStateRef.ts
│ ├── useTimeout.ts
│ └── useWidth.ts
├── index.tsx
├── providers
│ ├── ChannelOptionsProvider.tsx
│ ├── ChannelVideosProvider.tsx
│ └── index.ts
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
├── store
│ ├── index.ts
│ ├── reducers
│ │ ├── app.ts
│ │ ├── channels.ts
│ │ ├── settings.ts
│ │ └── videos.ts
│ ├── selectors
│ │ ├── app.ts
│ │ ├── channels.ts
│ │ ├── settings.ts
│ │ └── videos.ts
│ ├── services
│ │ └── youtube
│ │ │ ├── api.ts
│ │ │ ├── endpoints.ts
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ ├── thunks
│ │ ├── channels.ts
│ │ └── videos.ts
│ └── utils
│ │ ├── index.ts
│ │ ├── misc.ts
│ │ ├── persist.ts
│ │ └── preload.ts
├── types
│ ├── Channel.ts
│ ├── Settings.ts
│ ├── Video.ts
│ ├── api.ts
│ ├── common.ts
│ ├── index.ts
│ ├── legacy.ts
│ └── webext.ts
└── ui
│ ├── components
│ ├── pages
│ │ ├── About
│ │ │ ├── Credit.tsx
│ │ │ ├── SocialLink.tsx
│ │ │ └── index.tsx
│ │ ├── Channels
│ │ │ ├── ChannelCard
│ │ │ │ ├── ChannelActions
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ChannelDialogs
│ │ │ │ │ ├── ChannelFiltersDialog
│ │ │ │ │ │ ├── Filter
│ │ │ │ │ │ │ ├── Input.ts
│ │ │ │ │ │ │ ├── MenuItem.tsx
│ │ │ │ │ │ │ ├── Select.tsx
│ │ │ │ │ │ │ └── index.tsx
│ │ │ │ │ │ ├── config.ts
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── RemoveChannelDialog.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ChannelPicture.tsx
│ │ │ │ ├── ChannelTitle.tsx
│ │ │ │ ├── DragHandle.tsx
│ │ │ │ ├── DraggableCard.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── ChannelList
│ │ │ │ ├── Actions.tsx
│ │ │ │ ├── ImportChannelsDialog.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── ChannelResults
│ │ │ │ ├── PickChannelActions.tsx
│ │ │ │ ├── PickChannelCard.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── NoChannels.tsx
│ │ │ └── index.tsx
│ │ ├── Home
│ │ │ ├── ChannelRenderer
│ │ │ │ ├── AllViewRenderer.tsx
│ │ │ │ ├── BookmarksViewRenderer.tsx
│ │ │ │ ├── ChannelDataHandler.tsx
│ │ │ │ ├── ChannelRenderer.tsx
│ │ │ │ ├── ChannelTitle
│ │ │ │ │ ├── ChannelAvatar.tsx
│ │ │ │ │ ├── ChannelExpandToggle.tsx
│ │ │ │ │ ├── ChannelLink.tsx
│ │ │ │ │ ├── ChannelName.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ChannelVideos
│ │ │ │ │ ├── GridItem.tsx
│ │ │ │ │ ├── LoadMore.tsx
│ │ │ │ │ ├── VideoCard
│ │ │ │ │ │ ├── Badges
│ │ │ │ │ │ │ ├── ArchivedBadge.tsx
│ │ │ │ │ │ │ ├── IgnoredBadge.tsx
│ │ │ │ │ │ │ ├── SeenBadge.tsx
│ │ │ │ │ │ │ └── index.tsx
│ │ │ │ │ │ ├── TopActions
│ │ │ │ │ │ │ ├── ArchiveAction.tsx
│ │ │ │ │ │ │ ├── BookmarkAction.tsx
│ │ │ │ │ │ │ ├── CopyLinkAction.tsx
│ │ │ │ │ │ │ ├── IgnoreAction.tsx
│ │ │ │ │ │ │ ├── SeenAction.tsx
│ │ │ │ │ │ │ ├── WatchLaterAction.tsx
│ │ │ │ │ │ │ └── index.tsx
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── VideoSkeleton.tsx
│ │ │ │ │ ├── config.ts
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── DefaultRenderer.tsx
│ │ │ │ ├── StaticRenderer.tsx
│ │ │ │ ├── WatchLaterViewRenderer.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ChannelsWrapper.tsx
│ │ │ ├── DisplayOptionsDialog
│ │ │ │ ├── ActiveViews.tsx
│ │ │ │ ├── ExtraVideoActions.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── NoChannels.tsx
│ │ │ ├── StyledTabs.tsx
│ │ │ ├── Tab
│ │ │ │ ├── Badge.ts
│ │ │ │ └── index.tsx
│ │ │ ├── TabActions
│ │ │ │ ├── AllViewActions
│ │ │ │ │ ├── AllViewOptions.tsx
│ │ │ │ │ ├── Menus
│ │ │ │ │ │ └── AllViewMoreActions.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── BookmarksViewActions
│ │ │ │ │ ├── BookmarksViewOptions.tsx
│ │ │ │ │ ├── Menus
│ │ │ │ │ │ └── BookmarksViewMoreActions.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── CommonMenus
│ │ │ │ │ ├── ViewChannelOptions.tsx
│ │ │ │ │ ├── ViewFilters.tsx
│ │ │ │ │ ├── ViewSorting.tsx
│ │ │ │ │ └── ViewVideosSeniority.tsx
│ │ │ │ ├── WatchLaterViewActions
│ │ │ │ │ ├── Menus
│ │ │ │ │ │ └── WatchLaterViewMoreActions.tsx
│ │ │ │ │ ├── WatchLaterViewOptions.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── TabPanel.tsx
│ │ │ ├── VideoPlayerDialog
│ │ │ │ ├── CloseButton.tsx
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ ├── Settings
│ │ │ ├── Alerts.tsx
│ │ │ ├── Field
│ │ │ │ ├── Input.ts
│ │ │ │ ├── MenuItem.tsx
│ │ │ │ ├── Secret.tsx
│ │ │ │ ├── Select.tsx
│ │ │ │ ├── Switch.ts
│ │ │ │ └── index.tsx
│ │ │ ├── SavedVideosOptions
│ │ │ │ ├── ClearVideosData.tsx
│ │ │ │ ├── ExportVideosData.tsx
│ │ │ │ ├── ImportVideosData.tsx
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ └── index.ts
│ ├── shared
│ │ ├── Alert
│ │ │ └── index.tsx
│ │ ├── CheckableMenuItem
│ │ │ └── index.tsx
│ │ ├── ConfirmationDialog
│ │ │ └── index.tsx
│ │ ├── ErrorAlert
│ │ │ └── index.tsx
│ │ ├── Layout
│ │ │ └── index.tsx
│ │ ├── LoadingSpinner
│ │ │ └── index.tsx
│ │ ├── Logo
│ │ │ └── index.tsx
│ │ ├── NestedMenuItem
│ │ │ └── index.tsx
│ │ ├── ProgressBar
│ │ │ ├── LinearProgress.ts
│ │ │ └── index.tsx
│ │ ├── RawHTML
│ │ │ └── index.tsx
│ │ ├── SearchInput
│ │ │ ├── Input.tsx
│ │ │ └── index.tsx
│ │ ├── Sidebar
│ │ │ ├── Actions
│ │ │ │ ├── HomeActions.tsx
│ │ │ │ ├── SettingsActions.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Header.tsx
│ │ │ ├── ListItemLink
│ │ │ │ ├── Badge.ts
│ │ │ │ ├── ListItem.ts
│ │ │ │ ├── ListItemText.ts
│ │ │ │ └── index.tsx
│ │ │ ├── WarningIcon.tsx
│ │ │ └── index.tsx
│ │ ├── StyledMenu
│ │ │ ├── StyledMenuList.tsx
│ │ │ └── index.tsx
│ │ └── index.ts
│ └── webext
│ │ ├── Background
│ │ ├── ChannelChecker.tsx
│ │ ├── ContextMenus.tsx
│ │ ├── EventsHandler.tsx
│ │ └── index.tsx
│ │ └── index.ts
│ └── theme
│ ├── fonts.ts
│ ├── index.ts
│ └── styles.css
├── tsconfig.json
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_NAME=YouTube viewer
2 | REACT_APP_VERSION=$npm_package_version
3 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: https://www.paypal.me/axeldev
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | Describe your issue here.
2 |
3 | ### Your environment
4 | * version of npm
5 | * version of node.js
6 | * version of reactjs
7 | * which browser and its version
8 |
9 | ### Steps to reproduce
10 | Tell us how to reproduce this issue. Please provide a working demo.
11 |
12 | ### Expected behaviour
13 | Tell us what should happen
14 |
15 | ### Actual behaviour
16 | Tell us what happens instead
17 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ### All Submissions:
2 |
3 | * [ ] Have you followed the guidelines in our [Contributing document](https://github.com/AXeL-dev/contributing)?
4 | * [ ] Have you checked to ensure there aren't other open [Pull Requests](../pulls) for the same update/change?
5 |
6 |
7 |
8 | ### New Feature Submissions:
9 |
10 | 1. [ ] Does your submission pass tests?
11 | 2. [ ] Have you lint your code locally prior to submission?
12 |
13 | ### Changes to Core Features:
14 |
15 | * [ ] Have you added an explanation of what your changes do and why you'd like us to include them?
16 | * [ ] Have you written new tests for your core changes, as applicable?
17 | * [ ] Have you successfully ran tests with your changes locally?
18 |
--------------------------------------------------------------------------------
/.github/stale.yml:
--------------------------------------------------------------------------------
1 | # Number of days of inactivity before an issue becomes stale
2 | daysUntilStale: 30
3 | # Number of days of inactivity before a stale issue is closed
4 | daysUntilClose: 7
5 | # Issues with these labels will never be considered stale
6 | exemptLabels:
7 | - pinned
8 | - security
9 | - bug
10 | - enhancement
11 | - todo
12 | - feature request
13 | - refactor
14 | - dependencies
15 | # Label to use when marking an issue as stale
16 | staleLabel: wontfix
17 | # Comment to post when marking an issue as stale. Set to `false` to disable
18 | markComment: >
19 | This issue has been automatically marked as stale because it has not had
20 | recent activity. It will be closed if no further activity occurs. Thank you
21 | for your contributions.
22 | # Comment to post when closing a stale issue. Set to `false` to disable
23 | closeComment: false
24 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-to-github.yml:
--------------------------------------------------------------------------------
1 | name: Build, Release and Deploy to Github pages
2 | on:
3 | push:
4 | tags:
5 | - 'v*.*.*'
6 | - 'force-release'
7 | jobs:
8 | build-and-deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout 🛎️
12 | uses: actions/checkout@v2.3.1 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
13 | with:
14 | persist-credentials: false
15 |
16 | - name: Setup node 🔨
17 | uses: actions/setup-node@v2
18 | with:
19 | node-version: 14
20 | registry-url: https://registry.npmjs.org/
21 |
22 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
23 | run: |
24 | yarn install
25 | yarn build:github
26 | yarn build:chrome
27 |
28 | - name: Package 📦
29 | run: |
30 | yarn package
31 | echo "::set-output name=PACKAGE_NAME::$(ls -d youtube_viewer-*.zip)"
32 | id: package
33 |
34 | - name: Upload to release 💾
35 | uses: meeDamian/github-release@2.0
36 | with:
37 | token: ${{ secrets.GITHUB_TOKEN }}
38 | files: ${{ steps.package.outputs.PACKAGE_NAME }}
39 | gzip: folders
40 | allow_override: true
41 |
42 | - name: Deploy 🚀
43 | uses: JamesIves/github-pages-deploy-action@3.6.2
44 | with:
45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 | BRANCH: gh-pages # The branch the action should deploy to.
47 | FOLDER: dist/github # The folder the action should deploy.
48 | CLEAN: true # Automatically remove deleted files from the deploy branch
49 |
--------------------------------------------------------------------------------
/.github/workflows/release-to-amo.yml.disabled:
--------------------------------------------------------------------------------
1 | name: Build and Release to AMO
2 | on:
3 | push:
4 | tags:
5 | - 'v*.*.*'
6 | jobs:
7 | build-and-release:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout 🛎️
11 | uses: actions/checkout@v2
12 |
13 | - name: Install and Build 🔧
14 | run: |
15 | npm install
16 | npm run build:firefox
17 |
18 | - name: Release to AMO 🚀
19 | run: cd dist/web-ext && npx web-ext-submit
20 | env:
21 | WEB_EXT_API_KEY: ${{ secrets.WEB_EXT_API_KEY }}
22 | WEB_EXT_API_SECRET: ${{ secrets.WEB_EXT_API_SECRET }}
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 | /dist
14 | /old
15 | /v6.x
16 | *.zip
17 | *.xpi
18 | *.crx
19 | *.pem
20 |
21 | # misc
22 | .DS_Store
23 | #.env
24 | .env.local
25 | .env.development.local
26 | .env.test.local
27 | .env.production.local
28 |
29 | npm-debug.log*
30 | yarn-debug.log*
31 | yarn-error.log*
32 |
--------------------------------------------------------------------------------
/.release-it.json:
--------------------------------------------------------------------------------
1 | {
2 | "src": {
3 | "tagName": "v%s"
4 | },
5 | "npm": {
6 | "publish": false
7 | },
8 | "git": {
9 | "tagName": "v${version}",
10 | "requireCleanWorkingDir": false,
11 | "commitMessage": "release: v${version} :tada:"
12 | },
13 | "github": {
14 | "release": true
15 | },
16 | "plugins": {
17 | "release-it-update-manifest-plugin": {
18 | "preset": "react"
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | const CopyPlugin = require('copy-webpack-plugin');
2 |
3 | module.exports = {
4 | webpack: {
5 | plugins: [
6 | new CopyPlugin({
7 | patterns: [
8 | {
9 | from: 'node_modules/webextension-polyfill/dist/browser-polyfill.min.js',
10 | to: 'static/js',
11 | },
12 | ],
13 | }),
14 | ],
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/public/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/public/.nojekyll
--------------------------------------------------------------------------------
/public/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extensionName": {
3 | "message": "YouTube viewer"
4 | },
5 | "extensionDesc": {
6 | "message": "Keep tracking your favorite youtube channels with less hassle"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/public/content.js:
--------------------------------------------------------------------------------
1 | /* global browser */
2 |
3 | (function () {
4 | // listen to background page messages
5 | const port = browser.runtime.connect({ name: 'youtube-viewer-cs' });
6 | port.onMessage.addListener(function (request) {
7 | window.postMessage({ from: 'content_script', request });
8 | });
9 |
10 | // inject window listener script
11 | const script = document.createElement('script');
12 | script.src = browser.runtime.getURL('listener.js');
13 | script.onload = function () {
14 | this.remove();
15 | };
16 | (document.head || document.documentElement).appendChild(script);
17 |
18 | // listen to window messages
19 | window.addEventListener('message', function (event) {
20 | const { from, request, response } = event.data;
21 |
22 | if (from !== 'page') {
23 | return;
24 | }
25 |
26 | port.postMessage({
27 | request,
28 | response,
29 | });
30 | });
31 | })();
32 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/public/favicon.ico
--------------------------------------------------------------------------------
/public/icons/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/public/icons/128.png
--------------------------------------------------------------------------------
/public/icons/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/public/icons/256.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 |
25 | YouTube viewer
26 |
27 |
28 |
29 |
30 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/public/listener.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | const channelUrlById = {};
3 |
4 | function getChannelId(url) {
5 | if (url.includes('youtube.com/watch?v=') || url.includes('youtu.be/')) {
6 | return ytInitialPlayerResponse.microformat.playerMicroformatRenderer
7 | .externalChannelId;
8 | } else if (
9 | url.includes('youtube.com/channel/') ||
10 | url.includes('youtube.com/c/')
11 | ) {
12 | return ytInitialData.metadata.channelMetadataRenderer.externalId;
13 | } else {
14 | return null;
15 | }
16 | }
17 |
18 | function sendChannelId(data, delay = 0) {
19 | setTimeout(() => {
20 | const url = window.location.href;
21 | let channelId = getChannelId(url);
22 | if (channelId) {
23 | if (!channelUrlById[channelId]) {
24 | channelUrlById[channelId] = url;
25 | } else if (url !== channelUrlById[channelId]) {
26 | channelId = null;
27 | }
28 | }
29 | window.postMessage({
30 | from: 'page',
31 | response: {
32 | channelId,
33 | },
34 | ...data,
35 | });
36 | }, delay);
37 | }
38 |
39 | // listen to content script messages
40 | window.addEventListener('message', function (event) {
41 | const { from, request } = event.data;
42 |
43 | if (from !== 'content_script') {
44 | return;
45 | }
46 |
47 | switch (request.message) {
48 | case 'getChannelId':
49 | sendChannelId({ request });
50 | break;
51 | default:
52 | // ToDo: add commands to control the youtube player (play/stop video, update video link, etc...)
53 | break;
54 | }
55 | });
56 |
57 | // send channel id to content script on page load
58 | sendChannelId();
59 |
60 | // observe window location changes
61 | // Note: this doesn't work properly since getChannelId() only gets the initial channel id
62 | // & not the channel id of the currently played video
63 | let prevLocation = window.location.href;
64 | const observer = new MutationObserver(function (mutations) {
65 | mutations.forEach(function (mutation) {
66 | if (prevLocation !== window.location.href) {
67 | prevLocation = window.location.href;
68 | sendChannelId();
69 | }
70 | });
71 | });
72 |
73 | const body = document.querySelector('body');
74 | observer.observe(body, {
75 | childList: true,
76 | subtree: true,
77 | });
78 | })();
79 |
--------------------------------------------------------------------------------
/public/manifest.firefox.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 |
4 | "browser_specific_settings": {
5 | "gecko": {
6 | "id": "{d5970ac0-2c7c-453d-af91-b660bd64f88b}",
7 | "strict_min_version": "52.0"
8 | }
9 | },
10 |
11 | "name": "__MSG_extensionName__",
12 | "version": "1.4.5",
13 |
14 | "default_locale": "en",
15 | "description": "__MSG_extensionDesc__",
16 | "icons": {
17 | "128": "icons/128.png",
18 | "256": "icons/256.png"
19 | },
20 |
21 | "web_accessible_resources": [
22 | "icons/*.png",
23 | "listener.js"
24 | ],
25 |
26 | "browser_action": {
27 | "default_icon": "icons/128.png"
28 | },
29 |
30 | "options_ui":{
31 | "page": "index.html#settings",
32 | "open_in_tab": true
33 | },
34 |
35 | "background": {
36 | "page": "index.html#background"
37 | },
38 |
39 | "content_scripts": [
40 | {
41 | "matches": ["*://*.youtube.com/*"],
42 | "js": [
43 | "static/js/browser-polyfill.min.js",
44 | "content.js"
45 | ]
46 | }
47 | ],
48 |
49 | "author": "AXeL-dev",
50 | "homepage_url": "https://github.com/AXeL-dev/youtube-viewer",
51 |
52 | "content_security_policy": "script-src 'self' https://www.google-analytics.com/ https://www.youtube.com/ https://s.ytimg.com; object-src 'self'; child-src https://www.youtube.com/ https://s.ytimg.com",
53 |
54 | "permissions": [
55 | "storage",
56 | "notifications",
57 | "tabs",
58 | "contextMenus"
59 | ]
60 | }
61 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 |
4 | "name": "__MSG_extensionName__",
5 | "version": "1.4.5",
6 |
7 | "default_locale": "en",
8 | "description": "__MSG_extensionDesc__",
9 | "icons": {
10 | "128": "icons/128.png",
11 | "256": "icons/256.png"
12 | },
13 |
14 | "web_accessible_resources": [
15 | "icons/*.png",
16 | "listener.js"
17 | ],
18 |
19 | "browser_action": {
20 | "default_icon": "icons/128.png"
21 | },
22 |
23 | "options_ui":{
24 | "page": "index.html#settings",
25 | "open_in_tab": true
26 | },
27 |
28 | "background": {
29 | "page": "index.html#background"
30 | },
31 |
32 | "content_scripts": [
33 | {
34 | "matches": ["*://*.youtube.com/*"],
35 | "js": [
36 | "static/js/browser-polyfill.min.js",
37 | "content.js"
38 | ]
39 | }
40 | ],
41 |
42 | "author": "AXeL-dev",
43 | "homepage_url": "https://github.com/AXeL-dev/youtube-viewer",
44 |
45 | "content_security_policy": "script-src 'self' https://www.google-analytics.com/ https://www.youtube.com/ https://s.ytimg.com; object-src 'self'; child-src https://www.youtube.com/ https://s.ytimg.com",
46 |
47 | "permissions": [
48 | "storage",
49 | "notifications",
50 | "tabs",
51 | "contextMenus"
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/screenshots/1280x800/channels.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/1280x800/channels.png
--------------------------------------------------------------------------------
/screenshots/1280x800/home-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/1280x800/home-dark.png
--------------------------------------------------------------------------------
/screenshots/1280x800/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/1280x800/home.png
--------------------------------------------------------------------------------
/screenshots/1280x800/settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/1280x800/settings.png
--------------------------------------------------------------------------------
/screenshots/channels.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/channels.jpg
--------------------------------------------------------------------------------
/screenshots/old/all-channels-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/old/all-channels-view.png
--------------------------------------------------------------------------------
/screenshots/old/first-launch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/old/first-launch.png
--------------------------------------------------------------------------------
/screenshots/old/popup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/old/popup.png
--------------------------------------------------------------------------------
/screenshots/old/settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/old/settings.png
--------------------------------------------------------------------------------
/screenshots/old/solo-channel-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/old/solo-channel-view.png
--------------------------------------------------------------------------------
/screenshots/settings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/settings.jpg
--------------------------------------------------------------------------------
/screenshots/soon.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/soon.gif
--------------------------------------------------------------------------------
/screenshots/video-player.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/video-player.jpg
--------------------------------------------------------------------------------
/screenshots/youtube-viewer-dark.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/youtube-viewer-dark.jpg
--------------------------------------------------------------------------------
/screenshots/youtube-viewer.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/youtube-viewer.gif
--------------------------------------------------------------------------------
/screenshots/youtube-viewer.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AXeL-dev/youtube-viewer/db02d81f873ba17a658d51a78055b1ad68ef1905/screenshots/youtube-viewer.jpg
--------------------------------------------------------------------------------
/scripts/sign-addon.js:
--------------------------------------------------------------------------------
1 | // @source: https://github.com/mozilla/sign-addon
2 |
3 | const { signAddon } = require('sign-addon');
4 | const fs = require('fs');
5 |
6 | const manifestJson = fs.readFileSync('./public/manifest.firefox.json');
7 | const manifest = JSON.parse(manifestJson);
8 |
9 | signAddon({
10 | // Required arguments:
11 |
12 | xpiPath: `./youtube_viewer-${manifest.version}.zip`,
13 | version: manifest.version,
14 | apiKey: process.env.AMO_JWT_ISSUER,
15 | apiSecret: process.env.AMO_JWT_SECRET,
16 |
17 | // Optional arguments:
18 |
19 | // The explicit extension ID.
20 | // WebExtensions do not require an ID.
21 | // See the notes below about dealing with IDs.
22 | id: manifest.browser_specific_settings.gecko.id,
23 | // The release channel (listed or unlisted).
24 | // Ignored for new add-ons, which are always unlisted.
25 | // Default: most recently used channel.
26 | channel: 'unlisted',
27 | // Save downloaded files to this directory.
28 | // Default: current working directory.
29 | //downloadDir: undefined,
30 | // Number of milliseconds to wait before aborting the request.
31 | // Default: 15 minutes.
32 | //timeout: undefined,
33 | // Optional proxy to use for all API requests,
34 | // such as "http://yourproxy:6000"
35 | // Read this for details on how proxy requests work:
36 | // https://github.com/request/request#proxies
37 | //apiProxy: undefined,
38 | // Optional object to pass to request() for additional configuration.
39 | // Some properties such as 'url' cannot be defined here.
40 | // Available options:
41 | // https://github.com/request/request#requestoptions-callback
42 | //apiRequestConfig: undefined,
43 | // Optional override to the number of seconds until the JWT token for
44 | // the API request expires. This must match the expiration time that
45 | // the API server accepts.
46 | //apiJwtExpiresIn: undefined,
47 | // Optional override to the URL prefix of the signing API.
48 | // The production instance of the API will be used by default.
49 | apiUrlPrefix: 'https://addons.mozilla.org/api/v4',
50 | })
51 | .then(function (result) {
52 | if (result.success) {
53 | console.log('The following signed files were downloaded:');
54 | console.log(result.downloadedFiles);
55 | console.log('Your extension ID is:');
56 | console.log(result.id);
57 | } else {
58 | console.error('Your add-on could not be signed!');
59 | console.error('Error code: ' + result.errorCode);
60 | console.error('Details: ' + result.errorDetails);
61 | }
62 | console.log(result.success ? 'SUCCESS' : 'FAIL');
63 | })
64 | .catch(function (error) {
65 | console.error('Signing error:', error);
66 | });
67 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | HashRouter as Router,
4 | Switch,
5 | Route,
6 | Redirect,
7 | } from 'react-router-dom';
8 | import { Home, Channels, Settings, About } from 'ui/components/pages';
9 | import { Background } from 'ui/components/webext';
10 | import { ThemeProvider } from '@mui/material/styles';
11 | import CssBaseline from '@mui/material/CssBaseline';
12 | import useTheme from 'ui/theme';
13 | import { useAppSelector } from 'store';
14 | import { selectMode } from 'store/selectors/settings';
15 | import { ChannelVideosProvider } from 'providers';
16 |
17 | function App() {
18 | const mode = useAppSelector(selectMode);
19 | const theme = useTheme(mode);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | export default App;
41 |
--------------------------------------------------------------------------------
/src/__tests__/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import App from '../App';
4 |
5 | test('renders correctly', () => {
6 | const { asFragment } = render();
7 | expect(asFragment()).toMatchSnapshot();
8 | });
9 |
--------------------------------------------------------------------------------
/src/helpers/file.ts:
--------------------------------------------------------------------------------
1 | export function downloadFile(blob: Blob, filename: string) {
2 | if ((window.navigator as any).msSaveOrOpenBlob) {
3 | // IE10+
4 | (window.navigator as any).msSaveOrOpenBlob(blob, filename);
5 | } else {
6 | // Others
7 | const a = document.createElement('a'),
8 | url = URL.createObjectURL(blob);
9 | a.href = url;
10 | a.download = filename;
11 | document.body.appendChild(a);
12 | a.click();
13 | setTimeout(function () {
14 | document.body.removeChild(a);
15 | window.URL.revokeObjectURL(url);
16 | }, 0);
17 | }
18 | }
19 |
20 | export function readFile(file: Blob) {
21 | return new Promise(function (resolve, reject) {
22 | const fileReader = new FileReader();
23 | fileReader.readAsText(file, 'UTF-8');
24 | fileReader.onload = function () {
25 | resolve(fileReader.result);
26 | };
27 | fileReader.onerror = function (error) {
28 | reject(error);
29 | };
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/src/helpers/logger.ts:
--------------------------------------------------------------------------------
1 | const { REACT_APP_DEBUG } = process.env;
2 |
3 | function time() {
4 | const now = new Date();
5 | return `[${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}]`;
6 | }
7 |
8 | export function log(message: any, ...params: any) {
9 | if (REACT_APP_DEBUG) {
10 | console.log(time(), message, ...params);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/helpers/react.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const childrenWithProps = (children: React.ReactNode, props: object) =>
4 | React.Children.map(children, (child) => {
5 | if (React.isValidElement(child)) {
6 | return React.cloneElement(child, props);
7 | }
8 | return child;
9 | });
10 |
--------------------------------------------------------------------------------
/src/helpers/storage.ts:
--------------------------------------------------------------------------------
1 | import { Nullable } from 'types';
2 | //import { browser } from "webextension-polyfill-ts";
3 |
4 | declare var browser: any;
5 |
6 | /**
7 | * Get data from storage
8 | *
9 | * e.g.: get('key1', 'key2', ...)
10 | *
11 | * @param keys
12 | */
13 | async function get(...keys: string[]) {
14 | try {
15 | const result = await browser.storage.local.get(keys);
16 | return keys.length > 1 ? result : result[keys[0]];
17 | } catch (error) {
18 | const result: { [key: string]: string } = {};
19 | for (const key of keys) {
20 | const value = localStorage.getItem(key);
21 | result[key] = parse(value);
22 | }
23 | return keys.length > 1 ? result : result[keys[0]];
24 | }
25 | }
26 |
27 | function parse(value: Nullable) {
28 | if (!value) {
29 | return value;
30 | }
31 | try {
32 | return JSON.parse(value);
33 | } catch (error) {
34 | return value;
35 | }
36 | }
37 |
38 | /**
39 | * Save data to storage
40 | *
41 | * e.g.: save({ key1: value1, key2: value2, ... })
42 | *
43 | * @param values
44 | */
45 | function save(values: { [key: string]: any }) {
46 | try {
47 | browser.storage.local.set(values);
48 | } catch (error) {
49 | const keys = Object.keys(values);
50 | for (const key of keys) {
51 | const value = stringify(values[key]);
52 | localStorage.setItem(key, value);
53 | }
54 | }
55 | }
56 |
57 | function stringify(value: any) {
58 | try {
59 | return JSON.stringify(value);
60 | } catch (error) {
61 | return value;
62 | }
63 | }
64 |
65 | const storage = {
66 | get,
67 | save,
68 | };
69 |
70 | export default storage;
71 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './usePrevious';
2 | export * from './useWidth';
3 | export * from './useGrid';
4 | export * from './useDidMountEffect';
5 | export * from './useTimeout';
6 | export * from './useInterval';
7 | export * from './useForwardedRef';
8 | export * from './useStateRef';
9 | export * from './useSelectorRef';
10 | export * from './useGetChannelVideos';
11 | export * from './useDialog';
12 |
--------------------------------------------------------------------------------
/src/hooks/useDialog.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Nullable } from 'types';
3 |
4 | type DialogName = Nullable;
5 |
6 | export function useDialog(defaultDialog: DialogName = null) {
7 | const [openedDialog, setOpenedDialog] = useState(defaultDialog);
8 |
9 | const openDialog = (dialog: string) => {
10 | setOpenedDialog(dialog);
11 | };
12 |
13 | const closeDialog = () => {
14 | setOpenedDialog(null);
15 | };
16 |
17 | return {
18 | openedDialog,
19 | openDialog,
20 | closeDialog,
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/src/hooks/useDidMountEffect.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, EffectCallback, DependencyList } from 'react';
2 |
3 | export function useDidMountEffect(
4 | effect: EffectCallback,
5 | deps?: DependencyList,
6 | ) {
7 | const didMount = useRef(false);
8 |
9 | useEffect(() => {
10 | if (didMount.current) {
11 | if (effect) {
12 | return effect();
13 | }
14 | } else {
15 | didMount.current = true;
16 | }
17 | // eslint-disable-next-line react-hooks/exhaustive-deps
18 | }, deps);
19 |
20 | return didMount.current;
21 | }
22 |
--------------------------------------------------------------------------------
/src/hooks/useForwardedRef.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, ForwardedRef } from 'react';
2 |
3 | export function useForwardedRef(ref: ForwardedRef) {
4 | const innerRef = useRef(null);
5 |
6 | useEffect(() => {
7 | if (!ref) return;
8 | if (typeof ref === 'function') {
9 | ref(innerRef.current);
10 | } else {
11 | ref.current = innerRef.current;
12 | }
13 | });
14 |
15 | return innerRef;
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/useGetChannelVideos/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Video,
3 | ChannelFilterOperator,
4 | ViewFilter,
5 | VideoFlag,
6 | VideoCache,
7 | ViewFilters,
8 | } from 'types';
9 |
10 | const filter2Flag = (key: ViewFilter): VideoFlag => {
11 | switch (key) {
12 | case 'watchLater':
13 | return 'toWatchLater';
14 | default:
15 | return key as VideoFlag;
16 | }
17 | };
18 |
19 | export const filterVideoByFlags = (video: VideoCache, filters: ViewFilters) => {
20 | const filterKeys = Object.keys(filters) as ViewFilter[];
21 | const filterKeysWithFlag = filterKeys.filter(
22 | (key) => !['others'].includes(key),
23 | );
24 | const hasFlag = (key: ViewFilter) => {
25 | const flag = filter2Flag(key);
26 | return video.flags[flag];
27 | };
28 | return filterKeys.some((key) => {
29 | switch (key) {
30 | case 'others':
31 | return (
32 | filters.others && filterKeysWithFlag.every((key) => !hasFlag(key))
33 | );
34 | default:
35 | return filters[key] && hasFlag(key);
36 | }
37 | });
38 | };
39 |
40 | export const parseVideoField = (video: Video, field: string) => {
41 | const parsed = video[field as keyof Video];
42 | switch (field) {
43 | case 'duration': {
44 | const minutes = (parsed as string).split(':')[0];
45 | return +minutes;
46 | }
47 | default:
48 | return parsed;
49 | }
50 | };
51 |
52 | export const evaluateField = (
53 | field: string | number,
54 | operator: ChannelFilterOperator,
55 | value: string | number,
56 | ) => {
57 | switch (operator) {
58 | default:
59 | case ChannelFilterOperator.Equal:
60 | return field === value;
61 | case ChannelFilterOperator.NotEqual:
62 | return field !== value;
63 | case ChannelFilterOperator.GreatherThan:
64 | return field > value;
65 | case ChannelFilterOperator.GreatherThanOrEqual:
66 | return field >= value;
67 | case ChannelFilterOperator.LowerThan:
68 | return field < value;
69 | case ChannelFilterOperator.LowerThanOrEqual:
70 | return field <= value;
71 | case ChannelFilterOperator.Contains:
72 | return (field as string).includes(value as string);
73 | case ChannelFilterOperator.NotContains:
74 | return !(field as string).includes(value as string);
75 | case ChannelFilterOperator.StartsWith:
76 | return (field as string).startsWith(value as string);
77 | case ChannelFilterOperator.EndsWith:
78 | return (field as string).endsWith(value as string);
79 | }
80 | };
81 |
82 | export const recalculateTotalAndCount = (
83 | count: number,
84 | oldCount: number,
85 | oldTotal: number,
86 | maxResults: number | undefined,
87 | ) => {
88 | let total = oldTotal;
89 | if (count < oldCount && maxResults && count < maxResults) {
90 | total = count;
91 | } else {
92 | total = oldTotal - (oldCount - count);
93 | }
94 | return [total, count];
95 | };
96 |
--------------------------------------------------------------------------------
/src/hooks/useGrid.ts:
--------------------------------------------------------------------------------
1 | import { useWidth } from './useWidth';
2 |
3 | interface GridColumns {
4 | [key: string]: number;
5 | }
6 |
7 | export function useGrid(columns: GridColumns) {
8 | const width = useWidth(null);
9 |
10 | return {
11 | itemsPerRow: width ? columns[width] : undefined,
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/src/hooks/useInterval.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { Nullable } from 'types';
3 |
4 | export function useInterval(callback: () => void, delay: Nullable) {
5 | const savedCallback = useRef(callback);
6 |
7 | useEffect(() => {
8 | savedCallback.current = callback;
9 | }, [callback]);
10 |
11 | useEffect(() => {
12 | if (delay === null) {
13 | return;
14 | }
15 | const id = setInterval(() => savedCallback.current(), delay);
16 | return () => clearInterval(id);
17 | }, [delay]);
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/usePrevious.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 |
3 | export function usePrevious(value: T): T | undefined {
4 | const ref = useRef();
5 |
6 | useEffect(() => {
7 | ref.current = value;
8 | }, [value]);
9 |
10 | return ref.current;
11 | }
12 |
--------------------------------------------------------------------------------
/src/hooks/useSelectorRef.ts:
--------------------------------------------------------------------------------
1 | import { MutableRefObject } from 'react';
2 | import { EqualityFn, NoInfer } from 'react-redux';
3 | import { RootState, useAppSelector } from 'store';
4 | import { useStateRef } from './useStateRef';
5 |
6 | interface UseSelectorRefHook {
7 | (
8 | selector: (state: TState) => TSelected,
9 | equalityFn?: EqualityFn>,
10 | ): MutableRefObject;
11 | }
12 |
13 | export const useSelectorRef: UseSelectorRefHook = (...params) => {
14 | const selected = useAppSelector(...params);
15 | const ref = useStateRef(selected);
16 | return ref;
17 | };
18 |
--------------------------------------------------------------------------------
/src/hooks/useStateRef.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 |
3 | export function useStateRef(state: T) {
4 | const ref = useRef(state);
5 |
6 | useEffect(() => {
7 | ref.current = state;
8 | }, [state]);
9 |
10 | return ref; // should be ref & not ref.current
11 | }
12 |
--------------------------------------------------------------------------------
/src/hooks/useTimeout.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { Nullable } from 'types';
3 |
4 | export function useTimeout(callback: () => void, delay: Nullable) {
5 | const savedCallback = useRef(callback);
6 |
7 | useEffect(() => {
8 | savedCallback.current = callback;
9 | }, [callback]);
10 |
11 | useEffect(() => {
12 | if (delay === null) {
13 | return;
14 | }
15 | const id = setTimeout(() => savedCallback.current(), delay);
16 | return () => clearTimeout(id);
17 | }, [delay]);
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useWidth.ts:
--------------------------------------------------------------------------------
1 | import { Breakpoint, useTheme } from '@mui/material/styles';
2 | import useMediaQuery from '@mui/material/useMediaQuery';
3 | import { Nullable } from 'types';
4 |
5 | /**
6 | * Be careful using this hook. It only works because the number of
7 | * breakpoints in theme is static. It will break once you change the number of
8 | * breakpoints. See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
9 | */
10 | export function useWidth(defaultWidth: Nullable = 'xs') {
11 | const theme = useTheme();
12 | const keys = [...theme.breakpoints.keys].reverse();
13 |
14 | return (
15 | keys.reduce((output: Nullable, key: Breakpoint) => {
16 | // eslint-disable-next-line react-hooks/rules-of-hooks
17 | const matches = useMediaQuery(theme.breakpoints.up(key));
18 | return !output && matches ? key : output;
19 | }, null) || defaultWidth
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import reportWebVitals from './reportWebVitals';
5 | import { Provider } from 'react-redux';
6 | import store from 'store';
7 |
8 | ReactDOM.render(
9 |
10 |
11 |
12 |
13 | ,
14 | document.getElementById('root'),
15 | );
16 |
17 | // If you want to start measuring performance in your app, pass a function
18 | // to log results (for example: reportWebVitals(console.log))
19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
20 | reportWebVitals();
21 |
--------------------------------------------------------------------------------
/src/providers/ChannelOptionsProvider.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import { createContext, FC, memo, useContext, useMemo, useState } from 'react';
3 | import { useAppSelector } from 'store';
4 | import { selectViewChannelOption } from 'store/selectors/settings';
5 | import { HomeView } from 'types';
6 |
7 | type ChannelOptionsContextType = {
8 | collapseByDefault: boolean;
9 | collapsed: boolean;
10 | setCollapsed: (value: boolean) => void;
11 | };
12 |
13 | const ChannelOptionsContext = createContext<
14 | ChannelOptionsContextType | undefined
15 | >(undefined);
16 |
17 | export const ChannelOptionsProvider: FC<{ view: HomeView }> = memo(
18 | ({ view, children }) => {
19 | const collapseByDefault = useAppSelector(
20 | selectViewChannelOption(view, 'collapseByDefault'),
21 | );
22 | const [collapsed, setCollapsed] = useState(collapseByDefault);
23 |
24 | const value = useMemo(
25 | () => ({
26 | collapseByDefault,
27 | collapsed: collapseByDefault && collapsed,
28 | setCollapsed,
29 | }),
30 | [collapseByDefault, collapsed],
31 | );
32 |
33 | return (
34 |
35 | {children}
36 |
37 | );
38 | },
39 | );
40 |
41 | export function useChannelOptions(): ChannelOptionsContextType {
42 | const context = useContext(ChannelOptionsContext);
43 |
44 | if (context === undefined) {
45 | throw new Error(
46 | 'useChannelOptions must be used within a ChannelOptionsContext',
47 | );
48 | }
49 |
50 | return context;
51 | }
52 |
--------------------------------------------------------------------------------
/src/providers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ChannelVideosProvider';
2 | export * from './ChannelOptionsProvider';
3 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
3 | import settingsReducer from './reducers/settings';
4 | import channelsReducer from './reducers/channels';
5 | import videosReducer from './reducers/videos';
6 | import appReducer from './reducers/app';
7 | import { debounce } from 'helpers/utils';
8 | import { youtubeApi } from './services/youtube';
9 | import { preloadState, persistState } from './utils';
10 |
11 | const store = configureStore({
12 | reducer: {
13 | settings: settingsReducer,
14 | channels: channelsReducer,
15 | videos: videosReducer,
16 | app: appReducer,
17 | [youtubeApi.reducerPath]: youtubeApi.reducer,
18 | },
19 | middleware: (getDefaultMiddleware) =>
20 | getDefaultMiddleware().concat(youtubeApi.middleware),
21 | });
22 |
23 | store.subscribe(
24 | debounce(() => {
25 | persistState(store.getState());
26 | }, 1000),
27 | );
28 |
29 | (async () => await preloadState())();
30 |
31 | export type RootState = ReturnType;
32 | export type AppDispatch = typeof store.dispatch;
33 | export const useAppDispatch = () => useDispatch();
34 | export const useAppSelector: TypedUseSelectorHook = useSelector;
35 | export { storageKey, dispatch } from './utils';
36 |
37 | export default store;
38 |
--------------------------------------------------------------------------------
/src/store/reducers/app.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 |
3 | interface AppState {
4 | loaded: boolean;
5 | }
6 |
7 | const initialState: AppState = {
8 | loaded: false,
9 | };
10 |
11 | export const appSlice = createSlice({
12 | name: 'app',
13 | initialState,
14 | reducers: {
15 | setApp: (state, action: PayloadAction>) => {
16 | return {
17 | ...state,
18 | ...action.payload,
19 | };
20 | },
21 | },
22 | });
23 |
24 | export const { setApp } = appSlice.actions;
25 |
26 | export default appSlice.reducer;
27 |
--------------------------------------------------------------------------------
/src/store/selectors/app.ts:
--------------------------------------------------------------------------------
1 | import type { RootState } from 'store';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { selectSettings } from './settings';
4 |
5 | export const selectApp = (state: RootState) => state.app;
6 |
7 | export const selectIsSetupRequired = createSelector(
8 | selectApp,
9 | selectSettings,
10 | (app, settings) => app.loaded && !settings.apiKey,
11 | );
12 |
--------------------------------------------------------------------------------
/src/store/selectors/channels.ts:
--------------------------------------------------------------------------------
1 | import type { RootState } from 'store';
2 | import { createSelector } from '@reduxjs/toolkit';
3 | import { Channel, HomeView } from 'types';
4 |
5 | export const selectChannels = (state: RootState) => state.channels.list;
6 |
7 | export const selectChannelsByView = (view: HomeView) =>
8 | createSelector(selectChannels, (channels) => {
9 | switch (view) {
10 | case HomeView.All:
11 | return channels.filter(({ isHidden }) => !isHidden);
12 | default:
13 | return channels;
14 | }
15 | });
16 |
17 | export const selectActiveChannels = createSelector(selectChannels, (channels) =>
18 | channels.filter(({ isHidden }) => !isHidden),
19 | );
20 |
21 | export const selectHiddenChannels = createSelector(selectChannels, (channels) =>
22 | channels.filter(({ isHidden }) => isHidden),
23 | );
24 |
25 | export const selectNotificationEnabledChannels = createSelector(
26 | selectChannels,
27 | (channels) =>
28 | channels.filter(
29 | ({ isHidden, notifications }) => !isHidden && !notifications?.isDisabled,
30 | ),
31 | );
32 |
33 | export const selectChannelsCountByView = (view: HomeView) =>
34 | createSelector(selectChannelsByView(view), (channels) => channels.length);
35 |
36 | export const selectActiveChannelsCount = createSelector(
37 | selectActiveChannels,
38 | (channels) => channels.length,
39 | );
40 |
41 | export const selectChannelsCount = createSelector(
42 | selectChannels,
43 | selectActiveChannels,
44 | (channels, activeChannels) =>
45 | channels.length === activeChannels.length
46 | ? channels.length
47 | : `${activeChannels.length}/${channels.length}`,
48 | );
49 |
50 | export const selectChannel = (channel: Channel) =>
51 | createSelector(selectChannels, (channels) =>
52 | channels.find(({ id }) => id === channel.id),
53 | );
54 |
--------------------------------------------------------------------------------
/src/store/services/youtube/api.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createApi,
3 | FetchArgs,
4 | fetchBaseQuery,
5 | } from '@reduxjs/toolkit/query/react';
6 | import { BaseQueryApi } from '@reduxjs/toolkit/dist/query/baseQueryTypes';
7 | import store, { RootState } from 'store';
8 | import { FetchError, Settings } from 'types';
9 |
10 | const defaultBaseQuery = fetchBaseQuery({
11 | baseUrl: 'https://www.googleapis.com/youtube/v3/',
12 | prepareHeaders: (headers, { getState }) => {
13 | const apiKey = (getState() as RootState).settings.apiKey;
14 | if (apiKey) {
15 | headers.set('X-Goog-Api-Key', apiKey);
16 | }
17 | return headers;
18 | },
19 | });
20 |
21 | export interface BaseQueryExtraOptions {
22 | timeout?: number;
23 | }
24 |
25 | const baseQuery = (
26 | args: string | FetchArgs,
27 | api: BaseQueryApi,
28 | extraOptions: BaseQueryExtraOptions = {},
29 | ) =>
30 | Promise.race([
31 | defaultBaseQuery(args, api, extraOptions),
32 | new Promise((resolve) => {
33 | const { queryTimeout = 10000 } = store.getState().settings as Settings;
34 | return setTimeout(
35 | () =>
36 | resolve({
37 | error: { status: 'FETCH_ERROR', error: FetchError.TIMEOUT },
38 | }),
39 | extraOptions.timeout ?? queryTimeout,
40 | );
41 | }) as ReturnType,
42 | ]);
43 |
44 | export const youtubeApi = createApi({
45 | reducerPath: 'youtubeApi',
46 | baseQuery,
47 | endpoints: () => ({}),
48 | });
49 |
--------------------------------------------------------------------------------
/src/store/services/youtube/index.ts:
--------------------------------------------------------------------------------
1 | export * from './api';
2 | export * from './endpoints';
3 | export * from './utils';
4 |
--------------------------------------------------------------------------------
/src/store/services/youtube/utils.ts:
--------------------------------------------------------------------------------
1 | import { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query';
2 | import { FetchError } from 'types';
3 |
4 | export const isFetchTimeoutError = (err: FetchBaseQueryError | undefined) =>
5 | err && err.status === 'FETCH_ERROR' && err.error === FetchError.TIMEOUT;
6 |
--------------------------------------------------------------------------------
/src/store/thunks/channels.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 | import { RootState } from 'store';
3 | import { Channel } from 'types';
4 | import { addChannel } from '../reducers/channels';
5 | import { extendedApi } from '../services/youtube';
6 |
7 | export const fetchChannelById = createAsyncThunk<
8 | Channel | undefined,
9 | { id: string },
10 | { state: RootState }
11 | >('channels/fetchChannelById', async ({ id }, { dispatch }) => {
12 | const result = dispatch(
13 | extendedApi.endpoints.findChannelById.initiate({ id, maxResults: 1 }),
14 | );
15 | result.unsubscribe();
16 | const response = await result;
17 | const channel = response.data?.items[0];
18 |
19 | return channel;
20 | });
21 |
22 | export const addChannelById = createAsyncThunk<
23 | void,
24 | { id: string; hide?: boolean },
25 | { state: RootState }
26 | >('channels/addChannelById', async ({ id, hide }, { getState, dispatch }) => {
27 | // check if channel exists
28 | const { channels } = getState();
29 | const found = channels.list.find((channel: Channel) => channel.id === id);
30 | if (found) {
31 | return;
32 | }
33 |
34 | // fetch channel by id
35 | const channel = await dispatch(fetchChannelById({ id })).unwrap();
36 |
37 | // save to channels list
38 | if (channel) {
39 | dispatch(
40 | addChannel({
41 | ...channel,
42 | isHidden: !!hide,
43 | }),
44 | );
45 | }
46 | });
47 |
--------------------------------------------------------------------------------
/src/store/thunks/videos.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 | import { RootState } from 'store';
3 | import { Video, VideoCache, VideoFlags } from 'types';
4 | import { addVideo } from '../reducers/videos';
5 | import { extendedApi, GetVideosByIdArgs } from '../services/youtube';
6 | import { addChannelById } from './channels';
7 |
8 | export const fetchVideosById = createAsyncThunk<
9 | Video[],
10 | GetVideosByIdArgs,
11 | { state: RootState }
12 | >('videos/fetchVideosById', async (payload, { dispatch }) => {
13 | const result = dispatch(
14 | extendedApi.endpoints.getVideosById.initiate(payload),
15 | );
16 | result.unsubscribe();
17 | const response = await result;
18 | const videos = response.data?.items || [];
19 |
20 | return videos;
21 | });
22 |
23 | export const addVideoById = createAsyncThunk<
24 | void,
25 | { id: string; flags?: VideoFlags; hideChannel?: boolean },
26 | { state: RootState }
27 | >(
28 | 'videos/addVideoById',
29 | async ({ id, flags, hideChannel }, { getState, dispatch }) => {
30 | // check if video exists
31 | const { videos } = getState();
32 | let video: Video | VideoCache | undefined = videos.list.find(
33 | (video) => video.id === id,
34 | );
35 |
36 | if (!video) {
37 | // fetch video by id
38 | const result = await dispatch(
39 | fetchVideosById({ ids: [id], maxResults: 1 }),
40 | ).unwrap();
41 | video = result[0];
42 | }
43 |
44 | // save to videos list
45 | if (video) {
46 | dispatch(
47 | addVideo({
48 | video,
49 | flags,
50 | }),
51 | );
52 | // ensure to add channel too (if it does not exist)
53 | dispatch(addChannelById({ id: video.channelId, hide: hideChannel }));
54 | }
55 | },
56 | );
57 |
--------------------------------------------------------------------------------
/src/store/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './persist';
2 | export * from './preload';
3 | export * from './misc';
4 |
--------------------------------------------------------------------------------
/src/store/utils/misc.ts:
--------------------------------------------------------------------------------
1 | import { EqualityFn } from 'react-redux';
2 |
3 | export const jsonEqualityFn: EqualityFn = (left, right) =>
4 | JSON.stringify(left) === JSON.stringify(right);
5 |
--------------------------------------------------------------------------------
/src/store/utils/persist.ts:
--------------------------------------------------------------------------------
1 | import store, { RootState } from 'store';
2 | import storage from 'helpers/storage';
3 | import { log } from 'helpers/logger';
4 |
5 | export const storageKey = 'APP_YOUTUBE_VIEWER';
6 |
7 | let canPersistState = true;
8 | let prevSerializedState = '';
9 |
10 | type DispatchParams = Parameters;
11 |
12 | export const dispatch = (
13 | action: DispatchParams[0],
14 | persist: boolean = false,
15 | ) => {
16 | canPersistState = persist;
17 | store.dispatch(action);
18 | };
19 |
20 | export const persistState = (state: RootState, onlyIfChanged?: boolean) => {
21 | log('Persist state:', {
22 | canPersistState,
23 | state,
24 | });
25 | if (!canPersistState) {
26 | canPersistState = true;
27 | return;
28 | }
29 | const { settings, channels, videos } = state;
30 | if (onlyIfChanged) {
31 | const serializedState = JSON.stringify({ settings, channels, videos });
32 | if (prevSerializedState === serializedState) {
33 | log('State did not change!');
34 | return;
35 | }
36 | prevSerializedState = serializedState;
37 | }
38 | storage.save({
39 | [storageKey]: {
40 | settings,
41 | channels,
42 | videos,
43 | },
44 | });
45 | };
46 |
--------------------------------------------------------------------------------
/src/types/Channel.ts:
--------------------------------------------------------------------------------
1 | export interface Channel {
2 | id: string;
3 | thumbnail: string;
4 | title: string;
5 | url: string;
6 | description: string;
7 | isHidden?: boolean;
8 | notifications?: ChannelNotifications;
9 | filters?: ChannelFilter[];
10 | }
11 |
12 | export enum ChannelFilterOperator {
13 | Equal = '=',
14 | NotEqual = '!=',
15 | GreatherThan = '>',
16 | GreatherThanOrEqual = '>=',
17 | LowerThan = '<',
18 | LowerThanOrEqual = '<=',
19 | Contains = 'contains',
20 | NotContains = 'notContains',
21 | StartsWith = 'startsWith',
22 | EndsWith = 'endsWith',
23 | }
24 |
25 | export interface ChannelFilter {
26 | field: string;
27 | operator: ChannelFilterOperator;
28 | value: string | number;
29 | }
30 |
31 | export interface ChannelNotifications {
32 | isDisabled: boolean;
33 | }
34 |
35 | export interface ChannelActivities {
36 | videoId: string;
37 | }
38 |
--------------------------------------------------------------------------------
/src/types/Settings.ts:
--------------------------------------------------------------------------------
1 | export enum HomeView {
2 | All = 'all',
3 | WatchLater = 'watchLater',
4 | Bookmarks = 'bookmarks',
5 | }
6 |
7 | export interface Settings {
8 | defaultView: HomeView | null;
9 | apiKey: string;
10 | darkMode: boolean;
11 | autoPlayVideos: boolean;
12 | viewOptions: {
13 | [HomeView.All]: {
14 | sorting: ViewSorting;
15 | filters: AllViewFilters;
16 | channels: ChannelOptions;
17 | videosSeniority: VideosSeniority;
18 | };
19 | [HomeView.WatchLater]: {
20 | sorting: ViewSorting;
21 | filters: WatchLaterViewFilters;
22 | channels: ChannelOptions;
23 | videosSeniority: VideosSeniority;
24 | };
25 | [HomeView.Bookmarks]: {
26 | sorting: ViewSorting;
27 | filters: BookmarksViewFilters;
28 | channels: ChannelOptions;
29 | videosSeniority: VideosSeniority;
30 | };
31 | };
32 | homeDisplayOptions: HomeDisplayOptions;
33 | enableNotifications: boolean;
34 | queryTimeout: number;
35 | }
36 |
37 | export interface ChannelOptions {
38 | collapseByDefault: boolean;
39 | displayVideosCount: boolean;
40 | openChannelOnNameClick: boolean;
41 | }
42 |
43 | export enum ExtraVideoAction {
44 | CopyLink = 'copyLink',
45 | }
46 |
47 | export interface HomeDisplayOptions {
48 | hiddenViews: HomeView[];
49 | extraVideoActions: ExtraVideoAction[];
50 | }
51 |
52 | export interface ViewSorting {
53 | publishDate: boolean;
54 | }
55 |
56 | export interface AllViewFilters {
57 | seen: boolean;
58 | watchLater: boolean;
59 | bookmarked: boolean;
60 | ignored: boolean;
61 | others: boolean;
62 | }
63 |
64 | export interface WatchLaterViewFilters {
65 | seen: boolean;
66 | bookmarked: boolean;
67 | archived: boolean;
68 | others: boolean;
69 | }
70 |
71 | export interface BookmarksViewFilters {
72 | seen: boolean;
73 | watchLater: boolean;
74 | others: boolean;
75 | }
76 |
77 | export interface ViewFilters
78 | extends AllViewFilters,
79 | WatchLaterViewFilters,
80 | BookmarksViewFilters {}
81 |
82 | export type ViewFilter = keyof ViewFilters;
83 |
84 | export enum VideosSeniority {
85 | Any = 0,
86 | OneDay = 1,
87 | ThreeDays = 3,
88 | SevenDays = 7,
89 | TwoWeeks = 14,
90 | OneMonth = 31,
91 | }
92 |
93 | export enum QueryTimeout {
94 | TenSeconds = 10000,
95 | FifteenSeconds = 15000,
96 | TwentySeconds = 20000,
97 | ThirtySeconds = 30000,
98 | OneMinute = 60000,
99 | }
100 |
101 | export enum SettingType {
102 | String,
103 | Secret,
104 | Number,
105 | Boolean,
106 | List,
107 | Custom,
108 | }
109 |
--------------------------------------------------------------------------------
/src/types/Video.ts:
--------------------------------------------------------------------------------
1 | export interface Video {
2 | id: string;
3 | title: string;
4 | url: string;
5 | duration: string;
6 | publishedAt: number;
7 | publishedSince: string;
8 | thumbnail: string;
9 | views: string | number;
10 | channelId: string;
11 | channelTitle: string;
12 | }
13 |
14 | export type VideoFlags = Partial<{
15 | seen: boolean;
16 | toWatchLater: boolean;
17 | notified: boolean;
18 | archived: boolean;
19 | ignored: boolean;
20 | recent: boolean;
21 | bookmarked: boolean;
22 | }>;
23 |
24 | export type VideoFlag = keyof VideoFlags;
25 |
26 | export interface VideoCache {
27 | id: string;
28 | channelId: string;
29 | publishedAt: number;
30 | flags: VideoFlags;
31 | }
32 |
--------------------------------------------------------------------------------
/src/types/api.ts:
--------------------------------------------------------------------------------
1 | export enum FetchError {
2 | TIMEOUT = 'timed out',
3 | }
4 |
5 | export interface Response {
6 | kind?: string;
7 | etag?: string;
8 | nextPageToken?: string;
9 | regionCode?: string;
10 | pageInfo: {
11 | totalResults: number;
12 | resultsPerPage: number;
13 | };
14 | items: Item[];
15 | }
16 |
17 | interface Thumbnail {
18 | url: string;
19 | width: number;
20 | height: number;
21 | }
22 |
23 | type SnippetType =
24 | | 'channelItem'
25 | | 'comment'
26 | | 'favorite'
27 | | 'like'
28 | | 'playlistItem'
29 | | 'promotedItem'
30 | | 'recommendation'
31 | | 'social'
32 | | 'subscription'
33 | | 'upload';
34 |
35 | interface Item {
36 | id: string;
37 | snippet: {
38 | type: SnippetType;
39 | channelId: string;
40 | channelTitle: string;
41 | liveBroadcastContent?: string;
42 | publishedAt: string;
43 | publishedTime: string;
44 | title: string;
45 | description: string;
46 | thumbnails: {
47 | default: Thumbnail;
48 | medium: Thumbnail;
49 | high: Thumbnail;
50 | };
51 | };
52 | contentDetails: {
53 | upload: {
54 | videoId: string;
55 | };
56 | duration: string;
57 | };
58 | statistics: {
59 | viewCount: number;
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/src/types/common.ts:
--------------------------------------------------------------------------------
1 | export type Nullable = T | null;
2 |
3 | export type Only = { [P in keyof T]: T[P] } & Omit<
4 | { [P in keyof U]?: never },
5 | keyof T
6 | >;
7 |
8 | export type Either = Only | Only;
9 |
10 | export declare const ObjectTyped: {
11 | keys(object: T): (keyof T)[];
12 | };
13 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Channel';
2 | export * from './Video';
3 | export * from './Settings';
4 | export * from './api';
5 | export * from './webext';
6 | export * from './common';
7 | export * from './legacy';
8 |
--------------------------------------------------------------------------------
/src/types/legacy.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AllViewFilters as RecentViewFilters,
3 | BookmarksViewFilters,
4 | Settings,
5 | VideosSeniority,
6 | ViewSorting,
7 | WatchLaterViewFilters,
8 | } from './Settings';
9 | import { VideoFlags } from './Video';
10 |
11 | export interface LegacySettings extends Omit {
12 | recentVideosSeniority: VideosSeniority;
13 | recentViewFilters: RecentViewFilters;
14 | watchLaterViewFilters: WatchLaterViewFilters;
15 | bookmarksViewFilters: BookmarksViewFilters;
16 | allViewSorting: ViewSorting;
17 | recentViewSorting: ViewSorting;
18 | watchLaterViewSorting: ViewSorting;
19 | bookmarksViewSorting: ViewSorting;
20 | recentVideosDisplayOptions?: {
21 | hideViewedVideos: boolean;
22 | hideWatchLaterVideos: boolean;
23 | };
24 | }
25 |
26 | export type LegacyVideoFlags = VideoFlags & {
27 | viewed: boolean;
28 | };
29 |
--------------------------------------------------------------------------------
/src/types/webext.ts:
--------------------------------------------------------------------------------
1 | interface NotificationButton {
2 | title: string;
3 | iconUrl?: string;
4 | }
5 |
6 | interface NotificationItem {
7 | title: string;
8 | message: string;
9 | }
10 |
11 | export interface SendNotificationParams {
12 | id?: string;
13 | title?: string;
14 | message: string;
15 | contextMessage?: string;
16 | type?: string;
17 | imageUrl?: string;
18 | iconUrl?: string;
19 | buttons?: NotificationButton[];
20 | items?: NotificationItem[];
21 | }
22 |
23 | export interface MessageRequest {
24 | message: string;
25 | params: any;
26 | }
27 |
28 | export interface BadgeColors {
29 | backgroundColor: string;
30 | textColor: string;
31 | }
32 |
33 | type Context = 'page' | 'link' | 'selection' | 'audio' | 'bookmark' | 'all';
34 |
35 | type ContextType = 'normal' | 'checkbox' | 'radio' | 'separator';
36 |
37 | export interface ContextMenu {
38 | title: string;
39 | id: string;
40 | type?: ContextType;
41 | enabled?: boolean;
42 | checked?: boolean;
43 | contexts: Context[];
44 | }
45 |
46 | export interface ContextMenuInfo {
47 | menuItemId: string;
48 | checked: boolean;
49 | }
50 |
51 | export interface Tab {
52 | id: number;
53 | url: string;
54 | }
55 |
56 | export type TabResolver = (tab: Tab) => boolean;
57 |
58 | export interface OpenTabOptions {
59 | reloadIfExists?: boolean;
60 | resolver?: TabResolver;
61 | }
62 |
--------------------------------------------------------------------------------
/src/ui/components/pages/About/Credit.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Typography, IconButton, Link } from '@mui/material';
3 | import FavoriteRoundedIcon from '@mui/icons-material/FavoriteRounded';
4 | import GitHubIcon from '@mui/icons-material/GitHub';
5 |
6 | interface CreditProps {
7 | author: string;
8 | repositoryUrl?: string;
9 | }
10 |
11 | export default function Credit(props: CreditProps) {
12 | const { author, repositoryUrl } = props;
13 |
14 | return (
15 |
24 | Made with {' '}
25 | by {author}
26 | {repositoryUrl ? (
27 |
28 |
29 |
30 |
31 |
32 | ) : null}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/ui/components/pages/About/SocialLink.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, Tooltip } from '@mui/material';
3 | interface SocialLinkProps {
4 | children: React.ReactNode;
5 | tooltip: string;
6 | href: string;
7 | target?: '_blank' | '_self';
8 | }
9 |
10 | export default function SocialLink(props: SocialLinkProps) {
11 | const { children, tooltip, href, target = '_blank' } = props;
12 |
13 | return (
14 |
15 |
16 | {children}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelDialogs/ChannelFiltersDialog/Filter/Input.ts:
--------------------------------------------------------------------------------
1 | import { styled, alpha } from '@mui/material/styles';
2 | import InputBase from '@mui/material/InputBase';
3 |
4 | const Input = styled(InputBase)(({ theme }) => ({
5 | borderRadius: 4,
6 | position: 'relative',
7 | backgroundColor: theme.palette.background.default,
8 | border: `1px solid ${
9 | theme.palette.mode === 'light'
10 | ? 'rgba(0, 0, 0, 0.23)'
11 | : 'rgba(255, 255, 255, 0.23)'
12 | }`,
13 | fontSize: '0.9rem',
14 | width: 'auto',
15 | minWidth: 320,
16 | padding: theme.spacing(1, 1.5),
17 | transition: theme.transitions.create(['border-color', 'box-shadow']),
18 | '&:focus-within': {
19 | boxShadow: `${alpha(theme.palette.secondary.main, 0.25)} 0 0 0 0.2rem`,
20 | borderColor: theme.palette.secondary.main,
21 | },
22 | '& .MuiInputBase-input': {
23 | padding: 0,
24 | },
25 | '& .MuiInputAdornment-root': {
26 | marginLeft: theme.spacing(0.75),
27 | '& .MuiButtonBase-root': {
28 | padding: theme.spacing(0.75, 1),
29 | '& > svg': {
30 | width: '0.85em',
31 | },
32 | },
33 | },
34 | }));
35 |
36 | export default Input;
37 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelDialogs/ChannelFiltersDialog/Filter/MenuItem.tsx:
--------------------------------------------------------------------------------
1 | import { styled, Theme } from '@mui/material/styles';
2 | import MuiMenuItem from '@mui/material/MenuItem';
3 |
4 | const MenuItemSelectedStyle = (theme: Theme) => ({
5 | backgroundColor: theme.palette.secondary.main,
6 | color: theme.palette.common.white,
7 | });
8 |
9 | const MenuItem = styled(MuiMenuItem)(({ theme }) => ({
10 | fontSize: '0.9rem',
11 | '&.Mui-selected': {
12 | ...MenuItemSelectedStyle(theme),
13 | '&.Mui-focusVisible': MenuItemSelectedStyle(theme),
14 | },
15 | '&.Mui-selected:hover': MenuItemSelectedStyle(theme),
16 | })) as typeof MuiMenuItem;
17 |
18 | export default MenuItem;
19 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelDialogs/ChannelFiltersDialog/Filter/Select.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles';
2 | import MuiSelect from '@mui/material/Select';
3 |
4 | const Select = styled(MuiSelect)(({ theme }) => ({
5 | fontSize: '0.9rem',
6 | backgroundColor: theme.palette.background.default,
7 | '&.Mui-focused': {
8 | '& .MuiOutlinedInput-notchedOutline': {
9 | borderColor: theme.palette.secondary.light,
10 | },
11 | },
12 | })) as unknown as typeof MuiSelect;
13 |
14 | export default Select;
15 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelDialogs/ChannelFiltersDialog/config.ts:
--------------------------------------------------------------------------------
1 | import { ChannelFilterOperator } from 'types';
2 |
3 | export enum FilterType {
4 | Text = 'text',
5 | Number = 'number',
6 | }
7 |
8 | interface FilterSettings {
9 | field: string;
10 | type: FilterType;
11 | operators: ChannelFilterOperator[];
12 | }
13 |
14 | export const settings: FilterSettings[] = [
15 | {
16 | field: 'title',
17 | type: FilterType.Text,
18 | operators: [
19 | ChannelFilterOperator.Equal,
20 | ChannelFilterOperator.NotEqual,
21 | ChannelFilterOperator.Contains,
22 | ChannelFilterOperator.NotContains,
23 | ChannelFilterOperator.StartsWith,
24 | ChannelFilterOperator.EndsWith,
25 | ],
26 | },
27 | {
28 | field: 'duration',
29 | type: FilterType.Number,
30 | operators: [
31 | ChannelFilterOperator.Equal,
32 | ChannelFilterOperator.NotEqual,
33 | ChannelFilterOperator.GreatherThan,
34 | ChannelFilterOperator.GreatherThanOrEqual,
35 | ChannelFilterOperator.LowerThan,
36 | ChannelFilterOperator.LowerThanOrEqual,
37 | ],
38 | },
39 | {
40 | field: 'publishedAt',
41 | type: FilterType.Number,
42 | operators: [
43 | ChannelFilterOperator.Equal,
44 | ChannelFilterOperator.NotEqual,
45 | ChannelFilterOperator.GreatherThan,
46 | ChannelFilterOperator.GreatherThanOrEqual,
47 | ChannelFilterOperator.LowerThan,
48 | ChannelFilterOperator.LowerThanOrEqual,
49 | ],
50 | },
51 | ];
52 |
53 | export const settingsByField: { [key: string]: FilterSettings } =
54 | settings.reduce((acc, { field, ...rest }) => ({ ...acc, [field]: rest }), {});
55 |
56 | export const fields = settings.map(({ field }) => field);
57 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelDialogs/RemoveChannelDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import {
3 | Box,
4 | Button,
5 | Checkbox,
6 | Dialog,
7 | DialogActions,
8 | DialogContent,
9 | DialogContentText,
10 | DialogTitle,
11 | FormControlLabel,
12 | FormGroup,
13 | } from '@mui/material';
14 | import { Channel } from 'types';
15 | import ChannelPicture from '../ChannelPicture';
16 |
17 | interface RemoveChannelDialogProps {
18 | open: boolean;
19 | channel: Channel;
20 | onClose: (confirmed?: boolean, shouldRemoveVideos?: boolean) => void;
21 | }
22 |
23 | export default function RemoveChannelDialog(props: RemoveChannelDialogProps) {
24 | const { open, channel, onClose } = props;
25 | const [shouldRemoveVideos, setShouldRemoveVideos] = useState(false);
26 |
27 | const handleConfirm = () => {
28 | onClose(true, shouldRemoveVideos);
29 | };
30 |
31 | const handleClose = () => {
32 | onClose(false);
33 | };
34 |
35 | const handleRemoveVideosToggle = () => {
36 | setShouldRemoveVideos(!shouldRemoveVideos);
37 | };
38 |
39 | return (
40 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelDialogs/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Channel, Nullable } from 'types';
3 | import { useAppDispatch } from 'store';
4 | import { removeChannel, setChannelFilters } from 'store/reducers/channels';
5 | import RemoveChannelDialog from './RemoveChannelDialog';
6 | import ChannelFiltersDialog from './ChannelFiltersDialog';
7 | import { removeChannelVideos } from 'store/reducers/videos';
8 |
9 | interface ChannelDialogsProps {
10 | channel: Channel;
11 | openedDialog: Nullable;
12 | onClose: () => void;
13 | }
14 |
15 | function ChannelDialogs(props: ChannelDialogsProps) {
16 | const { channel, openedDialog, onClose } = props;
17 | const dispatch = useAppDispatch();
18 |
19 | return (
20 | <>
21 | {
25 | if (confirmed) {
26 | dispatch(removeChannel(channel));
27 | if (shouldRemoveVideos) {
28 | dispatch(removeChannelVideos(channel));
29 | }
30 | }
31 | onClose();
32 | }}
33 | />
34 | {
38 | if (filters) {
39 | dispatch(
40 | setChannelFilters({
41 | channel,
42 | filters,
43 | }),
44 | );
45 | }
46 | onClose();
47 | }}
48 | />
49 | >
50 | );
51 | }
52 |
53 | function propsAreEqual(
54 | prevProps: ChannelDialogsProps,
55 | nextProps: ChannelDialogsProps,
56 | ) {
57 | return (
58 | prevProps.openedDialog === nextProps.openedDialog &&
59 | prevProps.channel.id === nextProps.channel.id
60 | );
61 | }
62 |
63 | export default React.memo(ChannelDialogs, propsAreEqual);
64 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelPicture.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Avatar, Link, Fade } from '@mui/material';
3 | import { Channel } from 'types';
4 |
5 | interface ChannelPictureProps {
6 | channel: Channel;
7 | }
8 |
9 | function ChannelPicture(props: ChannelPictureProps) {
10 | const { channel } = props;
11 |
12 | return (
13 |
14 |
20 |
29 |
30 |
31 | );
32 | }
33 |
34 | function propsAreEqual(
35 | prevProps: ChannelPictureProps,
36 | nextProps: ChannelPictureProps,
37 | ) {
38 | return (
39 | prevProps.channel.title === nextProps.channel.title &&
40 | prevProps.channel.thumbnail === nextProps.channel.thumbnail &&
41 | prevProps.channel.url === nextProps.channel.url
42 | );
43 | }
44 |
45 | export default React.memo(ChannelPicture, propsAreEqual);
46 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/ChannelTitle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Link, Tooltip } from '@mui/material';
3 | import { Channel } from 'types';
4 | import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
5 | import FilterAltIcon from '@mui/icons-material/FilterAlt';
6 |
7 | interface ChannelTitleProps {
8 | channel: Channel;
9 | }
10 |
11 | function ChannelTitle(props: ChannelTitleProps) {
12 | const { channel } = props;
13 |
14 | return (
15 |
22 |
31 | {channel.title}
32 |
33 | {channel.notifications?.isDisabled ? (
34 |
35 |
36 |
37 | ) : null}
38 | {!!channel.filters?.length ? (
39 |
40 |
41 |
42 | ) : null}
43 |
44 | );
45 | }
46 |
47 | function propsAreEqual(
48 | prevProps: ChannelTitleProps,
49 | nextProps: ChannelTitleProps,
50 | ) {
51 | return (
52 | prevProps.channel.title === nextProps.channel.title &&
53 | prevProps.channel.url === nextProps.channel.url &&
54 | prevProps.channel.notifications?.isDisabled ===
55 | nextProps.channel.notifications?.isDisabled &&
56 | prevProps.channel.filters?.length === nextProps.channel.filters?.length
57 | );
58 | }
59 |
60 | export default React.memo(ChannelTitle, propsAreEqual);
61 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/DragHandle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton } from '@mui/material';
3 | import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
4 |
5 | interface DragHandleProps {}
6 |
7 | export default function DragHandle(props: DragHandleProps) {
8 | return (
9 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/DraggableCard.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSortable } from '@dnd-kit/sortable';
3 | import { CSS } from '@dnd-kit/utilities';
4 | import ChannelCard, { ChannelCardProps } from '.';
5 |
6 | export default function DraggableCard(props: ChannelCardProps) {
7 | const { channel, ...rest } = props;
8 | const {
9 | attributes,
10 | listeners,
11 | setNodeRef,
12 | transform,
13 | transition,
14 | isDragging,
15 | isSorting,
16 | } = useSortable({
17 | id: channel.id,
18 | data: { channel },
19 | });
20 |
21 | const style = {
22 | transition: isSorting ? transition : 'none',
23 | opacity: isDragging ? 0.5 : 1,
24 | transform: CSS.Transform.toString(transform),
25 | } as React.CSSProperties;
26 |
27 | return (
28 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelCard/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Paper, Card, CardHeader, Collapse } from '@mui/material';
3 | import { Channel } from 'types';
4 | import ChannelPicture from './ChannelPicture';
5 | import ChannelTitle from './ChannelTitle';
6 | import { DraggableSyntheticListeners } from '@dnd-kit/core';
7 | import ChannelActions from './ChannelActions';
8 | import DragHandle from './DragHandle';
9 |
10 | export interface ChannelCardProps {
11 | channel: Channel;
12 | style?: React.CSSProperties;
13 | isOverlay?: boolean;
14 | showDragHandle?: boolean;
15 | listeners?: DraggableSyntheticListeners;
16 | }
17 |
18 | const ChannelCard = React.forwardRef(
19 | (props: ChannelCardProps, ref: React.LegacyRef) => {
20 | const { channel, isOverlay, showDragHandle, listeners, ...rest } = props;
21 |
22 | const renderCard = React.useMemo(
23 | () => (
24 |
25 | }
50 | action={}
51 | title={}
52 | subheader={channel.description}
53 | />
54 |
55 | ),
56 | [channel],
57 | );
58 |
59 | return (
60 |
61 |
62 |
69 | {isOverlay ? (
70 |
71 | ) : (
72 |
73 |
74 |
75 | )}
76 | {renderCard}
77 |
78 |
79 |
80 | );
81 | },
82 | );
83 |
84 | export default ChannelCard;
85 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelList/ImportChannelsDialog.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Box,
4 | Button,
5 | Dialog,
6 | DialogActions,
7 | DialogContent,
8 | DialogContentText,
9 | DialogTitle,
10 | } from '@mui/material';
11 | import { Channel } from 'types';
12 |
13 | interface ImportChannelsDialogProps {
14 | open: boolean;
15 | channels: Channel[];
16 | onClose: (confirmed?: boolean, shouldReplace?: boolean) => void;
17 | }
18 |
19 | export default function ImportChannelsDialog(props: ImportChannelsDialogProps) {
20 | const { open, channels, onClose } = props;
21 | const channelsCount = channels?.length || 0;
22 |
23 | const handleConfirm = (shouldReplace?: boolean) => {
24 | onClose(true, shouldReplace);
25 | };
26 |
27 | const handleClose = () => {
28 | onClose(false);
29 | };
30 |
31 | return (
32 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelResults/PickChannelActions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Tooltip, IconButton, Fade } from '@mui/material';
3 | import CheckIcon from '@mui/icons-material/Check';
4 | import { Channel } from 'types';
5 | import { useAppDispatch } from 'store';
6 | import { addChannel } from 'store/reducers/channels';
7 | import ChannelActions from '../ChannelCard/ChannelActions';
8 |
9 | interface PickChannelActionsProps {
10 | channel: Channel;
11 | canEdit?: boolean;
12 | }
13 |
14 | function PickChannelActions(props: PickChannelActionsProps) {
15 | const { channel, canEdit } = props;
16 | const dispatch = useAppDispatch();
17 |
18 | const handleClick = () => {
19 | dispatch(addChannel(channel));
20 | };
21 |
22 | return canEdit ? (
23 |
24 |
25 |
26 | ) : (
27 |
28 |
29 |
33 | theme.transitions.create([
34 | 'color',
35 | 'background-color',
36 | 'border-color',
37 | ]),
38 | borderColor: 'custom.lightBorder',
39 | ':hover': {
40 | bgcolor: 'secondary.main',
41 | color: 'common.white',
42 | borderColor: 'secondary.main',
43 | },
44 | }}
45 | size="small"
46 | aria-label="add"
47 | onClick={handleClick}
48 | disableRipple
49 | >
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | export default PickChannelActions;
58 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelResults/PickChannelCard.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Card, CardHeader } from '@mui/material';
3 | import { Channel } from 'types';
4 | import { useAppSelector } from 'store';
5 | import { selectChannel } from 'store/selectors/channels';
6 | import ChannelPicture from '../ChannelCard/ChannelPicture';
7 | import ChannelTitle from '../ChannelCard/ChannelTitle';
8 | import ChannelActions from './PickChannelActions';
9 |
10 | interface PickChannelCardProps {
11 | channel: Channel;
12 | }
13 |
14 | export default function PickChannelCard(props: PickChannelCardProps) {
15 | const found = useAppSelector(selectChannel(props.channel));
16 | const channel = found || props.channel;
17 |
18 | return (
19 |
20 |
27 | }
52 | action={}
53 | title={}
54 | subheader={channel.description}
55 | />
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/ChannelResults/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Stack, Divider } from '@mui/material';
3 | import { ErrorAlert, ProgressBar } from 'ui/components/shared';
4 | import { useFindChannelByNameQuery } from 'store/services/youtube';
5 | import PickChannelCard from './PickChannelCard';
6 |
7 | interface ChannelResultsProps {
8 | search: string;
9 | }
10 |
11 | export default function ChannelResults(props: ChannelResultsProps) {
12 | const { search } = props;
13 | const { data, error, isLoading } = useFindChannelByNameQuery(
14 | { name: search },
15 | { skip: search === '' },
16 | );
17 | const results = data?.items || [];
18 |
19 | return error ? (
20 |
21 | ) : isLoading ? (
22 |
23 | ) : (
24 | }>
25 | {results.map((channel, index) => (
26 |
27 | ))}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/NoChannels.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, MouseEvent, ChangeEvent } from 'react';
2 | import { Box, Typography, Button } from '@mui/material';
3 | import UploadIcon from '@mui/icons-material/Upload';
4 | import { readFile } from 'helpers/file';
5 | import { useAppDispatch } from 'store';
6 | import { setChannels } from 'store/reducers/channels';
7 | import { Nullable } from 'types';
8 |
9 | interface NoChannelsProps {}
10 |
11 | export default function NoChannels(props: NoChannelsProps) {
12 | const fileInputRef = useRef>(null);
13 | const dispatch = useAppDispatch();
14 |
15 | const importChannels = (event: ChangeEvent) => {
16 | const file = event.target.files?.[0];
17 | if (!file) {
18 | return;
19 | }
20 | try {
21 | readFile(file).then((content) => {
22 | const channels = JSON.parse(content as string);
23 | dispatch(setChannels({ list: channels }));
24 | });
25 | } catch (e) {
26 | console.error(e);
27 | }
28 | };
29 |
30 | return (
31 |
40 |
41 | No channels found.
42 |
43 |
49 | Start by typing a channel name in the search bar above
50 |
51 | or
52 |
53 |
64 | ) => {
76 | event.stopPropagation();
77 | event.currentTarget.value = '';
78 | }}
79 | onChange={importChannels}
80 | />
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Channels/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Layout, SearchInput } from 'ui/components/shared';
3 | import { Box, IconButton, Collapse, Tooltip } from '@mui/material';
4 | import ArrowBackIcon from '@mui/icons-material/ArrowBack';
5 | import ChannelResults from './ChannelResults';
6 | import ChannelList from './ChannelList';
7 | import NoChannels from './NoChannels';
8 | import { useAppSelector } from 'store';
9 | import { selectChannels } from 'store/selectors/channels';
10 | import ChannelListActions from './ChannelList/Actions';
11 |
12 | interface ChannelsProps {}
13 |
14 | export function Channels(props: ChannelsProps) {
15 | const [search, setSearch] = useState('');
16 | const [showDragHandles, setShowDragHandles] = useState(false);
17 | const channels = useAppSelector(selectChannels);
18 | const isSearchActive = Boolean(search);
19 |
20 | const showList = () => {
21 | setSearch('');
22 | };
23 |
24 | return (
25 |
26 | theme.spacing(1.25, 3),
33 | gap: 2,
34 | }}
35 | >
36 |
37 |
38 |
39 |
44 |
45 |
46 |
47 |
48 | {
53 | setSearch(value);
54 | }}
55 | onClear={showList}
56 | clearable
57 | />
58 |
59 | {!isSearchActive ? (
60 |
65 | ) : null}
66 |
67 | {search ? (
68 |
69 | ) : channels.length > 0 ? (
70 |
71 | ) : (
72 |
73 | )}
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/AllViewRenderer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import { useAppSelector } from 'store';
3 | import {
4 | selectVideosSeniority,
5 | selectViewFilters,
6 | } from 'store/selectors/settings';
7 | import { date2ISO, getDateBefore } from 'helpers/utils';
8 | import DefaultRenderer, { DefaultRendererProps } from './DefaultRenderer';
9 | import { selectChannelVideosById } from 'store/selectors/videos';
10 | import { HomeView, VideoCache, VideosSeniority } from 'types';
11 | import { jsonEqualityFn } from 'store/utils';
12 |
13 | export interface AllViewRendererProps
14 | extends Omit {}
15 |
16 | // should be instanciated outside the component to avoid multi-rendering
17 | const persistVideosOptions = {
18 | enable: true,
19 | flags: { recent: true },
20 | };
21 |
22 | const filterableFlags = [
23 | 'seen',
24 | 'toWatchLater',
25 | 'archived',
26 | 'ignored',
27 | 'bookmarked',
28 | ];
29 |
30 | const videosCacheFilter = ({ flags }: VideoCache) =>
31 | Object.keys(flags).some((flag) => filterableFlags.includes(flag));
32 |
33 | function AllViewRenderer(props: AllViewRendererProps) {
34 | const { channel } = props;
35 | const filters = useAppSelector(selectViewFilters(HomeView.All));
36 | const videosSeniority = useAppSelector(selectVideosSeniority(HomeView.All));
37 | const videosById = useAppSelector(
38 | selectChannelVideosById(channel, videosCacheFilter),
39 | jsonEqualityFn,
40 | );
41 | const publishedAfter = useMemo(
42 | () =>
43 | videosSeniority === VideosSeniority.Any
44 | ? undefined
45 | : date2ISO(getDateBefore(videosSeniority)),
46 | [videosSeniority],
47 | );
48 |
49 | return (
50 |
59 | );
60 | }
61 |
62 | export default AllViewRenderer;
63 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/BookmarksViewRenderer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useAppSelector } from 'store';
3 | import { selectBookmarkedVideos } from 'store/selectors/videos';
4 | import StaticRenderer, { StaticRendererProps } from './StaticRenderer';
5 |
6 | export interface BookmarksViewRendererProps
7 | extends Omit {}
8 |
9 | function BookmarksViewRenderer(props: BookmarksViewRendererProps) {
10 | const { channel, ...rest } = props;
11 | const videos = useAppSelector(selectBookmarkedVideos(channel));
12 |
13 | return ;
14 | }
15 |
16 | export default BookmarksViewRenderer;
17 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelDataHandler.tsx:
--------------------------------------------------------------------------------
1 | import { DependencyList, useEffect } from 'react';
2 | import { Channel, HomeView, Video } from 'types';
3 | import { useChannelVideos } from 'providers';
4 |
5 | export interface ChannelDataHandlerProps {
6 | view: HomeView;
7 | channel: Channel;
8 | videos: Video[];
9 | total: number;
10 | isFetching: boolean;
11 | hasData: boolean;
12 | deps: DependencyList;
13 | }
14 |
15 | function ChannelDataHandler(props: ChannelDataHandlerProps) {
16 | const { view, channel, videos, total, isFetching, hasData, deps } = props;
17 | const { setChannelData } = useChannelVideos(view);
18 |
19 | useEffect(() => {
20 | if (!isFetching && hasData) {
21 | setChannelData({ channel, items: videos, total });
22 | }
23 | // eslint-disable-next-line react-hooks/exhaustive-deps
24 | }, deps);
25 |
26 | return null;
27 | }
28 |
29 | export default ChannelDataHandler;
30 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelRenderer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import { Channel, HomeView, Video } from 'types';
4 | import ChannelTitle from './ChannelTitle';
5 | import ChannelVideos from './ChannelVideos';
6 | import { ChannelOptionsProvider, useChannelOptions } from 'providers';
7 | import { channelVideosLimit as limit } from 'hooks';
8 |
9 | export interface ChannelRendererProps {
10 | view: HomeView;
11 | channel: Channel;
12 | videos: Video[];
13 | count?: number;
14 | total: number;
15 | isLoading: boolean;
16 | itemsPerRow: number;
17 | maxResults: number;
18 | onLoadMore: () => void;
19 | onVideoPlay: (video: Video) => void;
20 | }
21 |
22 | function ChannelRenderer(props: ChannelRendererProps) {
23 | const {
24 | view,
25 | channel,
26 | videos,
27 | count,
28 | total,
29 | isLoading,
30 | maxResults,
31 | ...rest
32 | } = props;
33 | const videosCount = count || videos.length;
34 | const hasVideos = isLoading || videosCount > 0;
35 | const hasMore = videosCount > 0 && videosCount < limit && total > maxResults;
36 | const { collapsed } = useChannelOptions();
37 |
38 | return hasVideos ? (
39 |
46 |
47 | {!collapsed ? (
48 |
56 | ) : null}
57 |
58 | ) : null;
59 | }
60 |
61 | function ChannelRendererWrapper(props: ChannelRendererProps) {
62 | const { view } = props;
63 |
64 | return (
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | function propsAreEqual(
72 | prevProps: ChannelRendererProps,
73 | nextProps: ChannelRendererProps,
74 | ) {
75 | return (
76 | prevProps.view === nextProps.view &&
77 | prevProps.channel.id === nextProps.channel.id &&
78 | prevProps.count === nextProps.count &&
79 | prevProps.total === nextProps.total &&
80 | prevProps.isLoading === nextProps.isLoading &&
81 | prevProps.itemsPerRow === nextProps.itemsPerRow &&
82 | prevProps.maxResults === nextProps.maxResults &&
83 | JSON.stringify(prevProps.videos.map(({ id }) => id)) ===
84 | JSON.stringify(nextProps.videos.map(({ id }) => id))
85 | );
86 | }
87 |
88 | export default React.memo(ChannelRendererWrapper, propsAreEqual);
89 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelTitle/ChannelAvatar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Avatar, Badge } from '@mui/material';
3 | import { Channel, HomeView } from 'types';
4 | import { useAppSelector } from 'store';
5 | import { selectViewChannelOption } from 'store/selectors/settings';
6 | import { useChannelVideos } from 'providers';
7 |
8 | interface ChannelAvatarProps {
9 | view: HomeView;
10 | channel: Channel;
11 | }
12 |
13 | function ChannelAvatar(props: ChannelAvatarProps) {
14 | const { view, channel } = props;
15 | const displayVideosCount = useAppSelector(
16 | selectViewChannelOption(view, 'displayVideosCount'),
17 | );
18 | const { getChannelVideosCount } = useChannelVideos(view);
19 | const videosCount = displayVideosCount
20 | ? getChannelVideosCount(channel)
21 | : null;
22 |
23 | return (
24 |
29 |
30 |
31 | );
32 | }
33 |
34 | export default ChannelAvatar;
35 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelTitle/ChannelExpandToggle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Tooltip } from '@mui/material';
3 | import { useChannelOptions } from 'providers';
4 | import ExpandLessIcon from '@mui/icons-material/ExpandLess';
5 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
6 |
7 | interface ChannelExpandToggleProps {}
8 |
9 | function ChannelExpandToggle(props: ChannelExpandToggleProps) {
10 | const { collapseByDefault, collapsed, setCollapsed } = useChannelOptions();
11 |
12 | const handleToggle = () => {
13 | setCollapsed(!collapsed);
14 | };
15 |
16 | if (!collapseByDefault) {
17 | return null;
18 | }
19 |
20 | return collapsed ? (
21 |
22 |
23 |
24 | ) : (
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | export default ChannelExpandToggle;
32 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelTitle/ChannelLink.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from '@mui/material';
3 | import { Channel } from 'types';
4 |
5 | interface ChannelLinkProps {
6 | channel: Channel;
7 | children: React.ReactNode;
8 | }
9 |
10 | function ChannelLink(props: ChannelLinkProps) {
11 | const { channel, children } = props;
12 |
13 | return (
14 |
20 | {children}
21 |
22 | );
23 | }
24 |
25 | export default ChannelLink;
26 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelTitle/ChannelName.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { SxProps, Typography } from '@mui/material';
3 | import { Channel, HomeView } from 'types';
4 | import { useAppSelector } from 'store';
5 | import { selectViewChannelOption } from 'store/selectors/settings';
6 | import ChannelLink from './ChannelLink';
7 |
8 | interface ChannelNameProps {
9 | view: HomeView;
10 | channel: Channel;
11 | }
12 |
13 | function ChannelName(props: ChannelNameProps) {
14 | const { view, channel } = props;
15 | const openChannelOnNameClick = useAppSelector(
16 | selectViewChannelOption(view, 'openChannelOnNameClick'),
17 | );
18 |
19 | const renderName = (sx?: SxProps) => (
20 |
21 | {channel.title}
22 |
23 | );
24 |
25 | return openChannelOnNameClick ? (
26 | {renderName()}
27 | ) : (
28 | renderName({ cursor: 'default' })
29 | );
30 | }
31 |
32 | export default ChannelName;
33 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelTitle/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import { Channel, HomeView } from 'types';
4 | import ChannelLink from './ChannelLink';
5 | import ChannelAvatar from './ChannelAvatar';
6 | import ChannelName from './ChannelName';
7 | import ChannelExpandToggle from './ChannelExpandToggle';
8 |
9 | interface ChannelTitleProps {
10 | view: HomeView;
11 | channel: Channel;
12 | }
13 |
14 | function ChannelTitle(props: ChannelTitleProps) {
15 | const { view, channel } = props;
16 |
17 | return (
18 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | function propsAreEqual(
36 | prevProps: ChannelTitleProps,
37 | nextProps: ChannelTitleProps,
38 | ) {
39 | return (
40 | prevProps.view === nextProps.view &&
41 | prevProps.channel.id === nextProps.channel.id
42 | );
43 | }
44 |
45 | export default React.memo(ChannelTitle, propsAreEqual);
46 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/GridItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Grid, GridProps } from '@mui/material';
3 |
4 | interface GridItemProps extends GridProps {}
5 |
6 | export default function GridItem(props: GridItemProps) {
7 | const { children, ...rest } = props;
8 | const sizes = {
9 | xxs: 1,
10 | xs: 1,
11 | sm: 1,
12 | md: 1,
13 | lg: 1,
14 | xl: 1,
15 | } as any;
16 |
17 | return (
18 |
19 | {children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/LoadMore.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, IconButton } from '@mui/material';
3 | import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
4 |
5 | interface LoadMoreProps {
6 | isLoading: boolean;
7 | hasMore?: boolean;
8 | onClick?: () => void;
9 | }
10 |
11 | export default function LoadMore(props: LoadMoreProps) {
12 | const { isLoading, hasMore, onClick } = props;
13 |
14 | return (
15 | theme.spacing(3.5),
21 | ml: 2,
22 | gap: 2,
23 | }}
24 | >
25 | {hasMore ? (
26 |
32 |
33 |
34 | ) : null}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/Badges/ArchivedBadge.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Tooltip } from '@mui/material';
3 | import ArchiveIcon from '@mui/icons-material/Archive';
4 | import { HomeView, Video } from 'types';
5 | import { useAppSelector } from 'store';
6 | import { selectVideoFlag } from 'store/selectors/videos';
7 |
8 | interface ArchivedBadgeProps {
9 | video: Video;
10 | view: HomeView;
11 | }
12 |
13 | function ArchivedBadge(props: ArchivedBadgeProps) {
14 | const { video, view } = props;
15 | const isArchived = useAppSelector(selectVideoFlag(video, 'archived'));
16 |
17 | return view === HomeView.WatchLater && isArchived ? (
18 |
19 |
31 |
32 |
33 |
34 | ) : null;
35 | }
36 |
37 | export default ArchivedBadge;
38 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/Badges/IgnoredBadge.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Tooltip } from '@mui/material';
3 | import DoDisturbOnIcon from '@mui/icons-material/DoDisturbOn';
4 | import { HomeView, Video } from 'types';
5 | import { useAppSelector } from 'store';
6 | import { selectVideoFlag } from 'store/selectors/videos';
7 |
8 | interface IgnoredBadgeProps {
9 | video: Video;
10 | view: HomeView;
11 | }
12 |
13 | function IgnoredBadge(props: IgnoredBadgeProps) {
14 | const { video, view } = props;
15 | const isIgnored = useAppSelector(selectVideoFlag(video, 'ignored'));
16 |
17 | return view === HomeView.All && isIgnored ? (
18 |
19 |
31 |
32 |
33 |
34 | ) : null;
35 | }
36 |
37 | export default IgnoredBadge;
38 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/Badges/SeenBadge.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Tooltip } from '@mui/material';
3 | import VisibilityIcon from '@mui/icons-material/Visibility';
4 | import { Video } from 'types';
5 | import { useAppSelector } from 'store';
6 | import { selectVideoFlag } from 'store/selectors/videos';
7 |
8 | interface SeenBadgeProps {
9 | video: Video;
10 | }
11 |
12 | function SeenBadge(props: SeenBadgeProps) {
13 | const { video } = props;
14 | const isSeen = useAppSelector(selectVideoFlag(video, 'seen'));
15 |
16 | return isSeen ? (
17 |
18 |
30 |
31 |
32 |
33 | ) : null;
34 | }
35 |
36 | export default SeenBadge;
37 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/Badges/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import { HomeView, Video } from 'types';
4 | import SeenBadge from './SeenBadge';
5 | import ArchivedBadge from './ArchivedBadge';
6 | import IgnoredBadge from './IgnoredBadge';
7 |
8 | interface VideoBadgesProps {
9 | video: Video;
10 | view: HomeView;
11 | }
12 |
13 | function VideoBadges(props: VideoBadgesProps) {
14 | const { video, view } = props;
15 |
16 | return (
17 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | function propsAreEqual(
35 | prevProps: VideoBadgesProps,
36 | nextProps: VideoBadgesProps,
37 | ) {
38 | return (
39 | prevProps.view === nextProps.view &&
40 | prevProps.video.id === nextProps.video.id
41 | );
42 | }
43 |
44 | export default React.memo(VideoBadges, propsAreEqual);
45 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/TopActions/ArchiveAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton, Tooltip } from '@mui/material';
3 | import ArchiveIcon from '@mui/icons-material/Archive';
4 | import UnarchiveIcon from '@mui/icons-material/Unarchive';
5 | import { HomeView, Video } from 'types';
6 | import { useAppDispatch, useAppSelector } from 'store';
7 | import { archiveVideo, unarchiveVideo } from 'store/reducers/videos';
8 | import { selectVideoFlag } from 'store/selectors/videos';
9 |
10 | interface ArchiveActionProps {
11 | video: Video;
12 | view: HomeView;
13 | }
14 |
15 | function ArchiveAction(props: ArchiveActionProps) {
16 | const { video, view } = props;
17 | const isArchived = useAppSelector(selectVideoFlag(video, 'archived'));
18 | const dispatch = useAppDispatch();
19 |
20 | if (view !== HomeView.WatchLater) {
21 | return null;
22 | }
23 |
24 | return isArchived ? (
25 |
26 | {
39 | dispatch(unarchiveVideo(video));
40 | }}
41 | >
42 |
43 |
44 |
45 | ) : (
46 |
47 | {
59 | dispatch(archiveVideo(video));
60 | }}
61 | >
62 |
63 |
64 |
65 | );
66 | }
67 |
68 | export default ArchiveAction;
69 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/TopActions/BookmarkAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton, Tooltip } from '@mui/material';
3 | import BookmarkIcon from '@mui/icons-material/Bookmark';
4 | import CloseIcon from '@mui/icons-material/Close';
5 | import { HomeView, Video } from 'types';
6 | import { useAppDispatch, useAppSelector } from 'store';
7 | import { addVideoFlag, removeVideoFlag } from 'store/reducers/videos';
8 | import { selectVideoFlag } from 'store/selectors/videos';
9 | import { selectHasHiddenView } from 'store/selectors/settings';
10 |
11 | interface BookmarkActionProps {
12 | video: Video;
13 | view: HomeView;
14 | }
15 |
16 | const flag = 'bookmarked';
17 |
18 | function BookmarkAction(props: BookmarkActionProps) {
19 | const { video, view } = props;
20 | const isBookmarked = useAppSelector(selectVideoFlag(video, flag));
21 | const isBookmarksViewHidden = useAppSelector(
22 | selectHasHiddenView(HomeView.Bookmarks),
23 | );
24 | const dispatch = useAppDispatch();
25 |
26 | if (isBookmarksViewHidden) {
27 | return null;
28 | }
29 |
30 | return !isBookmarked ? (
31 |
32 | {
45 | dispatch(
46 | addVideoFlag({
47 | video,
48 | flag,
49 | }),
50 | );
51 | }}
52 | >
53 |
54 |
55 |
56 | ) : view === HomeView.Bookmarks ? (
57 |
58 | {
70 | dispatch(
71 | removeVideoFlag({
72 | video,
73 | flag,
74 | }),
75 | );
76 | }}
77 | >
78 |
79 |
80 |
81 | ) : null;
82 | }
83 |
84 | export default BookmarkAction;
85 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/TopActions/CopyLinkAction.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Grow, IconButton, Tooltip } from '@mui/material';
3 | import { ExtraVideoAction, Video } from 'types';
4 | import LinkIcon from '@mui/icons-material/Link';
5 | import CheckIcon from '@mui/icons-material/Check';
6 | import copy from 'copy-to-clipboard';
7 | import { useAppSelector } from 'store';
8 | import { selectHasExtraVideoAction } from 'store/selectors/settings';
9 |
10 | interface CopyLinkActionProps {
11 | video: Video;
12 | }
13 |
14 | function CopyLinkAction(props: CopyLinkActionProps) {
15 | const { video } = props;
16 | const [isShown, setIsShown] = useState(true);
17 | const [copied, setCopied] = useState(false);
18 | const enabled = useAppSelector(
19 | selectHasExtraVideoAction(ExtraVideoAction.CopyLink),
20 | );
21 |
22 | const handleClick = () => {
23 | if (copied) return;
24 | copy(video.url);
25 | setCopied(true);
26 | setTimeout(() => {
27 | setIsShown(false);
28 | }, 1000);
29 | };
30 |
31 | return enabled ? (
32 |
33 |
37 |
50 | {copied ? (
51 |
52 | ) : (
53 |
54 | )}
55 |
56 |
57 |
58 | ) : null;
59 | }
60 |
61 | export default CopyLinkAction;
62 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/TopActions/IgnoreAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton, Tooltip } from '@mui/material';
3 | import DoDisturbOnIcon from '@mui/icons-material/DoDisturbOn';
4 | import DoNotDisturbOffIcon from '@mui/icons-material/DoNotDisturbOff';
5 | import { HomeView, Video } from 'types';
6 | import { useAppDispatch, useAppSelector } from 'store';
7 | import { addVideoFlag, removeVideoFlag } from 'store/reducers/videos';
8 | import { selectVideoFlag } from 'store/selectors/videos';
9 |
10 | interface IgnoreActionProps {
11 | video: Video;
12 | view: HomeView;
13 | }
14 |
15 | const flag = 'ignored';
16 |
17 | function IgnoreAction(props: IgnoreActionProps) {
18 | const { video, view } = props;
19 | const isIgnored = useAppSelector(selectVideoFlag(video, flag));
20 | const dispatch = useAppDispatch();
21 |
22 | if (view !== HomeView.All) {
23 | return null;
24 | }
25 |
26 | return isIgnored ? (
27 |
28 | {
41 | dispatch(
42 | removeVideoFlag({
43 | video,
44 | flag,
45 | }),
46 | );
47 | }}
48 | >
49 |
50 |
51 |
52 | ) : (
53 |
54 | {
66 | dispatch(
67 | addVideoFlag({
68 | video,
69 | flag,
70 | }),
71 | );
72 | }}
73 | >
74 |
75 |
76 |
77 | );
78 | }
79 |
80 | export default IgnoreAction;
81 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/TopActions/SeenAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton, Tooltip } from '@mui/material';
3 | import VisibilityIcon from '@mui/icons-material/Visibility';
4 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
5 | import { Video } from 'types';
6 | import { useAppDispatch, useAppSelector } from 'store';
7 | import { addVideoFlag, removeVideoFlag } from 'store/reducers/videos';
8 | import { selectVideoFlag } from 'store/selectors/videos';
9 |
10 | interface SeenActionProps {
11 | video: Video;
12 | }
13 |
14 | const flag = 'seen';
15 |
16 | function SeenAction(props: SeenActionProps) {
17 | const { video } = props;
18 | const isSeen = useAppSelector(selectVideoFlag(video, flag));
19 | const dispatch = useAppDispatch();
20 |
21 | return !isSeen ? (
22 |
23 | {
35 | dispatch(
36 | addVideoFlag({
37 | video,
38 | flag,
39 | }),
40 | );
41 | }}
42 | >
43 |
44 |
45 |
46 | ) : (
47 |
48 | {
61 | dispatch(
62 | removeVideoFlag({
63 | video,
64 | flag,
65 | }),
66 | );
67 | }}
68 | >
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | export default SeenAction;
76 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/TopActions/WatchLaterAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton, Tooltip } from '@mui/material';
3 | import WatchLaterOutlinedIcon from '@mui/icons-material/WatchLaterOutlined';
4 | import CloseIcon from '@mui/icons-material/Close';
5 | import { HomeView, Video } from 'types';
6 | import { useAppDispatch, useAppSelector } from 'store';
7 | import { addVideoFlag, removeVideoFlag } from 'store/reducers/videos';
8 | import { selectVideoFlag } from 'store/selectors/videos';
9 | import { selectHasHiddenView } from 'store/selectors/settings';
10 |
11 | interface WatchLaterActionProps {
12 | video: Video;
13 | view: HomeView;
14 | }
15 |
16 | const flag = 'toWatchLater';
17 |
18 | function WatchLaterAction(props: WatchLaterActionProps) {
19 | const { video, view } = props;
20 | const isToWatchLater = useAppSelector(selectVideoFlag(video, flag));
21 | const isWatchLaterViewHidden = useAppSelector(
22 | selectHasHiddenView(HomeView.WatchLater),
23 | );
24 | const dispatch = useAppDispatch();
25 |
26 | if (isWatchLaterViewHidden) {
27 | return null;
28 | }
29 |
30 | return !isToWatchLater ? (
31 |
32 | {
45 | dispatch(
46 | addVideoFlag({
47 | video,
48 | flag,
49 | }),
50 | );
51 | }}
52 | >
53 |
54 |
55 |
56 | ) : view === HomeView.WatchLater ? (
57 |
58 | {
70 | dispatch(
71 | removeVideoFlag({
72 | video,
73 | flag,
74 | }),
75 | );
76 | }}
77 | >
78 |
79 |
80 |
81 | ) : null;
82 | }
83 |
84 | export default WatchLaterAction;
85 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoCard/TopActions/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import { HomeView, Video } from 'types';
4 | import WatchLaterAction from './WatchLaterAction';
5 | import SeenAction from './SeenAction';
6 | import ArchiveAction from './ArchiveAction';
7 | import IgnoreAction from './IgnoreAction';
8 | import CopyLinkAction from './CopyLinkAction';
9 | import BookmarkAction from './BookmarkAction';
10 |
11 | interface VideoTopActionsProps {
12 | video: Video;
13 | view: HomeView;
14 | }
15 |
16 | function VideoTopActions(props: VideoTopActionsProps) {
17 | const { video, view } = props;
18 |
19 | return (
20 |
30 |
31 |
32 |
33 |
34 | {view === HomeView.Bookmarks ? (
35 | <>
36 |
37 |
38 | >
39 | ) : (
40 | <>
41 |
42 |
43 | >
44 | )}
45 |
46 | );
47 | }
48 |
49 | function propsAreEqual(
50 | prevProps: VideoTopActionsProps,
51 | nextProps: VideoTopActionsProps,
52 | ) {
53 | return (
54 | prevProps.view === nextProps.view &&
55 | prevProps.video.id === nextProps.video.id
56 | );
57 | }
58 |
59 | export default React.memo(VideoTopActions, propsAreEqual);
60 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/VideoSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Skeleton, Box } from '@mui/material';
3 |
4 | interface VideoSkeletonProps {
5 | width?: string | number;
6 | height?: string | number;
7 | }
8 |
9 | export default function VideoSkeleton(props: VideoSkeletonProps) {
10 | const { width = '100%', height = 120 } = props;
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/config.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | gridSpacing: { xxs: 1, sm: 2 },
3 | gridColumns: { xxs: 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6 },
4 | };
5 |
6 | export default config;
7 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/ChannelVideos/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { Box, Grid } from '@mui/material';
3 | import { HomeView, Video } from 'types';
4 | import VideoCard from './VideoCard';
5 | import VideoSkeleton from './VideoSkeleton';
6 | import GridItem from './GridItem';
7 | import config from './config';
8 | import LoadMore from './LoadMore';
9 |
10 | interface ChannelVideosProps {
11 | videos: Video[];
12 | view: HomeView;
13 | isLoading: boolean;
14 | itemsPerRow: number;
15 | maxResults: number;
16 | hasMore?: boolean;
17 | onVideoPlay: (video: Video) => void;
18 | onLoadMore?: () => void;
19 | }
20 |
21 | function ChannelVideos(props: ChannelVideosProps) {
22 | const {
23 | videos,
24 | view,
25 | isLoading,
26 | itemsPerRow,
27 | maxResults,
28 | hasMore,
29 | onVideoPlay,
30 | onLoadMore,
31 | } = props;
32 | const showSkeletons = useRef(true);
33 | const skeletonNumber = Math.min(maxResults - videos.length, itemsPerRow);
34 |
35 | useEffect(() => {
36 | if (!isLoading) {
37 | showSkeletons.current = hasMore ?? true;
38 | }
39 | // eslint-disable-next-line react-hooks/exhaustive-deps
40 | }, [isLoading]);
41 |
42 | return (
43 |
44 |
45 | {videos.map((video: Video, index: number) => (
46 |
47 |
48 |
49 | ))}
50 | {isLoading && showSkeletons.current && skeletonNumber > 0
51 | ? Array.from(new Array(skeletonNumber)).map((_, index: number) => (
52 |
53 |
54 |
55 | ))
56 | : null}
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | export default ChannelVideos;
64 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/DefaultRenderer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { Channel, HomeView, Video } from 'types';
3 | import {
4 | FilterVideosOptions,
5 | PersistVideosOptions,
6 | } from 'store/services/youtube';
7 | import ChannelRenderer from './ChannelRenderer';
8 | import ChannelDataHandler from './ChannelDataHandler';
9 | import config from './ChannelVideos/config';
10 | import { useGetChannelVideos, useGrid } from 'hooks';
11 |
12 | export interface DefaultRendererProps {
13 | view: HomeView;
14 | channel: Channel;
15 | publishedAfter?: string;
16 | persistVideosOptions?: PersistVideosOptions;
17 | filterVideosOptions?: FilterVideosOptions;
18 | onError?: (error: any) => void;
19 | onVideoPlay: (video: Video) => void;
20 | }
21 |
22 | function DefaultRenderer(props: DefaultRendererProps) {
23 | const {
24 | view,
25 | channel,
26 | publishedAfter,
27 | persistVideosOptions,
28 | filterVideosOptions,
29 | onError,
30 | ...rest
31 | } = props;
32 | // const lastVideoIdRef = useRef(undefined);
33 | const [page, setPage] = useState(1);
34 | const { itemsPerRow = 0 } = useGrid(config.gridColumns);
35 | const maxResults = itemsPerRow * page;
36 | const {
37 | data,
38 | // lastVideoId,
39 | error,
40 | isLoading,
41 | isFetching,
42 | } = useGetChannelVideos({
43 | channel,
44 | publishedAfter,
45 | maxResults,
46 | persistVideosOptions,
47 | filterVideosOptions,
48 | // lastVideoId: lastVideoIdRef.current,
49 | skip: itemsPerRow === 0,
50 | selectFromResult: (data) => ({
51 | ...data,
52 | // lastVideoId: data?.items[data.items.length - 1]?.id,
53 | }),
54 | });
55 | const videos = data?.items || [];
56 | const count = data?.count || 0;
57 | const total = data?.total || 0;
58 |
59 | const handleLoadMore = () => {
60 | // lastVideoIdRef.current = lastVideoId;
61 | setPage(page + 1);
62 | };
63 |
64 | useEffect(() => {
65 | if (error && onError) {
66 | onError(error);
67 | }
68 | }, [error, onError]);
69 |
70 | return (
71 | <>
72 |
84 |
93 | >
94 | );
95 | }
96 |
97 | export default DefaultRenderer;
98 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/StaticRenderer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { Channel, HomeView, Video, VideoCache } from 'types';
3 | import { useGetVideosByIdQuery } from 'store/services/youtube';
4 | import ChannelRenderer from './ChannelRenderer';
5 | import ChannelDataHandler from './ChannelDataHandler';
6 | import config from './ChannelVideos/config';
7 | import { useGrid } from 'hooks';
8 |
9 | export interface StaticRendererProps {
10 | view: HomeView;
11 | channel: Channel;
12 | videos: VideoCache[];
13 | onVideoPlay: (video: Video) => void;
14 | onError?: (error: any) => void;
15 | }
16 |
17 | function StaticRenderer(props: StaticRendererProps) {
18 | const { view, channel, videos: initialVideos, onError, onVideoPlay } = props;
19 | const [page, setPage] = useState(1);
20 | const { itemsPerRow = 0 } = useGrid(config.gridColumns);
21 | const ids = initialVideos.map(({ id }) => id);
22 | const total = ids.length;
23 | const maxResults = Math.min(total, itemsPerRow * page);
24 | const { data, error, isLoading, isFetching } = useGetVideosByIdQuery(
25 | {
26 | ids,
27 | maxResults,
28 | },
29 | {
30 | skip: itemsPerRow === 0,
31 | },
32 | );
33 | const videos = (data?.items || []).filter((video) =>
34 | // filter deleted videos (since next refetch may take time)
35 | ids.includes(video.id),
36 | );
37 |
38 | const handleLoadMore = () => {
39 | setPage(page + 1);
40 | };
41 |
42 | useEffect(() => {
43 | if (error && onError) {
44 | onError(error);
45 | }
46 | }, [error, onError]);
47 |
48 | return (
49 | <>
50 |
61 |
70 | >
71 | );
72 | }
73 |
74 | export default StaticRenderer;
75 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/WatchLaterViewRenderer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useAppSelector } from 'store';
3 | import { selectWatchLaterVideos } from 'store/selectors/videos';
4 | import StaticRenderer, { StaticRendererProps } from './StaticRenderer';
5 |
6 | export interface WatchLaterViewRendererProps
7 | extends Omit {}
8 |
9 | function WatchLaterViewRenderer(props: WatchLaterViewRendererProps) {
10 | const { channel, ...rest } = props;
11 | const videos = useAppSelector(selectWatchLaterVideos(channel));
12 |
13 | return ;
14 | }
15 |
16 | export default WatchLaterViewRenderer;
17 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelRenderer/index.ts:
--------------------------------------------------------------------------------
1 | import DefaultRenderer from './DefaultRenderer';
2 | import AllViewRenderer from './AllViewRenderer';
3 | import WatchLaterViewRenderer from './WatchLaterViewRenderer';
4 | import BookmarksViewRenderer from './BookmarksViewRenderer';
5 |
6 | export {
7 | DefaultRenderer,
8 | AllViewRenderer,
9 | WatchLaterViewRenderer,
10 | BookmarksViewRenderer,
11 | };
12 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/ChannelsWrapper.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import { Box } from '@mui/material';
3 | import { Channel, HomeView, Video } from 'types';
4 | import {
5 | AllViewRenderer,
6 | BookmarksViewRenderer,
7 | DefaultRenderer,
8 | WatchLaterViewRenderer,
9 | } from './ChannelRenderer';
10 |
11 | interface ChannelsWrapperProps {
12 | view: HomeView;
13 | channels: Channel[];
14 | onError?: (error: any) => void;
15 | onVideoPlay: (video: Video) => void;
16 | }
17 |
18 | function ChannelsWrapper(props: ChannelsWrapperProps) {
19 | const { view, channels, onError, onVideoPlay } = props;
20 | const ChannelRenderer = useMemo(() => {
21 | switch (view) {
22 | case HomeView.All:
23 | return AllViewRenderer;
24 | case HomeView.WatchLater:
25 | return WatchLaterViewRenderer;
26 | case HomeView.Bookmarks:
27 | return BookmarksViewRenderer;
28 | default:
29 | return DefaultRenderer;
30 | }
31 | }, [view]);
32 |
33 | return (
34 |
44 | {channels.map((channel, index) => (
45 |
52 | ))}
53 |
54 | );
55 | }
56 |
57 | function propsAreEqual(
58 | prevProps: ChannelsWrapperProps,
59 | nextProps: ChannelsWrapperProps,
60 | ) {
61 | return (
62 | prevProps.view === nextProps.view &&
63 | JSON.stringify(prevProps.channels.map(({ id }) => id)) ===
64 | JSON.stringify(nextProps.channels.map(({ id }) => id))
65 | );
66 | }
67 |
68 | export default React.memo(ChannelsWrapper, propsAreEqual);
69 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/DisplayOptionsDialog/ExtraVideoActions.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, useImperativeHandle, useState } from 'react';
2 | import {
3 | FormControl,
4 | FormControlLabel,
5 | FormGroup,
6 | FormLabel,
7 | Switch,
8 | } from '@mui/material';
9 | import { useAppSelector } from 'store';
10 | import { selectExtraVideoActions } from 'store/selectors/settings';
11 | import { ExtraVideoAction } from 'types';
12 |
13 | interface ExtraVideoActionsProps {}
14 |
15 | interface Action {
16 | label: string;
17 | value: ExtraVideoAction;
18 | active: boolean;
19 | }
20 |
21 | export interface ExtraVideoActionsRef {
22 | reset: () => void;
23 | getActions: () => Action[];
24 | setActions: (actions: Action[]) => void;
25 | }
26 |
27 | const ExtraVideoActions = forwardRef<
28 | ExtraVideoActionsRef,
29 | ExtraVideoActionsProps
30 | >((props, ref) => {
31 | const extraVideoActions = useAppSelector(selectExtraVideoActions);
32 | const initialActions: Action[] = [
33 | {
34 | label: 'Copy link to clipboard',
35 | value: ExtraVideoAction.CopyLink,
36 | active: extraVideoActions.includes(ExtraVideoAction.CopyLink),
37 | },
38 | ];
39 | const [actions, setActions] = useState(initialActions);
40 |
41 | useImperativeHandle(
42 | ref,
43 | () => ({
44 | reset: () => setActions(initialActions),
45 | getActions: () => actions,
46 | setActions,
47 | }),
48 | // eslint-disable-next-line react-hooks/exhaustive-deps
49 | [actions],
50 | );
51 |
52 | const handleToggle = (target: Action) => {
53 | setActions((state) =>
54 | state.map((action) =>
55 | action.value === target.value
56 | ? {
57 | ...action,
58 | active: !action.active,
59 | }
60 | : action,
61 | ),
62 | );
63 | };
64 |
65 | return (
66 |
72 | Extra video actions
73 |
74 | {actions.map((action) => (
75 | handleToggle(action)}
77 | key={action.value}
78 | control={
79 |
84 | }
85 | label={action.label}
86 | />
87 | ))}
88 |
89 |
90 | );
91 | });
92 |
93 | export default ExtraVideoActions;
94 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/NoChannels.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Typography, Button } from '@mui/material';
3 | import { useHistory } from 'react-router-dom';
4 | import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
5 | import { useAppSelector } from 'store';
6 | import { selectApp } from 'store/selectors/app';
7 |
8 | interface NoChannelsProps {}
9 |
10 | export default function NoChannels(props: NoChannelsProps) {
11 | const app = useAppSelector(selectApp);
12 | const history = useHistory();
13 |
14 | return app.loaded ? (
15 |
24 |
29 | Welcome, stranger! To get started, you should add some channels
30 |
31 |
42 |
43 | ) : null;
44 | }
45 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/StyledTabs.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles';
2 | import Tabs, { TabsProps } from '@mui/material/Tabs';
3 |
4 | const StyledTabs = styled((props: TabsProps) => )(
5 | ({ theme }) => ({
6 | flexGrow: 1,
7 | '& .MuiTabs-flexContainer': {
8 | paddingTop: theme.spacing(1),
9 | paddingBottom: theme.spacing(0.5),
10 | },
11 | }),
12 | );
13 |
14 | export default StyledTabs;
15 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/Tab/Badge.ts:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles';
2 | import MuiBadge from '@mui/material/Badge';
3 |
4 | const Badge = styled(MuiBadge)(({ theme }) => ({
5 | '& .MuiBadge-badge': {
6 | position: 'relative',
7 | transform: 'none',
8 | backgroundColor: theme.palette.primary.main,
9 | color: theme.palette.common.white,
10 | minWidth: 20,
11 | height: 20,
12 | fontSize: '0.75rem',
13 | marginLeft: theme.spacing(1.5),
14 | borderRadius: theme.spacing(1.5),
15 | },
16 | }));
17 |
18 | export default Badge;
19 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/Tab/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import MuiTab, { TabProps as MuiTabProps } from '@mui/material/Tab';
4 | import Badge from './Badge';
5 | import { HomeView } from 'types';
6 | import { useChannelVideos } from 'providers';
7 |
8 | interface TabProps extends MuiTabProps {
9 | value: HomeView;
10 | selected?: boolean;
11 | }
12 |
13 | export default function Tab(props: TabProps) {
14 | const { label, value: view, selected, ...rest } = props;
15 | const { getAllVideosCount } = useChannelVideos(view);
16 | const badgeContent = getAllVideosCount();
17 |
18 | return (
19 |
28 | {label}
29 | {selected && badgeContent ? (
30 |
34 | ) : null}
35 |
36 | }
37 | value={view}
38 | disableRipple
39 | {...rest}
40 | />
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/AllViewActions/AllViewOptions.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { IconButton, Divider } from '@mui/material';
3 | import { StyledMenu } from 'ui/components/shared';
4 | import MoreVertIcon from '@mui/icons-material/MoreVert';
5 | import { HomeView, Nullable } from 'types';
6 | import ViewSorting from '../CommonMenus/ViewSorting';
7 | import ViewFilters, { ViewFilterOption } from '../CommonMenus/ViewFilters';
8 | import ViewVideosSeniority from '../CommonMenus/ViewVideosSeniority';
9 | import AllViewMoreActions from '../AllViewActions/Menus/AllViewMoreActions';
10 | import ViewChannelOptions from '../CommonMenus/ViewChannelOptions';
11 |
12 | const filterOptions: ViewFilterOption[] = [
13 | {
14 | label: 'Seen',
15 | value: 'seen',
16 | },
17 | {
18 | label: 'Watch later',
19 | value: 'watchLater',
20 | },
21 | {
22 | label: 'Bookmarked',
23 | value: 'bookmarked',
24 | },
25 | {
26 | label: 'Ignored',
27 | value: 'ignored',
28 | },
29 | {
30 | label: 'Others',
31 | value: 'others',
32 | },
33 | ];
34 |
35 | interface AllViewOptionsProps {}
36 |
37 | function AllViewOptions(props: AllViewOptionsProps) {
38 | const [anchorEl, setAnchorEl] = useState>(null);
39 | const open = Boolean(anchorEl);
40 |
41 | const handleClick = (event: React.MouseEvent) => {
42 | setAnchorEl(event.currentTarget);
43 | };
44 |
45 | const handleClose = () => {
46 | setAnchorEl(null);
47 | };
48 |
49 | return (
50 | <>
51 |
59 |
60 |
61 |
81 | >
82 | );
83 | }
84 |
85 | export default AllViewOptions;
86 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/AllViewActions/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import AllViewOptions from './AllViewOptions';
4 |
5 | interface AllViewActionsProps {}
6 |
7 | function AllViewActions(props: AllViewActionsProps) {
8 | return (
9 |
16 |
17 |
18 | );
19 | }
20 |
21 | export default AllViewActions;
22 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/BookmarksViewActions/BookmarksViewOptions.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { IconButton, Divider } from '@mui/material';
3 | import { StyledMenu } from 'ui/components/shared';
4 | import MoreVertIcon from '@mui/icons-material/MoreVert';
5 | import { HomeView, Nullable } from 'types';
6 | import ViewSorting from '../CommonMenus/ViewSorting';
7 | import ViewFilters, { ViewFilterOption } from '../CommonMenus/ViewFilters';
8 | import ViewVideosSeniority from '../CommonMenus/ViewVideosSeniority';
9 | import BookmarksViewMoreActions from './Menus/BookmarksViewMoreActions';
10 | import ViewChannelOptions from '../CommonMenus/ViewChannelOptions';
11 |
12 | const filterOptions: ViewFilterOption[] = [
13 | {
14 | label: 'Seen',
15 | value: 'seen',
16 | },
17 | {
18 | label: 'Watch later',
19 | value: 'watchLater',
20 | },
21 | {
22 | label: 'Others',
23 | value: 'others',
24 | },
25 | ];
26 |
27 | interface BookmarksViewOptionsProps {
28 | videosCount: number;
29 | }
30 |
31 | function BookmarksViewOptions(props: BookmarksViewOptionsProps) {
32 | const { videosCount } = props;
33 | const [anchorEl, setAnchorEl] = useState>(null);
34 | const open = Boolean(anchorEl);
35 |
36 | const handleClick = (event: React.MouseEvent) => {
37 | setAnchorEl(event.currentTarget);
38 | };
39 |
40 | const handleClose = () => {
41 | setAnchorEl(null);
42 | };
43 |
44 | return (
45 | <>
46 |
54 |
55 |
56 |
79 | >
80 | );
81 | }
82 |
83 | export default BookmarksViewOptions;
84 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/BookmarksViewActions/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import { useAppSelector } from 'store';
4 | import { selectBookmarkedVideosCount } from 'store/selectors/videos';
5 | import BookmarksViewOptions from './BookmarksViewOptions';
6 |
7 | interface BookmarksViewActionsProps {}
8 |
9 | function BookmarksViewActions(props: BookmarksViewActionsProps) {
10 | const bookmarkedVideosCount = useAppSelector(selectBookmarkedVideosCount);
11 |
12 | return (
13 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default BookmarksViewActions;
26 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/CommonMenus/ViewChannelOptions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ListItemIcon, ListItemText } from '@mui/material';
3 | import {
4 | CheckableMenuItem,
5 | NestedMenuItem,
6 | NestedMenuItemProps,
7 | } from 'ui/components/shared';
8 | import { useAppDispatch, useAppSelector } from 'store';
9 | import { selectViewChannelOptions } from 'store/selectors/settings';
10 | import { setViewChannelOptions } from 'store/reducers/settings';
11 | import { ChannelOptions, HomeView } from 'types';
12 | import SubscriptionsIcon from '@mui/icons-material/Subscriptions';
13 |
14 | export interface ViewChannelOption {
15 | label: string;
16 | value: keyof ChannelOptions;
17 | }
18 |
19 | const options: ViewChannelOption[] = [
20 | {
21 | label: 'Collapse channels by default',
22 | value: 'collapseByDefault',
23 | },
24 | {
25 | label: 'Display videos count per channel',
26 | value: 'displayVideosCount',
27 | },
28 | {
29 | label: 'Open channel on name click',
30 | value: 'openChannelOnNameClick',
31 | },
32 | ];
33 |
34 | interface ViewChannelOptionsProps extends Omit {
35 | view: HomeView;
36 | }
37 |
38 | function ViewChannelOptions(props: ViewChannelOptionsProps) {
39 | const { view, ...rest } = props;
40 | const channelOptions = useAppSelector(selectViewChannelOptions(view));
41 | const dispatch = useAppDispatch();
42 |
43 | const handleOptionToggle = (key: keyof ChannelOptions) => {
44 | dispatch(
45 | setViewChannelOptions({
46 | view,
47 | options: {
48 | [key]: !channelOptions[key],
49 | },
50 | }),
51 | );
52 | };
53 |
54 | return (
55 |
58 |
59 |
60 |
61 | Channels
62 | >
63 | }
64 | {...rest}
65 | >
66 | {options.map(({ label, value }, index) => (
67 | handleOptionToggle(value)}
71 | >
72 | {label}
73 |
74 | ))}
75 |
76 | );
77 | }
78 |
79 | export default ViewChannelOptions;
80 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/CommonMenus/ViewFilters.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import { ListItemIcon, ListItemText } from '@mui/material';
3 | import {
4 | CheckableMenuItem,
5 | NestedMenuItem,
6 | NestedMenuItemProps,
7 | } from 'ui/components/shared';
8 | import { useAppDispatch, useAppSelector } from 'store';
9 | import {
10 | selectViewFilters,
11 | getActiveViewFilters,
12 | } from 'store/selectors/settings';
13 | import { setViewFilters } from 'store/reducers/settings';
14 | import { HomeView, ViewFilters as Filters } from 'types';
15 | import FilterAltIcon from '@mui/icons-material/FilterAlt';
16 |
17 | export interface ViewFilterOption {
18 | label: string;
19 | value: keyof Filters;
20 | }
21 |
22 | interface ViewFiltersProps extends Omit {
23 | view: HomeView;
24 | options: ViewFilterOption[];
25 | }
26 |
27 | function ViewFilters(props: ViewFiltersProps) {
28 | const { view, options, ...rest } = props;
29 | const filters = useAppSelector(selectViewFilters(view));
30 | const dispatch = useAppDispatch();
31 | const activeFiltersCount = useMemo(
32 | () => getActiveViewFilters(filters).length,
33 | [filters],
34 | );
35 |
36 | const handleFilterToggle = (key: keyof Filters) => {
37 | dispatch(
38 | setViewFilters({
39 | view,
40 | filters: {
41 | [key]: !filters[key],
42 | },
43 | }),
44 | );
45 | };
46 |
47 | return (
48 |
51 |
52 |
53 |
54 | Filters ({activeFiltersCount})
55 | >
56 | }
57 | {...rest}
58 | >
59 | {options.map(({ label, value }, index) => (
60 | handleFilterToggle(value)}
64 | >
65 | {label}
66 |
67 | ))}
68 |
69 | );
70 | }
71 |
72 | export default ViewFilters;
73 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/CommonMenus/ViewSorting.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ListItemIcon, ListItemText } from '@mui/material';
3 | import {
4 | CheckableMenuItem,
5 | NestedMenuItem,
6 | NestedMenuItemProps,
7 | } from 'ui/components/shared';
8 | import { useAppDispatch, useAppSelector } from 'store';
9 | import { selectViewSorting } from 'store/selectors/settings';
10 | import { setViewSorting } from 'store/reducers/settings';
11 | import { HomeView, ViewSorting as Sorting } from 'types';
12 | import SortIcon from '@mui/icons-material/Sort';
13 |
14 | interface ViewSortingOption {
15 | label: string;
16 | value: keyof Sorting;
17 | }
18 |
19 | const options: ViewSortingOption[] = [
20 | {
21 | label: 'Publish date',
22 | value: 'publishDate',
23 | },
24 | ];
25 |
26 | interface ViewSortingProps extends Omit {
27 | view: HomeView;
28 | }
29 |
30 | function ViewSorting(props: ViewSortingProps) {
31 | const { view, ...rest } = props;
32 | const sorting = useAppSelector(selectViewSorting(view));
33 | const dispatch = useAppDispatch();
34 |
35 | const handleSortToggle = (key: keyof Sorting) => {
36 | dispatch(
37 | setViewSorting({
38 | view,
39 | sorting: {
40 | [key]: !sorting[key],
41 | },
42 | }),
43 | );
44 | };
45 |
46 | return (
47 |
50 |
51 |
52 |
53 | Sort by
54 | >
55 | }
56 | isFirstItem
57 | {...rest}
58 | >
59 | {options.map(({ label, value }, index) => (
60 | handleSortToggle(value)}
64 | >
65 | {label}
66 |
67 | ))}
68 |
69 | );
70 | }
71 |
72 | export default ViewSorting;
73 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/CommonMenus/ViewVideosSeniority.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ListItemIcon, ListItemText } from '@mui/material';
3 | import {
4 | CheckableMenuItem,
5 | NestedMenuItem,
6 | NestedMenuItemProps,
7 | } from 'ui/components/shared';
8 | import { useAppDispatch, useAppSelector } from 'store';
9 | import { selectVideosSeniority } from 'store/selectors/settings';
10 | import { setVideosSeniority } from 'store/reducers/settings';
11 | import { HomeView, VideosSeniority } from 'types';
12 | import HistoryIcon from '@mui/icons-material/History';
13 |
14 | const options = [
15 | {
16 | label: '1 day',
17 | value: VideosSeniority.OneDay,
18 | },
19 | {
20 | label: '3 days',
21 | value: VideosSeniority.ThreeDays,
22 | },
23 | {
24 | label: '7 days',
25 | value: VideosSeniority.SevenDays,
26 | },
27 | {
28 | label: '2 weeks',
29 | value: VideosSeniority.TwoWeeks,
30 | },
31 | {
32 | label: '1 month',
33 | value: VideosSeniority.OneMonth,
34 | },
35 | {
36 | label: 'any',
37 | value: VideosSeniority.Any,
38 | },
39 | ];
40 |
41 | interface ViewVideosSeniorityProps extends Omit {
42 | view: HomeView;
43 | }
44 |
45 | function ViewVideosSeniority(props: ViewVideosSeniorityProps) {
46 | const { view, ...rest } = props;
47 | const seniority = useAppSelector(selectVideosSeniority(view));
48 | const dispatch = useAppDispatch();
49 |
50 | const handleChange = (seniority: VideosSeniority) => {
51 | dispatch(setVideosSeniority({ view, seniority }));
52 | };
53 |
54 | return (
55 |
58 |
59 |
60 |
61 | Videos seniority
62 | >
63 | }
64 | MenuProps={{
65 | style: {
66 | minWidth: 160,
67 | },
68 | }}
69 | {...rest}
70 | >
71 | {options.map(({ label, value }, index) => (
72 | handleChange(value)}
76 | >
77 | {label}
78 |
79 | ))}
80 |
81 | );
82 | }
83 |
84 | export default ViewVideosSeniority;
85 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/WatchLaterViewActions/WatchLaterViewOptions.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { IconButton, Divider } from '@mui/material';
3 | import { StyledMenu } from 'ui/components/shared';
4 | import MoreVertIcon from '@mui/icons-material/MoreVert';
5 | import { HomeView, Nullable } from 'types';
6 | import ViewSorting from '../CommonMenus/ViewSorting';
7 | import ViewFilters, { ViewFilterOption } from '../CommonMenus/ViewFilters';
8 | import ViewVideosSeniority from '../CommonMenus/ViewVideosSeniority';
9 | import WatchLaterViewMoreActions from './Menus/WatchLaterViewMoreActions';
10 | import ViewChannelOptions from '../CommonMenus/ViewChannelOptions';
11 |
12 | const filterOptions: ViewFilterOption[] = [
13 | {
14 | label: 'Seen',
15 | value: 'seen',
16 | },
17 | {
18 | label: 'Bookmarked',
19 | value: 'bookmarked',
20 | },
21 | {
22 | label: 'Archived',
23 | value: 'archived',
24 | },
25 | {
26 | label: 'Others',
27 | value: 'others',
28 | },
29 | ];
30 |
31 | interface WatchLaterViewOptionsProps {
32 | videosCount: number;
33 | }
34 |
35 | function WatchLaterViewOptions(props: WatchLaterViewOptionsProps) {
36 | const { videosCount } = props;
37 | const [anchorEl, setAnchorEl] = useState>(null);
38 | const open = Boolean(anchorEl);
39 |
40 | const handleClick = (event: React.MouseEvent) => {
41 | setAnchorEl(event.currentTarget);
42 | };
43 |
44 | const handleClose = () => {
45 | setAnchorEl(null);
46 | };
47 |
48 | return (
49 | <>
50 |
58 |
59 |
60 |
83 | >
84 | );
85 | }
86 |
87 | export default WatchLaterViewOptions;
88 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/WatchLaterViewActions/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box } from '@mui/material';
3 | import { useAppSelector } from 'store';
4 | import { selectWatchLaterVideosCount } from 'store/selectors/videos';
5 | import WatchLaterViewOptions from './WatchLaterViewOptions';
6 |
7 | interface WatchLaterViewActionsProps {}
8 |
9 | function WatchLaterViewActions(props: WatchLaterViewActionsProps) {
10 | const watchLaterVideosCount = useAppSelector(selectWatchLaterVideosCount);
11 |
12 | return (
13 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default WatchLaterViewActions;
26 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabActions/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { HomeView } from 'types';
3 | import WatchLaterViewActions from './WatchLaterViewActions';
4 | import AllViewActions from './AllViewActions';
5 | import { useAppSelector } from 'store';
6 | import { selectChannelsCountByView } from 'store/selectors/channels';
7 | import BookmarksViewActions from './BookmarksViewActions';
8 |
9 | interface TabActionsProps {
10 | tab: HomeView;
11 | }
12 |
13 | function TabActions(props: TabActionsProps) {
14 | const { tab } = props;
15 | const channelsCount = useAppSelector(selectChannelsCountByView(tab));
16 |
17 | if (channelsCount === 0) {
18 | return null;
19 | }
20 |
21 | switch (tab) {
22 | case HomeView.All:
23 | return ;
24 | case HomeView.WatchLater:
25 | return ;
26 | case HomeView.Bookmarks:
27 | return ;
28 | default:
29 | return null;
30 | }
31 | }
32 |
33 | export default React.memo(TabActions);
34 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/TabPanel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { ErrorAlert } from 'ui/components/shared';
3 | import { Channel, HomeView, Video, Nullable } from 'types';
4 | import { useAppSelector } from 'store';
5 | import { selectChannelsByView } from 'store/selectors/channels';
6 | import VideoPlayerDialog from './VideoPlayerDialog';
7 | import ChannelsWrapper from './ChannelsWrapper';
8 | import NoChannels from './NoChannels';
9 | import { selectViewSorting } from 'store/selectors/settings';
10 | import { useChannelVideos } from 'providers';
11 |
12 | interface TabPanelProps {
13 | tab: HomeView;
14 | }
15 |
16 | function TabPanel(props: TabPanelProps) {
17 | const { tab } = props;
18 | const [error, setError] = useState(null);
19 | const [activeVideo, setActiveVideo] = useState>(null);
20 | const { getLatestChannelVideo } = useChannelVideos(tab);
21 | const channels = useAppSelector(selectChannelsByView(tab));
22 | const sorting = useAppSelector(selectViewSorting(tab));
23 |
24 | const handleVideoPlay = (video: Video) => {
25 | setActiveVideo(video);
26 | };
27 |
28 | const handleVideoDialogClose = () => {
29 | setActiveVideo(null);
30 | };
31 |
32 | const handleError = (err: any) => {
33 | setError(err);
34 | };
35 |
36 | const getChannelTimestamp = (channel: Channel) => {
37 | return getLatestChannelVideo(channel)?.publishedAt || 0;
38 | };
39 |
40 | // NOTE: cloning the channels array is required to trigger a re-render
41 | const sortedChannels = sorting.publishDate
42 | ? [...channels].sort(
43 | (a, b) => getChannelTimestamp(b) - getChannelTimestamp(a),
44 | )
45 | : channels;
46 |
47 | return error ? (
48 |
49 | ) : (
50 | <>
51 | {channels.length > 0 ? (
52 |
58 | ) : (
59 |
60 | )}
61 |
66 | >
67 | );
68 | }
69 |
70 | export default React.memo(TabPanel);
71 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/VideoPlayerDialog/CloseButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { MouseEvent } from 'react';
2 | import { IconButton } from '@mui/material';
3 | import CloseIcon from '@mui/icons-material/Close';
4 |
5 | interface CloseButtonProps {
6 | onClick: (event: MouseEvent) => void;
7 | }
8 |
9 | export default function CloseButton(props: CloseButtonProps) {
10 | const { onClick } = props;
11 |
12 | return (
13 | theme.spacing(-1.75),
17 | right: (theme) => theme.spacing(-1.75),
18 | bgcolor: 'primary.main',
19 | color: 'common.white',
20 | fontSize: '1.275rem',
21 | '&:hover': {
22 | bgcolor: 'primary.main',
23 | color: 'common.white',
24 | },
25 | }}
26 | size="small"
27 | onClick={onClick}
28 | >
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/ui/components/pages/Home/VideoPlayerDialog/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { MouseEvent } from 'react';
2 | import YouTube, { PlayerVars } from 'react-youtube';
3 | import { Dialog, DialogContent } from '@mui/material';
4 | import { Video, Nullable } from 'types';
5 | import { noop } from 'helpers/utils';
6 | import { useAppSelector } from 'store';
7 | import { selectSettings } from 'store/selectors/settings';
8 | import CloseButton from './CloseButton';
9 |
10 | interface VideoPlayerDialogProps {
11 | open: boolean;
12 | video: Nullable