├── .babelrc
├── .env.template
├── .eslintrc
├── .gitignore
├── .prettierrc
├── README.md
├── TODO.md
├── create-env.js
├── docs
└── Screenshot2019-05-05.png
├── netlify-script.js
├── package-lock.json
├── package.json
├── src
├── App.js
├── api
│ └── youtube.js
├── assets
│ ├── download.svg
│ ├── image.png
│ ├── notebook.svg
│ ├── play.svg
│ ├── playlist.svg
│ └── settings.svg
├── components
│ ├── Heading
│ │ ├── Heading.js
│ │ └── SettingsModal.js
│ ├── Notes
│ │ ├── Notes.js
│ │ ├── Notetaker.js
│ │ └── index.js
│ ├── Playlist
│ │ ├── Playlist.js
│ │ └── index.js
│ ├── VideoPlayer
│ │ ├── VideoPlayer.js
│ │ ├── _Video.js
│ │ └── index.js
│ └── styledShared
│ │ └── index.js
├── constants
│ ├── dummy.js
│ └── styles.js
├── context
│ ├── context.spec.js
│ └── index.js
├── helpers
│ └── index.js
├── index.html
└── index.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react", "linaria/babel"],
3 | "plugins": [
4 | "@babel/plugin-syntax-dynamic-import",
5 | "@babel/plugin-proposal-class-properties"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | YOUTUBE_API_KEY = 'YOU_NEED_A_YOUTUBE_KEY'
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb", "prettier", "prettier/react"],
3 | "plugins": ["prettier", "jest"],
4 | "rules": {
5 | "react/jsx-filename-extension": [
6 | 1,
7 | {
8 | "extensions": [".js", ".jsx"]
9 | }
10 | ],
11 | "react/prop-types": 0,
12 | "no-underscore-dangle": 0,
13 | "import/imports-first": ["error", "absolute-first"],
14 | "import/newline-after-import": "error",
15 | "react/destructuring-assignment": 0,
16 | "react/sort-comp": [
17 | 1,
18 | {
19 | "order": ["static-methods", "lifecycle", "everything-else", "render"]
20 | }
21 | ],
22 | "jsx-a11y/media-has-caption": 0
23 | },
24 | "env": {
25 | "jest/globals": true
26 | },
27 | "globals": {
28 | "window": true,
29 | "document": true,
30 | "localStorage": true,
31 | "FormData": true,
32 | "FileReader": true,
33 | "Blob": true,
34 | "navigator": true,
35 | "videojs": true
36 | },
37 | "parser": "babel-eslint"
38 | }
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .linaria-cache
3 | node_modules
4 | dist
5 | !dist/assets
6 |
7 | # Mac files
8 | .DS_Store
9 |
10 | .env
11 | .linaria-cache
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # `videojs-react-course-assistant`
2 |
3 | A course assistant tool similar to coursera or frontend masters, built using React, Linaria and VideoJS.
4 |
5 | WIP https://react-coursebuilder.netlify.com/ (please don't abuse the API key)
6 |
7 | ## Features
8 |
9 | - Fetches playlists from YouTube API
10 | - Autoplays through tracks
11 | - Takes time stamped notes and bookmarks
12 | - Sets time for note automatically based on start time of note
13 | - Somewhat responsive layout (untested)
14 | - Saves notes and fetched playlists to localStorage
15 | - Allows downloading of notes in JSON format
16 |
17 | 
18 | Uses dotenv, please copy the .env.template and to .env and use a valid API key for youtube. Initial load will not fire a youtube request but uses preloaded data.
19 |
20 | ## Initial Goals
21 |
22 | - [x] Use clean webpack configuration (i.e. no create react app)
23 | - [x] Play with Linaria :D
24 | - [x] VideoJS running okay with context actions
25 | - [x] Use react context for sharing notes and details (probably unnecessary tbh)
26 | - [x] Fetches a youtube playlist with sufficient data (working, needs playlist title)
27 | - [x] Autoplays through playlist
28 | - [x] Takes notes and bookmarks with correct timestamping (context handling added, bookmark = empty note)
29 | - [x] Saves notes relative to a specific playlist to localStorage (should be working once integrated)
30 | - [x] Mobile view (lazy initial effort)
31 | - [x] Download notes in a friendly format (csv? Json? what's good?)
32 | - [ ] Dont allow notes with same timestamp (no 0.000 options?)
33 |
34 | ## Further afield
35 |
36 | See [TODO.md](./TODO.md), contributions are strongly encouraged as I'm unlikely to ever get to adding everything that could be added
37 |
38 | ### Notes
39 |
40 | - This current buid depends on the videojs cdn loading
41 | - core-js handled to temporarily resolve linaria issues
42 | - To minimise API requests, only the playlist ID is known, meaning the playlist is hard to identify
43 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | Functionality
2 |
3 | - [x] Update poster for individual videos
4 | - [ ] Playback settings (just speed initially)
5 | - [ ] On click bookmark go to point in video
6 |
7 | Style
8 |
9 | - [ ] Forms standardisation
10 | - [ ] Buttons (hover on delete and that kind of thing, more tactful bookmarking)
11 | - [x] Responsive design (untested, fullscreen issues on iOS)
12 |
13 | Post MVP
14 |
15 | - [ ] Decouple youtube aspects from base code
16 | - [ ] Subtitles
17 | - [ ] More info (e.g. length of playlist, amount already played, resume from point stopped, etc)
18 | - [ ] custom playlists (multiple sources? JSON plugin?)
19 | - [ ] autoscroll through notes (highlight most recent active)
20 | - [ ] edit notes (modal)
21 | - [ ] Control panel to control speed and view
22 |
23 | Post YouTube split
24 |
25 | - [ ] Server/database/something
26 | - [ ] Admin mode
27 | - [ ] Ability to add captions (same format as notes)
28 | - [ ] Electron App Exploration
29 |
30 | Also see numerous @todo comments scattered across project
31 |
--------------------------------------------------------------------------------
/create-env.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 |
3 | fs.writeFileSync('./.env', `YOUTUBE_API_KEY=${process.env.YOUTUBE_API_KEY}\n`);
4 | console.log(process.env.YOUTUBE_API_KEY);
5 |
--------------------------------------------------------------------------------
/docs/Screenshot2019-05-05.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/padraigfl/videojs-react-course-assistant/33d3be55abab31a72a66c95b675786c9706c3094/docs/Screenshot2019-05-05.png
--------------------------------------------------------------------------------
/netlify-script.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 |
3 | fs.writeFileSync('./.env', `API_KEY=${process.env.YOUTUBE_API_KEY}\n`);
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "videojs-react-course-assistant",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "./node_modules/.bin/jest",
8 | "test:watch": "./node_modules/.bin/jest --watch",
9 | "lint": "./node_modules/.bin/eslint src/ ",
10 | "pretty-quick": "./node_modules/.bin/pretty-quick --staged",
11 | "build": "webpack --mode production",
12 | "netlify": "node ./create-env.js && npm run build",
13 | "start": "webpack-dev-server --open --mode development"
14 | },
15 | "keywords": [],
16 | "author": "",
17 | "license": "ISC",
18 | "devDependencies": {
19 | "@babel/core": "^7.4.3",
20 | "@babel/preset-env": "^7.4.3",
21 | "@babel/preset-react": "^7.0.0",
22 | "babel-eslint": "^10.0.1",
23 | "babel-jest": "^24.7.1",
24 | "babel-loader": "^8.0.5",
25 | "css-loader": "^2.1.1",
26 | "dotenv-webpack": "^1.7.0",
27 | "eslint": "^5.16.0",
28 | "eslint-config-airbnb": "^17.1.0",
29 | "eslint-config-prettier": "^4.1.0",
30 | "eslint-plugin-import": "^2.17.2",
31 | "eslint-plugin-jest": "^22.5.1",
32 | "eslint-plugin-jsx-a11y": "^6.2.1",
33 | "eslint-plugin-prettier": "^3.0.1",
34 | "eslint-plugin-react": "^7.12.4",
35 | "html-webpack-plugin": "^3.2.0",
36 | "jest": "^24.7.1",
37 | "mini-css-extract-plugin": "^0.6.0",
38 | "prettier": "^1.17.0",
39 | "react-test-renderer": "^16.8.6",
40 | "url-loader": "^1.1.2",
41 | "video.js": "^7.5.4",
42 | "webpack": "^4.30.0",
43 | "webpack-cli": "^3.3.1",
44 | "webpack-dev-server": "^3.3.1"
45 | },
46 | "lint-staged": {
47 | "*.{js,jsx}": [
48 | "npm run pretty-quick",
49 | "npm run lint",
50 | "git add"
51 | ]
52 | },
53 | "precommit": "NODE_ENV=production ./node_modules/.bin/lint-staged",
54 | "dependencies": {
55 | "@babel/plugin-proposal-class-properties": "^7.4.4",
56 | "@babel/plugin-syntax-dynamic-import": "^7.2.0",
57 | "core-js": "^2.6.5",
58 | "dotenv": "^7.0.0",
59 | "linaria": "^1.3.1",
60 | "nanoid": "^2.0.1",
61 | "react": "16.8.6",
62 | "react-dom": "16.8.6",
63 | "videojs-react": "github:padraigfl/videojs-react",
64 | "videojs-youtube": "^2.6.0"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from 'linaria';
3 | import Playlist from './components/Playlist';
4 | import VideoPlayer from './components/VideoPlayer';
5 | import { CourseProvider } from './context';
6 | import { colors, spacings } from './constants/styles';
7 | import Notes from './components/Notes';
8 | import Heading from './components/Heading/Heading';
9 | import notebookIcon from './assets/notebook.svg';
10 | import playlistIcon from './assets/playlist.svg';
11 | import playIcon from './assets/play.svg';
12 |
13 | const banner = css`
14 | display: flex;
15 | background-color: ${colors.dark2};
16 | color: ${colors.accent};
17 | align-items: center;
18 | padding: 0px ${spacings.xs / 2}px;
19 | `;
20 |
21 | const bodyStyle = css`
22 | background-color: #111;
23 | color: ${colors.accent};
24 | display: flex;
25 | flex-grow: 1;
26 | position: relative;
27 | justify-content: space-between;
28 | overflow: hidden;
29 |
30 | @media (min-width: 800px) {
31 | padding: ${spacings.xs / 2}px;
32 | section {
33 | margin: ${spacings.xs / 2}px;
34 | }
35 | }
36 |
37 | :global() {
38 | body {
39 | font-family: Arial;
40 | background-color: ${colors.dark2};
41 | margin: 0px;
42 | }
43 | #courseBuilder {
44 | display: flex;
45 | flex-direction: column;
46 | height: 100vh;
47 | }
48 | h1,
49 | h2 {
50 | margin: 0px;
51 | }
52 | h1 {
53 | font-size: 1.625rem;
54 | }
55 | h2 {
56 | font-size: 1.375rem;
57 | }
58 | }
59 | @media (max-width: 1000px) and (max-height: 999px) {
60 | height: 100vh;
61 | flex-wrap: wrap;
62 | .Column.Video {
63 | width: 100%;
64 | }
65 | .Column--selected {
66 | position: absolute;
67 | top: 0px;
68 | left: 0px;
69 | height: 100%;
70 | max-width: 100%;
71 | width: 100%;
72 | }
73 | .Column:not(.Column--selected) {
74 | display: none;
75 | }
76 | }
77 | @media (max-width: 1000px) and (min-height: 1000px) {
78 | height: initial;
79 | flex-wrap: wrap;
80 | overflow: auto;
81 | .Column.Video {
82 | order: -1;
83 | width: 100%;
84 | }
85 | .Column:not(.Video) {
86 | max-width: initial;
87 | flex-grow: 1;
88 | }
89 | }
90 | `;
91 |
92 | const viewButtonStyle = css`
93 | display: none;
94 | @media (max-width: 1000px) and (max-height: 999px) {
95 | display: flex;
96 | button {
97 | margin-left: ${spacings.xs}px;
98 | padding: ${spacings.xs}px ${spacings.xs}px;
99 | &:disabled {
100 | background-color: ${colors.brand};
101 | outline: none;
102 | border: none;
103 | color: ${colors.light};
104 | font-weight: bold;
105 | }
106 | }
107 | }
108 | `;
109 |
110 | const ViewOption = ({ icon, value = '', update, active }) => (
111 | update(value)}
114 | disabled={active === value}
115 | >
116 |
117 |
118 | );
119 |
120 | const Link = props => (
121 |
122 | {props.text}
123 |
124 | );
125 |
126 | const keys = [
127 | { value: 'playlist', icon: playlistIcon },
128 | { value: 'video', icon: playIcon },
129 | { value: 'notes', icon: notebookIcon }
130 | ];
131 |
132 | const App = () => {
133 | const [active, updateActive] = React.useState(keys[0].value);
134 | return (
135 |
136 |
141 | Project
142 |
143 | This project is built with React, Linaria and VideoJS, using the
144 | Context API quite messily. More details are available on{' '}
145 | {' '}
149 | (e.g. future goals). Collaboration and feedback is strongly
150 | welcomed.
151 |
152 |
153 | About Me
154 |
155 | I am a London based frontend developer trying to find fun side
156 | projects to do and collaborate on. Feel free to look at my work
157 | and get in touch via{' '}
158 | or{' '}
159 |
160 |
161 |
162 |
163 | If you like this and would like to support expanding upon it,
164 | I’d really appreciate some support over at{' '}
165 |
169 |
170 |
171 |
172 | {/* eslint-disable-next-line jsx-a11y/no-distracting-elements */}
173 |
174 | Other Projects:{' '}
175 |
179 | ,{' '}
180 |
185 | ,{' '}
186 |
190 | , other half baked things I haven‘t finished yet
191 |
192 |
193 | }
194 | level={1}
195 | >
196 | CourseBuilder
197 |
198 |
199 | {keys.map(k => (
200 |
207 | ))}
208 |
209 |
214 |
215 | );
216 | };
217 |
218 | export default App;
219 |
--------------------------------------------------------------------------------
/src/api/youtube.js:
--------------------------------------------------------------------------------
1 | const fetchYoutubeApiCore = (req, youtubeKey) =>
2 | window
3 | .fetch(
4 | `https://www.googleapis.com/youtube/v3/${req}&key=${process.env
5 | .YOUTUBE_API_KEY || youtubeKey}`,
6 | {
7 | method: 'GET',
8 | headers: {
9 | 'Content-Type': 'application/json'
10 | }
11 | }
12 | )
13 | .then(res => res.json());
14 |
15 | export const fetchPlaylist = (id, youtubeKey) => {
16 | const x = fetchYoutubeApiCore(`playlists?id=${id}&part=snippet`, youtubeKey);
17 | return x;
18 | };
19 |
20 | export const fetchPlaylistItems = (id, youtubeKey) =>
21 | fetchYoutubeApiCore(
22 | `playlistItems?playlistId=${id}&part=snippet,contentDetails&maxResults=50`,
23 | youtubeKey
24 | );
25 |
26 | export const fetchVideoDetails = (idList, youtubeKey) =>
27 | fetchYoutubeApiCore(`videos?part=contentDetails&id=${idList}`, youtubeKey);
28 |
--------------------------------------------------------------------------------
/src/assets/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/padraigfl/videojs-react-course-assistant/33d3be55abab31a72a66c95b675786c9706c3094/src/assets/image.png
--------------------------------------------------------------------------------
/src/assets/notebook.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/playlist.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Heading/Heading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { styled } from 'linaria/react';
3 | import { Header } from '../styledShared';
4 | import { colors, spacings } from '../../constants/styles';
5 | import SettingsModal from './SettingsModal';
6 |
7 | const SettingsButton = styled('button')`
8 | display: flex;
9 | background-color: transparent;
10 | border: none;
11 | color: ${colors.light};
12 | padding: 0px ${spacings.xs}px;
13 | align-self: center;
14 | align-content: center;
15 | margin-left: auto;
16 | font-size: 24px;
17 | filter: brightness(50%);
18 | `;
19 |
20 | const Heading = props => {
21 | const [modal, toggleModal] = React.useState();
22 | const HTag = `h${props.level}`;
23 | return (
24 |
25 | {props.children}
26 | toggleModal(true)}>
27 | {props.icon || '⚙'}
28 |
29 | toggleModal(false)} display={modal}>
30 | {props.settingsView}
31 |
32 |
33 | );
34 | };
35 |
36 | Heading.defaultProps = {
37 | level: 5
38 | };
39 |
40 | export default Heading;
41 |
--------------------------------------------------------------------------------
/src/components/Heading/SettingsModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { styled } from 'linaria/react';
4 | import { colors, spacings } from '../../constants/styles';
5 | import { CancelButton } from '../styledShared';
6 |
7 | const Overlay = styled('div')`
8 | position: absolute;
9 | left: 0px;
10 | top: 0px;
11 | height: 100%;
12 | width: 100%;
13 | align-items: center;
14 | background-color: rgba(0, 0, 0, 0.5);
15 | display: flex;
16 | `;
17 | const Settings = styled('div')`
18 | position: relative;
19 | background-color: ${colors.light};
20 | margin: auto;
21 | width: 90%;
22 | max-width: 400px;
23 | padding: ${spacings.m}px;
24 | display: block;
25 | `;
26 |
27 | const SettingsModal = props => {
28 | return props.display
29 | ? ReactDOM.createPortal(
30 |
31 | e.stopPropagation()}>
32 | {props.children}
33 |
37 | X
38 |
39 |
40 | ,
41 | document.getElementById('settings')
42 | )
43 | : null;
44 | };
45 |
46 | export default SettingsModal;
47 |
--------------------------------------------------------------------------------
/src/components/Notes/Notes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css, cx } from 'linaria';
3 | import CourseContext from '../../context';
4 | import Notetaker from './Notetaker';
5 | import downloadIcon from '../../assets/download.svg';
6 |
7 | import {
8 | ListSection,
9 | List,
10 | ListEntry,
11 | EllipsisTextLine,
12 | CancelButton
13 | } from '../styledShared';
14 | import { formatTime } from '../../helpers';
15 | import Heading from '../Heading/Heading';
16 |
17 | const main = css`
18 | display: flex;
19 | flex-direction: column;
20 | `;
21 |
22 | const downloadNotes = (notes, playlist) => {
23 | const a = document.createElement('a');
24 | a.style.display = 'none';
25 | document.body.appendChild(a);
26 |
27 | // Set the HREF to a Blob representation of the data to be downloaded
28 | a.href = window.URL.createObjectURL(
29 | new Blob(
30 | [
31 | JSON.stringify({
32 | playlist: playlist.id,
33 | notes: playlist.order.map(v => ({
34 | track: playlist.items[v].title,
35 | notes: notes[v]
36 | }))
37 | })
38 | ],
39 | { type: 'text/json' }
40 | )
41 | );
42 |
43 | // Use download attribute to set set desired file name
44 | a.setAttribute('download', `Notes_${playlist.id}`);
45 |
46 | // Trigger the download by simulating click
47 | a.click();
48 |
49 | // Cleanup
50 | window.URL.revokeObjectURL(a.href);
51 | document.body.removeChild(a);
52 | };
53 |
54 | const Notes = props => {
55 | const context = React.useContext(CourseContext);
56 | const [activeNote, selectNote] = React.useState();
57 |
58 | const dlNotes = () => downloadNotes(context.notes, context.playlist);
59 |
60 | return (
61 |
67 |
70 |
71 | Download JSON of notes
72 |
73 |
74 | }
75 | icon={ }
76 | level={2}
77 | >
78 | Notes & Bookmarks
79 |
80 |
81 | {context.playlist.order.map(vidId =>
82 | (context.notes[vidId] || []).map((note, idx) => (
83 | selectNote(note)}
86 | >
87 |
110 |
111 | ))
112 | )}
113 |
114 |
115 |
122 |
123 |
124 | );
125 | };
126 |
127 | export default Notes;
128 |
--------------------------------------------------------------------------------
/src/components/Notes/Notetaker.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { styled } from 'linaria/react';
3 |
4 | import CourseContext from '../../context';
5 | import { colors, spacings } from '../../constants/styles';
6 | import { formatTime } from '../../helpers';
7 |
8 | const NoteSection = styled('div')`
9 | display: flex;
10 | flex-direction: column;
11 | padding: ${spacings.s}px;
12 | background-color: ${colors.brand};
13 | margin-top: auto;
14 | position: relative;
15 | `;
16 | const TextArea = styled('textarea')`
17 | height: 100px;
18 | background-color: ${colors.accent};
19 | flex-grow: 1;
20 | `;
21 | const NoteForm = styled('div')`
22 | display: flex;
23 | `;
24 | const NoteActions = styled('div')`
25 | font-weight: bold;
26 | display: flex;
27 | flex-direction: column;
28 | button {
29 | flex-grow: 1;
30 | }
31 | `;
32 | const TimeStamp = styled('div')`
33 | display: block;
34 | position: absolute;
35 | bottom: 0px;
36 | left: ${spacings.s}px;
37 | font-size: 0.75rem;
38 | max-width: calc(100% - ${spacings.s * 2});
39 | overflow: hidden;
40 | text-overflow: ellipsis;
41 | `;
42 |
43 | const Notetaker = props => {
44 | const context = React.useContext(CourseContext);
45 | const textEl = React.useRef();
46 | const propNote = props.note || {};
47 | const [text, updateNote] = React.useState(propNote.text);
48 | const [time, updateTime] = React.useState(propNote.time || 0);
49 | const [video, updateVideo] = React.useState(propNote.video || 0);
50 |
51 | const submitNote = e => {
52 | if (e.type === 'click' || (!e.shiftKey && e.which === 13)) {
53 | e.preventDefault();
54 | context.alterNotes.add(
55 | {
56 | time,
57 | text
58 | },
59 | context.playlist.order[video]
60 | );
61 | updateTime(null);
62 | updateNote('');
63 | textEl.current.blur();
64 | }
65 | };
66 |
67 | return (
68 |
69 |
70 |
124 |
125 | );
126 | };
127 |
128 | Notetaker.defaultProps = {
129 | noteStart: 0
130 | };
131 |
132 | export default Notetaker;
133 |
--------------------------------------------------------------------------------
/src/components/Notes/index.js:
--------------------------------------------------------------------------------
1 | import Notes from './Notes';
2 |
3 | export default Notes;
4 |
--------------------------------------------------------------------------------
/src/components/Playlist/Playlist.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { styled } from 'linaria/react';
3 | import { spacings, colors } from '../../constants/styles';
4 | import { formatTime, ytTimeToSeconds } from '../../helpers';
5 | import {
6 | fetchPlaylistItems,
7 | fetchVideoDetails,
8 | fetchPlaylist
9 | } from '../../api/youtube';
10 |
11 | import { ListSection, ListEntry, List } from '../styledShared';
12 | import CourseContext from '../../context';
13 | import Heading from '../Heading/Heading';
14 |
15 | if (!process.env.YOUTUBE_API_KEY) {
16 | console.error('DOTENV-FAILURE');
17 | }
18 |
19 | // const formatVideoResponse = resp => ({
20 | // items: resp.items.map(item => ({
21 | // ...item.contentDetails
22 | // }))
23 | // });
24 |
25 | const formatPlaylistResponse = (resp, id, playlistInfo = {}) => ({
26 | total: resp.pageInfo.totalResults,
27 | items: resp.items.reduce(
28 | (acc, { snippet }) => ({
29 | ...acc,
30 | [snippet.resourceId.videoId]: {
31 | videoId: snippet.resourceId.videoId,
32 | thumbnail: snippet.thumbnails.default,
33 | posterImage: snippet.thumbnails.high.url,
34 | channelId: snippet.channelId,
35 | channelTitle: snippet.channelTitle,
36 | title: snippet.title,
37 | description: snippet.description
38 | }
39 | }),
40 | {}
41 | ),
42 | id,
43 | ...playlistInfo,
44 | order: resp.items.map(({ snippet }) => snippet.resourceId.videoId)
45 | });
46 |
47 | const getNewPlaylist = (
48 | playlistId,
49 | setPlaylist,
50 | youtubeKey = process.env.YOUTUBE_API_KEY
51 | ) =>
52 | fetchPlaylist(playlistId, youtubeKey)
53 | .then(({ items }) => {
54 | if (items.length === 0) {
55 | alert('Fetched playlist is empty');
56 | return;
57 | }
58 | const { snippet } = items[0];
59 | fetchPlaylistItems(playlistId, youtubeKey).then(result => {
60 | const formattedResult = formatPlaylistResponse(
61 | result,
62 | playlistId,
63 | snippet
64 | );
65 | return fetchVideoDetails(formattedResult.order.join(','), youtubeKey)
66 | .then(vidDetails => {
67 | vidDetails.items.forEach(item => {
68 | formattedResult.items[item.id] = {
69 | ...formattedResult.items[item.id],
70 | ...item.contentDetails,
71 | duration: ytTimeToSeconds(item.contentDetails.duration)
72 | };
73 | });
74 | setPlaylist(formattedResult);
75 | })
76 | .catch(error => {
77 | console.error(error);
78 | setPlaylist(result);
79 | });
80 | });
81 | })
82 | .catch(error => {
83 | console.error(error);
84 | });
85 |
86 | const getPlaylist = (
87 | playlistUrl,
88 | getSavedPlaylist,
89 | setPlaylist,
90 | youtubeKey
91 | ) => {
92 | const playlistId = playlistUrl.replace(/.*list=/, '').replace(/&.*/, '');
93 | const playlist = getSavedPlaylist();
94 | if (playlist) {
95 | return;
96 | }
97 | getNewPlaylist(playlistId, setPlaylist, youtubeKey);
98 | };
99 |
100 | const Thumbnail = styled('div')`
101 | height: 45px;
102 | width: 45px;
103 | background-size: cover;
104 | background-position: ${spacings.s}px;
105 | flex-shrink: 0;
106 | margin-right: ${spacings.xs}px;
107 | `;
108 |
109 | const Form = styled(`div`)`
110 | margin: ${spacings.xs}px 0px ${spacings.s}px;
111 | `;
112 |
113 | const ChangePlaylist = styled('div')`
114 | color: ${colors.dark2};
115 | font-size: 1rem;
116 | `;
117 |
118 | const Playlist = props => {
119 | const context = React.useContext(CourseContext);
120 |
121 | const [playlistId, updatePlaylistId] = React.useState(
122 | context.playlist.id || ''
123 | );
124 |
125 | const updatePlaylist = () =>
126 | getPlaylist(playlistId, context.getSavedPlaylist, context.setNewPlaylist);
127 | return (
128 |
131 | Change}
133 | settingsView={
134 | <>
135 |
150 |
151 |
172 | >
173 | }
174 | level={2}
175 | >
176 | Playlist
177 |
178 | {context.playlist.title && {context.playlist.title}
}
179 |
180 | {context.playlist &&
181 | context.playlist.order.map((vidKey, idx) => {
182 | const {
183 | title,
184 | thumbnail,
185 | videoId,
186 | duration
187 | } = context.playlist.items[vidKey];
188 | return (
189 |
190 |
193 |
205 |
206 | );
207 | })}
208 |
209 |
210 | );
211 | };
212 |
213 | export default Playlist;
214 |
--------------------------------------------------------------------------------
/src/components/Playlist/index.js:
--------------------------------------------------------------------------------
1 | import Playlist from './Playlist';
2 |
3 | export default Playlist;
4 |
--------------------------------------------------------------------------------
/src/components/VideoPlayer/VideoPlayer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from 'linaria';
3 | import { styled } from 'linaria/react';
4 | import VideoComponent from './_Video';
5 |
6 | import CourseContext from '../../context';
7 | import { Header } from '../styledShared';
8 | import { spacings, colors } from '../../constants/styles';
9 |
10 | const VideoSection = styled('section')`
11 | display: flex;
12 | flex-direction: column;
13 | flex-grow: 1;
14 | @media (min-width: 1000px) and (min-height: 800px) {
15 | min-height: 50vh;
16 | margin: 0px 4px;
17 | }
18 | `;
19 |
20 | const videoStyles = css`
21 | flex-grow: 1;
22 | width: 100%;
23 | @media (max-width: 1000px) and (min-height: 1000px) {
24 | min-height: 50vh;
25 | }
26 | `;
27 |
28 | const Description = styled('div')`
29 | overflow: auto;
30 | background-color: ${colors.dark1};
31 | color: ${colors.accent};
32 | padding: ${spacings.s}px;
33 | margin-top: ${spacings.xs}px;
34 | max-height: 200px;
35 | `;
36 |
37 | export default class Video extends React.Component {
38 | static contextType = CourseContext;
39 |
40 | videoRef = React.createRef();
41 |
42 | state = {};
43 |
44 | currentDescription = () => {
45 | const currentTrackNumber =
46 | this.context.currentlyPlaying && this.context.currentlyPlaying.video;
47 | if (!currentTrackNumber || currentTrackNumber < 0) {
48 | return null;
49 | }
50 | const currentId = this.context.playlist.order[currentTrackNumber];
51 | const currentTrack = this.context.playlist.items[currentId];
52 | if (currentTrack) return currentTrack.description;
53 | return null;
54 | };
55 |
56 | setVideo = vid => {
57 | this.context.setVideo(vid);
58 | };
59 |
60 | video;
61 |
62 | // wrap the player in a div with a `data-vjs-player` attribute
63 | // so videojs won't create additional wrapper in the DOM
64 | // see https://github.com/videojs/video.js/pull/3856
65 | render() {
66 | const currentId = this.context.getCurrentlyPlayingId();
67 | return (
68 |
73 |
74 |
75 | {currentId
76 | ? this.context.playlist.items[currentId].title
77 | : 'Coursebuilder'}
78 |
79 |
80 |
86 | {this.currentDescription()}
87 |
88 | );
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/VideoPlayer/_Video.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class VideoPlayer extends React.Component {
4 | static defaultProps = {
5 | id: 'vid1',
6 | className: 'vjs-16-9',
7 | // width: '640',
8 | // height: '360',
9 | setup: {
10 | techOrder: ['youtube'],
11 | sources: [
12 | {
13 | type: 'video/youtube',
14 | src: 'https://www.youtube.com/watch?v=TeccAtqd5K8'
15 | }
16 | ]
17 | },
18 | innerRef: React.createRef(),
19 | accessVideo: () => {}
20 | };
21 |
22 | state = {};
23 |
24 | componentDidMount() {
25 | if (!videojs) {
26 | this.setState({ noVideoJs: 'videojs' });
27 | } else if (this.needsYoutube() && !this.hasYoutube()) {
28 | this.setState({ noVideoJs: 'youtube' });
29 | } else {
30 | this.instantiate();
31 | }
32 | }
33 |
34 | // destroy player on unmount
35 | componentWillUnmount() {
36 | if (this.player) {
37 | this.props.setVideo(null);
38 | this.player.dispose();
39 | }
40 | }
41 |
42 | needsYoutube = () =>
43 | this.props.youtube ||
44 | this.props.setup.techOrder.find(tech => tech.toLowerCase === 'youtube');
45 |
46 | hasYoutube = () => videojs && videojs.getTech('youtube');
47 |
48 | /* TODO
49 | * Fallbacks do not currently work
50 | * videojs-youtube defaults to a different version of video.js than imported
51 | */
52 |
53 | // videoJsFallback = () => import('video.js').then(this.youtubeFallback);
54 |
55 | // youtubeFallback = () => {
56 | // if (this.props.youtube || this.props.setup.techOrder.find(tech => tech.toLowerCase === 'youtube')) {
57 | // import('videojs-youtube').then(() => {
58 | // this.instantiate();
59 | // });
60 | // }
61 | // };
62 |
63 | instantiate = () => {
64 | this.player = videojs(
65 | this.props.innerRef.current,
66 | this.props.setup,
67 | this.props.onReadyCheck ? () => this.props.onReadyCheck(this) : undefined
68 | );
69 | this.props.accessVideo(this.player);
70 | };
71 |
72 | // wrap the player in a div with a `data-vjs-player` attribute
73 | // so videojs won't create additional wrapper in the DOM
74 | // see https://github.com/videojs/video.js/pull/3856
75 | render() {
76 | const {
77 | setup,
78 | onReadyCheck,
79 | innerRef,
80 | accessVideo,
81 | className,
82 | ...rest
83 | } = this.props;
84 | if (this.state.noVideoJs === 'videojs') {
85 | return
Wheres Videojs?
;
86 | }
87 | if (this.state.noVideoJs === 'youtube') {
88 | return Wheres the youtube support
;
89 | }
90 | return (
91 |
92 |
99 |
100 | );
101 | }
102 | }
103 |
104 | export default VideoPlayer;
105 |
--------------------------------------------------------------------------------
/src/components/VideoPlayer/index.js:
--------------------------------------------------------------------------------
1 | import VideoPlayer from './VideoPlayer';
2 |
3 | export default VideoPlayer;
4 |
--------------------------------------------------------------------------------
/src/components/styledShared/index.js:
--------------------------------------------------------------------------------
1 | import { styled } from 'linaria/react';
2 | import { colors, spacings } from '../../constants/styles';
3 |
4 | export const ListSection = styled('section')`
5 | max-width: 350px;
6 | width: 30%;
7 | min-width: 250px;
8 | overflow-y: auto;
9 | background-color: ${colors.dark1};
10 | display: flex;
11 | flex-direction: column;
12 | max-height: 100%;
13 | `;
14 |
15 | export const List = styled('ul')`
16 | position: relative;
17 | overflow: auto;
18 | flex-grow: 1;
19 | padding-left: 0px;
20 | margin-top: 0px;
21 | margin-bottom: 0px;
22 | &:after {
23 | content: '';
24 | position: fixed;
25 | bottom: 0px;
26 | width: 100%;
27 | box-shadow: 0px 0px 10vh 5vh ${colors.dark1};
28 | }
29 | `;
30 |
31 | export const ListEntry = styled('li')`
32 | position: relative;
33 | display: flex;
34 | max-height: 100px;
35 | padding: ${spacings.xs}px;
36 | box-shadow: 0px 0px 0px 1px ${colors.accent};
37 | background-color: ${colors.dark1};
38 | overflow: hidden;
39 | a {
40 | display: block;
41 | color: ${colors.brand};
42 | font-weight: bold;
43 | &:hover {
44 | color: ${colors.accent};
45 | }
46 | }
47 | `;
48 |
49 | export const Header = styled('header')`
50 | margin: 0px;
51 | padding: ${spacings.xs}px;
52 | font-size: ${spacings.m}px;
53 | font-weight: bold;
54 | color: ${colors.light};
55 | background-color: ${colors.brand};
56 | display: flex;
57 | &.invert {
58 | color: ${colors.brand};
59 | background-color: ${colors.light};
60 | }
61 | @media (max-width: 500px) {
62 | font-size: ${spacings.s}px;
63 | }
64 | `;
65 |
66 | export const EllipsisTextLine = styled('div')`
67 | white-space: nowrap;
68 | overflow: hidden;
69 | text-overflow: ellipsis;
70 | `;
71 |
72 | export const CancelButton = styled('button')`
73 | position: absolute;
74 | top: 0px;
75 | right: 0px;
76 | color: ${colors.light};
77 | border: none;
78 | outline: none;
79 | font-size: 20px;
80 | background-color: transparent;
81 | padding-top: ${spacings.xs / 2}px;
82 | `;
83 |
--------------------------------------------------------------------------------
/src/constants/dummy.js:
--------------------------------------------------------------------------------
1 | export const initialPlaylist = {
2 | total: 20,
3 | id: 'testPlaylist',
4 | items: {
5 | OE2qEpkWWoQ: {
6 | videoId: 'OE2qEpkWWoQ',
7 | thumbnail: {
8 | url: 'https://i.ytimg.com/vi/OE2qEpkWWoQ/default.jpg',
9 | width: 120,
10 | height: 90
11 | },
12 | posterImage: 'https://i.ytimg.com/vi/OE2qEpkWWoQ/hqdefault.jpg',
13 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
14 | channelTitle: 'Full Album!',
15 | title: 'Carly Rae Jepsen - Run Away With Me (Audio)',
16 | duration: 260,
17 | description:
18 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
19 | },
20 | '_-XWAQ1wCAA': {
21 | videoId: '_-XWAQ1wCAA',
22 | thumbnail: {
23 | url: 'https://i.ytimg.com/vi/_-XWAQ1wCAA/default.jpg',
24 | width: 120,
25 | height: 90
26 | },
27 | posterImage: 'https://i.ytimg.com/vi/_-XWAQ1wCAA/hqdefault.jpg',
28 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
29 | channelTitle: 'Full Album!',
30 | title: 'Carly Rae Jepsen - Emotion (Audio)',
31 | duration: 260,
32 | description:
33 | 'Album: E•MO•TION\nRelease date: August 21, 2015 Buy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
34 | },
35 | '77PzXCKDyVQ': {
36 | videoId: '77PzXCKDyVQ',
37 | thumbnail: {
38 | url: 'https://i.ytimg.com/vi/77PzXCKDyVQ/default.jpg',
39 | width: 120,
40 | height: 90
41 | },
42 | posterImage: 'https://i.ytimg.com/vi/77PzXCKDyVQ/hqdefault.jpg',
43 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
44 | channelTitle: 'Full Album!',
45 | title: 'Carly Rae Jepsen - I Really Like You (Audio)',
46 | duration: 260,
47 | description:
48 | 'Get E•MO•TION on iTunes now: http://smarturl.it/E-MO-TION\n\nSign up for Carly Rae Jepsen news here: http://smarturl.it/CRJ.News\n\nMusic video by Carly Rae Jepsen performing I Really Like You. (C) 2015 School Boy/Interscope Records'
49 | },
50 | OKsAnpPg15c: {
51 | videoId: 'OKsAnpPg15c',
52 | thumbnail: {
53 | url: 'https://i.ytimg.com/vi/OKsAnpPg15c/default.jpg',
54 | width: 120,
55 | height: 90
56 | },
57 | posterImage: 'https://i.ytimg.com/vi/OKsAnpPg15c/hqdefault.jpg',
58 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
59 | channelTitle: 'Full Album!',
60 | title: 'Carly Rae Jepsen - Gimmie Love (Audio)',
61 | duration: 260,
62 | description:
63 | 'Album: E•MO•TION\nRelease date: August 21, 2015 Buy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
64 | },
65 | W374tWnsk70: {
66 | videoId: 'W374tWnsk70',
67 | thumbnail: {
68 | url: 'https://i.ytimg.com/vi/W374tWnsk70/default.jpg',
69 | width: 120,
70 | height: 90
71 | },
72 | posterImage: 'https://i.ytimg.com/vi/W374tWnsk70/hqdefault.jpg',
73 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
74 | channelTitle: 'Full Album!',
75 | title: 'Carly Rae Jepsen - All That (Audio)',
76 | duration: 260,
77 | description:
78 | 'Get E•MO•TION on iTunes now: http://smarturl.it/E-MO-TION\n\nSign up for Carly Rae Jepsen news here: http://smarturl.it/CRJ.News'
79 | },
80 | YSf_i1SUFhM: {
81 | videoId: 'YSf_i1SUFhM',
82 | thumbnail: {
83 | url: 'https://i.ytimg.com/vi/YSf_i1SUFhM/default.jpg',
84 | width: 120,
85 | height: 90
86 | },
87 | posterImage: 'https://i.ytimg.com/vi/YSf_i1SUFhM/hqdefault.jpg',
88 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
89 | channelTitle: 'Full Album!',
90 | title: 'Carly Rae Jepsen - Boy Problems (Audio)',
91 | duration: 260,
92 | description:
93 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
94 | },
95 | gBOhnT6bUaY: {
96 | videoId: 'gBOhnT6bUaY',
97 | thumbnail: {
98 | url: 'https://i.ytimg.com/vi/gBOhnT6bUaY/default.jpg',
99 | width: 120,
100 | height: 90
101 | },
102 | posterImage: 'https://i.ytimg.com/vi/gBOhnT6bUaY/hqdefault.jpg',
103 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
104 | channelTitle: 'Full Album!',
105 | title: 'Carly Rae Jepsen - Making The Most Of The Night (Audio)',
106 | duration: 260,
107 | description:
108 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
109 | },
110 | _98VZT9s7M0: {
111 | videoId: '_98VZT9s7M0',
112 | thumbnail: {
113 | url: 'https://i.ytimg.com/vi/_98VZT9s7M0/default.jpg',
114 | width: 120,
115 | height: 90
116 | },
117 | posterImage: 'https://i.ytimg.com/vi/_98VZT9s7M0/hqdefault.jpg',
118 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
119 | channelTitle: 'Full Album!',
120 | title: 'Carly Rae Jepsen - Your Type (Audio)',
121 | duration: 260,
122 | description:
123 | 'Get E•MO•TION on iTunes now: http://smarturl.it/E-MO-TION\n\nArt by Kelsey Montague\nInstagram: https://instagram.com/kelseymontagueart/\nFacebook: https://www.facebook.com/pages/Kelsey-Montague-Art/379278752084914\nTwitter: https://twitter.com/kelsmontagueart\nWebsite: http://kelseymontagueart.com/\n\nConnect with Carly Rae Jepsen:\nOfficial - http://www.carlyraemusic.com \nYouTube: https://www.youtube.com/user/CarlyRaeMusic \nTwitter - https://twitter.com/CarlyRaeJepsen \nFacebook - https://www.facebook.com/CarlyRaeJepsen \nInstagram - http://instagram.com/CarlyRaeJepsen \nSign up for Carly Rae Jepsen news here: http://smarturl.it/CRJ.News\n\nhttp://vevo.ly/ac4m7w'
124 | },
125 | S2BGjJ_4BTk: {
126 | videoId: 'S2BGjJ_4BTk',
127 | thumbnail: {
128 | url: 'https://i.ytimg.com/vi/S2BGjJ_4BTk/default.jpg',
129 | width: 120,
130 | height: 90
131 | },
132 | posterImage: 'https://i.ytimg.com/vi/S2BGjJ_4BTk/hqdefault.jpg',
133 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
134 | channelTitle: 'Full Album!',
135 | title: "Carly Rae Jepsen - Let's Get Lost (Audio)",
136 | duration: 260,
137 | description:
138 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
139 | },
140 | RPrZUiDVHrM: {
141 | videoId: 'RPrZUiDVHrM',
142 | thumbnail: {
143 | url: 'https://i.ytimg.com/vi/RPrZUiDVHrM/default.jpg',
144 | width: 120,
145 | height: 90
146 | },
147 | posterImage: 'https://i.ytimg.com/vi/RPrZUiDVHrM/hqdefault.jpg',
148 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
149 | channelTitle: 'Full Album!',
150 | title: 'Carly Rae Jepsen - LA Hallucinations (Audio)',
151 | duration: 260,
152 | description:
153 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
154 | },
155 | iBPw0l26L58: {
156 | videoId: 'iBPw0l26L58',
157 | thumbnail: {
158 | url: 'https://i.ytimg.com/vi/iBPw0l26L58/default.jpg',
159 | width: 120,
160 | height: 90
161 | },
162 | posterImage: 'https://i.ytimg.com/vi/iBPw0l26L58/hqdefault.jpg',
163 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
164 | channelTitle: 'Full Album!',
165 | title: 'Carly Rae Jepsen - Warm Blood (Audio)',
166 | duration: 260,
167 | description:
168 | 'Get E•MO•TION on iTunes now: http://smarturl.it/E-MO-TION\n\nArt by Shelby Edwards of LITTLEDRILL\nInstagram: http://instagram.com/LITTLEDRILL\nTwitter: http://twitter.com/itsLITTLEDRILL\nTumblr: http://LITTLEDRILL.tumblr.com\n\nConnect with Carly Rae Jepsen:\nOfficial - http://www.carlyraemusic.com\nYouTube: http://www.youtube.com/carlyraejepsen...\nTwitter - https://twitter.com/CarlyRaeJepsen\nFacebook - https://www.facebook.com/CarlyRaeJepsen\nInstagram - http://instagram.com/CarlyRaeJepsen\n\n“Warm Blood” was produced by Rostam Batmanglij.\n\nSign up for Carly Rae Jepsen news here: http://smarturl.it/CRJ.News\n\nhttp://vevo.ly/vqKKkx'
169 | },
170 | 'FLkj9zr0-sQ': {
171 | videoId: 'FLkj9zr0-sQ',
172 | thumbnail: {
173 | url: 'https://i.ytimg.com/vi/FLkj9zr0-sQ/default.jpg',
174 | width: 120,
175 | height: 90
176 | },
177 | posterImage: 'https://i.ytimg.com/vi/FLkj9zr0-sQ/hqdefault.jpg',
178 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
179 | channelTitle: 'Full Album!',
180 | title: 'Carly Rae Jepsen - When I Needed You (Audio)',
181 | duration: 260,
182 | description:
183 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
184 | },
185 | a79Y7Mbqx1I: {
186 | videoId: 'a79Y7Mbqx1I',
187 | thumbnail: {
188 | url: 'https://i.ytimg.com/vi/a79Y7Mbqx1I/default.jpg',
189 | width: 120,
190 | height: 90
191 | },
192 | posterImage: 'https://i.ytimg.com/vi/a79Y7Mbqx1I/hqdefault.jpg',
193 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
194 | channelTitle: 'Full Album!',
195 | title: 'Carly Rae Jepsen - Black Heart (Audio)',
196 | duration: 260,
197 | description:
198 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
199 | },
200 | '6_zD5-ij7So': {
201 | videoId: '6_zD5-ij7So',
202 | thumbnail: {
203 | url: 'https://i.ytimg.com/vi/6_zD5-ij7So/default.jpg',
204 | width: 120,
205 | height: 90
206 | },
207 | posterImage: 'https://i.ytimg.com/vi/6_zD5-ij7So/hqdefault.jpg',
208 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
209 | channelTitle: 'Full Album!',
210 | title: "Carly Rae Jepsen - I Didn't Just Come Here To Dance (Audio)",
211 | duration: 260,
212 | description:
213 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
214 | },
215 | '0gpGqGHEr_8': {
216 | videoId: '0gpGqGHEr_8',
217 | thumbnail: {
218 | url: 'https://i.ytimg.com/vi/0gpGqGHEr_8/default.jpg',
219 | width: 120,
220 | height: 90
221 | },
222 | posterImage: 'https://i.ytimg.com/vi/0gpGqGHEr_8/hqdefault.jpg',
223 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
224 | channelTitle: 'Full Album!',
225 | title: 'Carly Rae Jepsen - Favourite Colour (Audio)',
226 | duration: 260,
227 | description:
228 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
229 | },
230 | qvApKIuJ68M: {
231 | videoId: 'qvApKIuJ68M',
232 | thumbnail: {
233 | url: 'https://i.ytimg.com/vi/qvApKIuJ68M/default.jpg',
234 | width: 120,
235 | height: 90
236 | },
237 | posterImage: 'https://i.ytimg.com/vi/qvApKIuJ68M/hqdefault.jpg',
238 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
239 | channelTitle: 'Full Album!',
240 | title: 'Carly Rae Jepsen - Never Get to Hold You (Audio)',
241 | duration: 260,
242 | description:
243 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
244 | },
245 | z7Zmc838tCE: {
246 | videoId: 'z7Zmc838tCE',
247 | thumbnail: {
248 | url: 'https://i.ytimg.com/vi/z7Zmc838tCE/default.jpg',
249 | width: 120,
250 | height: 90
251 | },
252 | posterImage: 'https://i.ytimg.com/vi/z7Zmc838tCE/hqdefault.jpg',
253 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
254 | channelTitle: 'Full Album!',
255 | title: 'Carly Rae Jepsen - Love Again (Audio)',
256 | duration: 260,
257 | description:
258 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
259 | },
260 | ucshSaH5Jv4: {
261 | videoId: 'ucshSaH5Jv4',
262 | thumbnail: {
263 | url: 'https://i.ytimg.com/vi/ucshSaH5Jv4/default.jpg',
264 | width: 120,
265 | height: 90
266 | },
267 | posterImage: 'https://i.ytimg.com/vi/ucshSaH5Jv4/hqdefault.jpg',
268 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
269 | channelTitle: 'Full Album!',
270 | title:
271 | 'Carly Rae Jepsen - I Really Like You (Liam Keegan Remix Radio Edit) (Audio)',
272 | duration: 260,
273 | description:
274 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
275 | },
276 | n5g_xj0FWyw: {
277 | videoId: 'n5g_xj0FWyw',
278 | thumbnail: {
279 | url: 'https://i.ytimg.com/vi/n5g_xj0FWyw/default.jpg',
280 | width: 120,
281 | height: 90
282 | },
283 | posterImage: 'https://i.ytimg.com/vi/n5g_xj0FWyw/hqdefault.jpg',
284 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
285 | channelTitle: 'Full Album!',
286 | title: 'Carly Rae Jepsen - First Time (Audio)',
287 | duration: 260,
288 | description: 'Album: EMOTION REMIXED +\nRelease date: March 18, 2016'
289 | },
290 | 'Ao-HiX7w9GQ': {
291 | videoId: 'Ao-HiX7w9GQ',
292 | thumbnail: {
293 | url: 'https://i.ytimg.com/vi/Ao-HiX7w9GQ/default.jpg',
294 | width: 120,
295 | height: 90
296 | },
297 | posterImage: 'https://i.ytimg.com/vi/Ao-HiX7w9GQ/hqdefault.jpg',
298 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
299 | channelTitle: 'Full Album!',
300 | title: 'Carly Rae Jepsen - Fever (Audio)',
301 | duration: 260,
302 | description: 'Album: EMOTION REMIXED +\nRelease date: March 18, 2016'
303 | }
304 | },
305 | order: [
306 | 'OE2qEpkWWoQ',
307 | '_-XWAQ1wCAA',
308 | '77PzXCKDyVQ',
309 | 'OKsAnpPg15c',
310 | 'W374tWnsk70',
311 | 'YSf_i1SUFhM',
312 | 'gBOhnT6bUaY',
313 | '_98VZT9s7M0',
314 | 'S2BGjJ_4BTk',
315 | 'RPrZUiDVHrM',
316 | 'iBPw0l26L58',
317 | 'FLkj9zr0-sQ',
318 | 'a79Y7Mbqx1I',
319 | '6_zD5-ij7So',
320 | '0gpGqGHEr_8',
321 | 'qvApKIuJ68M',
322 | 'z7Zmc838tCE',
323 | 'ucshSaH5Jv4',
324 | 'n5g_xj0FWyw',
325 | 'Ao-HiX7w9GQ'
326 | ]
327 | };
328 |
329 | export const filmish = {
330 | total: 11,
331 | items: {
332 | OE2qEpkWWoQ: {
333 | videoId: 'OE2qEpkWWoQ',
334 | thumbnail: {
335 | url: 'https://i.ytimg.com/vi/OE2qEpkWWoQ/default.jpg',
336 | width: 120,
337 | height: 90
338 | },
339 | posterImage: 'https://i.ytimg.com/vi/OE2qEpkWWoQ/hqdefault.jpg',
340 | channelId: 'UC-1rVsh8pvXBAMZ5q_B3CEg',
341 | channelTitle: 'Full Album!',
342 | title: 'Carly Rae Jepsen - Run Away With Me (Audio)',
343 | duration: 260,
344 | description:
345 | 'Album: E•MO•TION\nRelease date: August 21, 2015\nBuy E•MO•TION on iTunes: http://smarturl.it/E-MO-TION'
346 | },
347 | '--P3SK4Prlc': {
348 | videoId: '--P3SK4Prlc',
349 | thumbnail: {
350 | url: 'https://i.ytimg.com/vi/--P3SK4Prlc/default.jpg',
351 | width: 120,
352 | height: 90
353 | },
354 | posterImage: 'https://i.ytimg.com/vi/--P3SK4Prlc/hqdefault.jpg',
355 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
356 | channelTitle: 'Paul Anderson',
357 | title: 'Kubrick single Point Perspective edit By Kogonada',
358 | description: 'Original video by Kogonada here http://vimeo.com/48425421',
359 | duration: 105,
360 | dimension: '2d',
361 | definition: 'sd',
362 | caption: 'false',
363 | licensedContent: false,
364 | projection: 'rectangular'
365 | },
366 | '0kGV3fOowAY': {
367 | videoId: '0kGV3fOowAY',
368 | thumbnail: {
369 | url: 'https://i.ytimg.com/vi/0kGV3fOowAY/default.jpg',
370 | width: 120,
371 | height: 90
372 | },
373 | posterImage: 'https://i.ytimg.com/vi/0kGV3fOowAY/hqdefault.jpg',
374 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
375 | channelTitle: 'Paul Anderson',
376 | title: 'Reframe - Wong Kar Wai',
377 | description: '',
378 | duration: 158,
379 | dimension: '2d',
380 | definition: 'hd',
381 | caption: 'false',
382 | licensedContent: false,
383 | projection: 'rectangular'
384 | },
385 | '1SOOfVMohvE': {
386 | videoId: '1SOOfVMohvE',
387 | thumbnail: {
388 | url: 'https://i.ytimg.com/vi/1SOOfVMohvE/default.jpg',
389 | width: 120,
390 | height: 90
391 | },
392 | posterImage: 'https://i.ytimg.com/vi/1SOOfVMohvE/hqdefault.jpg',
393 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
394 | channelTitle: 'Paul Anderson',
395 | title: 'Wes Anderson Centered by kogonada',
396 | description:
397 | 'Бесплатная раскрутка канала You Tube!!!\nБез фейков и накруток!!! Смотрите видео \nhttps://youtu.be/OLjeFKp2G2o',
398 | duration: 144,
399 | dimension: '2d',
400 | definition: 'hd',
401 | caption: 'false',
402 | licensedContent: false,
403 | projection: 'rectangular'
404 | },
405 | fdUPRnJhGE4: {
406 | videoId: 'fdUPRnJhGE4',
407 | thumbnail: {
408 | url: 'https://i.ytimg.com/vi/fdUPRnJhGE4/default.jpg',
409 | width: 120,
410 | height: 90
411 | },
412 | posterImage: 'https://i.ytimg.com/vi/fdUPRnJhGE4/hqdefault.jpg',
413 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
414 | channelTitle: 'Paul Anderson',
415 | title: "Columbus' References",
416 | description:
417 | '(dis)Sequenze#18\nhttp://www.cineforum.it/rubrica/dis_Sequenze/dis-Sequenze-18-Kogonada-e-il-superamento-della-scrittura\nhttp://www.cineforum.it/\nmusic: Hammock - Reverence',
418 | duration: 215,
419 | dimension: '2d',
420 | definition: 'hd',
421 | caption: 'false',
422 | licensedContent: false,
423 | projection: 'rectangular'
424 | },
425 | TG1SsxdjO0Y: {
426 | videoId: 'TG1SsxdjO0Y',
427 | thumbnail: {
428 | url: 'https://i.ytimg.com/vi/TG1SsxdjO0Y/default.jpg',
429 | width: 120,
430 | height: 90
431 | },
432 | posterImage: 'https://i.ytimg.com/vi/TG1SsxdjO0Y/hqdefault.jpg',
433 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
434 | channelTitle: 'Paul Anderson',
435 | title: 'Against Tyranny :: kogonada',
436 | description: '2014, :: kogonada.',
437 | duration: 640,
438 | dimension: '2d',
439 | definition: 'hd',
440 | caption: 'false',
441 | licensedContent: false,
442 | projection: 'rectangular'
443 | },
444 | uk_yKYhBjKA: {
445 | videoId: 'uk_yKYhBjKA',
446 | thumbnail: {
447 | url: 'https://i.ytimg.com/vi/uk_yKYhBjKA/default.jpg',
448 | width: 120,
449 | height: 90
450 | },
451 | posterImage: 'https://i.ytimg.com/vi/uk_yKYhBjKA/hqdefault.jpg',
452 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
453 | channelTitle: 'Paul Anderson',
454 | title: 'Hands of Bresson',
455 | description:
456 | 'Out now on Criterion: http://www.criterion.com/explore/10-robert-bresson',
457 | duration: 254,
458 | dimension: '2d',
459 | definition: 'hd',
460 | caption: 'false',
461 | licensedContent: true,
462 | projection: 'rectangular'
463 | },
464 | TC5WTwwrPmI: {
465 | videoId: 'TC5WTwwrPmI',
466 | thumbnail: {
467 | url: 'https://i.ytimg.com/vi/TC5WTwwrPmI/default.jpg',
468 | width: 120,
469 | height: 90
470 | },
471 | posterImage: 'https://i.ytimg.com/vi/TC5WTwwrPmI/hqdefault.jpg',
472 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
473 | channelTitle: 'Paul Anderson',
474 | title: 'Nostalghia(1983)/ Andrei Tarkovsky / BWV853',
475 | description:
476 | 'Music:Johann Sebastian Bach\nPiano:Sviatoslav Richter\nThe Well-Tempered Clavier, Book 1\nPrelude and Fugue No. 8 in E flat minor, BWV 853',
477 | duration: 273,
478 | dimension: '2d',
479 | definition: 'hd',
480 | caption: 'false',
481 | licensedContent: false,
482 | projection: 'rectangular'
483 | },
484 | vJdQU_5E_Ao: {
485 | videoId: 'vJdQU_5E_Ao',
486 | thumbnail: {
487 | url: 'https://i.ytimg.com/vi/vJdQU_5E_Ao/default.jpg',
488 | width: 120,
489 | height: 90
490 | },
491 | posterImage: 'https://i.ytimg.com/vi/vJdQU_5E_Ao/hqdefault.jpg',
492 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
493 | channelTitle: 'Paul Anderson',
494 | title: '"Listen to Bach (The Earth)" from "Solaris"',
495 | description:
496 | 'Bel Air Music presents the clip "Listen to Bach (The Earth)" from the film "Solaris" (1972), one of 24 clips from 18 Russian films 1932 to 2004 featured on Russian Film Music III CD & DVD. https://itunes.apple.com/us/album/russian-film-music-iii/id665574216',
497 | duration: 233,
498 | dimension: '2d',
499 | definition: 'sd',
500 | caption: 'false',
501 | licensedContent: false,
502 | projection: 'rectangular'
503 | },
504 | SJZVCW0JxKw: {
505 | videoId: 'SJZVCW0JxKw',
506 | thumbnail: {
507 | url: 'https://i.ytimg.com/vi/SJZVCW0JxKw/default.jpg',
508 | width: 120,
509 | height: 90
510 | },
511 | posterImage: 'https://i.ytimg.com/vi/SJZVCW0JxKw/hqdefault.jpg',
512 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
513 | channelTitle: 'Paul Anderson',
514 | title:
515 | "Solaris (1972) - All scenes with composed 'Bach' - Edward Artemiev",
516 | description:
517 | "All the scenes composed with the 'Bach' or in case known as the Solaris Theme in one compilation.",
518 | duration: 608,
519 | dimension: '2d',
520 | definition: 'hd',
521 | caption: 'false',
522 | licensedContent: false,
523 | projection: 'rectangular'
524 | },
525 | FWQdKkk4Xr0: {
526 | videoId: 'FWQdKkk4Xr0',
527 | thumbnail: {
528 | url: 'https://i.ytimg.com/vi/FWQdKkk4Xr0/default.jpg',
529 | width: 120,
530 | height: 90
531 | },
532 | posterImage: 'https://i.ytimg.com/vi/FWQdKkk4Xr0/hqdefault.jpg',
533 | channelId: 'UCwSS-NwvUn1KOkNkq_7jn0g',
534 | channelTitle: 'Paul Anderson',
535 | title: 'My Life In The Bush Of Ghosts . The Jezebel Spirit',
536 | description:
537 | 'My Life In The Bush Of Ghosts \nDavid Byrne Brian Eno\nand a lot a wonderful pictures!',
538 | duration: 295,
539 | dimension: '2d',
540 | definition: 'sd',
541 | caption: 'false',
542 | licensedContent: false,
543 | projection: 'rectangular'
544 | }
545 | },
546 | order: [
547 | 'OE2qEpkWWoQ',
548 | '--P3SK4Prlc',
549 | '0kGV3fOowAY',
550 | '1SOOfVMohvE',
551 | 'fdUPRnJhGE4',
552 | 'TG1SsxdjO0Y',
553 | 'uk_yKYhBjKA',
554 | 'TC5WTwwrPmI',
555 | 'vJdQU_5E_Ao',
556 | 'SJZVCW0JxKw',
557 | 'FWQdKkk4Xr0'
558 | ]
559 | };
560 |
--------------------------------------------------------------------------------
/src/constants/styles.js:
--------------------------------------------------------------------------------
1 | // export const dark1 = '#040303';
2 | // export const dark2 = '#223843';
3 | // export const light = '#EFF1F3';
4 | // export const brand = '#8B9D83';
5 | // export const accent = '#BEB0A7';
6 | // export const colors = {
7 | // dark1,
8 | // dark2,
9 | // light,
10 | // brand,
11 | // accent
12 | // };
13 |
14 | export const dark1 = '#393E41';
15 | export const dark2 = '#222222';
16 | export const accent = '#D6D9CE';
17 | export const light = '#DDE2E4';
18 | export const brand = '#89B6A5';
19 |
20 | export const colors = {
21 | dark1,
22 | dark2,
23 | light,
24 | brand,
25 | accent
26 | };
27 |
28 | export const spacingXS = 8;
29 | export const spacingS = 16;
30 | export const spacingM = 24;
31 | export const spacingL = 32;
32 | export const spacingXL = 40;
33 |
34 | export const spacings = {
35 | xs: spacingXS,
36 | s: spacingS,
37 | m: spacingM,
38 | l: spacingL,
39 | xl: spacingXL
40 | };
41 |
--------------------------------------------------------------------------------
/src/context/context.spec.js:
--------------------------------------------------------------------------------
1 | import { CourseProvider, insertAtIdx } from '.';
2 |
3 | const localStorageMock = () => {
4 | let store = {};
5 | return {
6 | clear() {
7 | store = {};
8 | },
9 | getItem(key) {
10 | return store[key];
11 | },
12 | setItem(key, value) {
13 | store[key] = value.toString();
14 | },
15 | removeItem(key) {
16 | delete store[key];
17 | }
18 | };
19 | };
20 |
21 | const mockVideo = {
22 | src: jest.fn(),
23 | play: jest.fn(),
24 | on: jest.fn(),
25 | currentTime: jest.fn()
26 | };
27 |
28 | const noteGen = val => ({
29 | text: val,
30 | time: val
31 | });
32 |
33 | Object.defineProperty(window, 'localStorage', { value: localStorageMock() });
34 |
35 | describe('ProgramProvider', () => {
36 | const component = new CourseProvider({ playlist: {} });
37 | component.state.video = mockVideo;
38 | component.state.playlist = { id: 'abc' };
39 | component.setState = val => {
40 | if (typeof val !== 'function') {
41 | component.state = {
42 | ...component.state,
43 | ...val
44 | };
45 | } else {
46 | component.state = {
47 | ...component.state,
48 | ...val(component.state)
49 | };
50 | }
51 | };
52 |
53 | it('setVideo', () => {
54 | const x = { ...mockVideo };
55 | component.setVideo(x);
56 | expect(component.state.video).toEqual(x);
57 | });
58 |
59 | it('setTrack', () => {
60 | component.state.playlist = {
61 | order: [0, 1]
62 | };
63 | component.setTrack(0);
64 | expect(component.state.currentlyPlaying).toEqual({
65 | video: 0,
66 | position: 0
67 | });
68 | component.setTrack(1, 123);
69 | expect(component.state.currentlyPlaying).toEqual({
70 | video: 1,
71 | position: 123
72 | });
73 | // @todo check position updates
74 | });
75 |
76 | it('getSavedPlaylist', () => {
77 | const playlist = { a: '123' };
78 | window.localStorage.setItem('playlist__playlist', JSON.stringify(playlist));
79 | expect(component.getSavedPlaylist('a')).toEqual(null);
80 | expect(component.getSavedPlaylist('playlist')).toEqual(playlist);
81 | });
82 |
83 | it('setNewPlaylist', () => {
84 | const playlist = { id: 'a', items: [] };
85 | component.setNewPlaylist(playlist);
86 | expect(component.state.playlist).toEqual(playlist);
87 | expect(component.state.notes).toEqual([]);
88 | });
89 |
90 | it('insert at index', () => {
91 | const list = [1, 2, 4, 5, 6];
92 | expect(insertAtIdx(list, 2, 3)).toEqual([1, 2, 3, 4, 5, 6]);
93 | expect(insertAtIdx(list, 5, 7)).toEqual([1, 2, 4, 5, 6, 7]);
94 | expect(insertAtIdx([], 0, 7)).toEqual([7]);
95 | });
96 |
97 | describe('alterNotes', () => {
98 | const note0 = noteGen(0);
99 | const note1 = noteGen(1);
100 | const note2 = noteGen(2);
101 | const note3 = noteGen(3);
102 |
103 | const editNote = { text: '2aa', time: '2' };
104 | it('addNotes', () => {
105 | component.alterNotes.add(note0, 'a');
106 | expect(component.state.notes).toEqual({ a: [note0] });
107 | component.alterNotes.add(note3, 'a');
108 | expect(component.state.notes).toEqual({ a: [note0, note3] });
109 | component.alterNotes.add(note2, 'b');
110 | expect(component.state.notes).toEqual({
111 | a: [note0, note3],
112 | b: [note2]
113 | });
114 | component.alterNotes.add(note1, 'a');
115 | expect(component.state.notes).toEqual({
116 | a: [note0, note1, note3],
117 | b: [note2]
118 | });
119 | });
120 | it('deleteNotes', () => {
121 | component.alterNotes.delete('a', 1);
122 | expect(component.state.notes).toEqual({
123 | a: [note0, note3],
124 | b: [note2]
125 | });
126 | });
127 | xit('editNotes', () => {
128 | component.alterNotes.edit(editNote, 0);
129 | expect(component.state.notes).toEqual([editNote, note1, note2]);
130 |
131 | // does not add if beyond index
132 | component.alterNotes.edit(editNote, 5);
133 | expect(component.state.notes).toEqual([editNote, note1, note2]);
134 | });
135 | });
136 | });
137 |
--------------------------------------------------------------------------------
/src/context/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component, createContext } from 'react';
2 | import nanoid from 'nanoid';
3 |
4 | import { filmish as dummyPlaylist } from '../constants/dummy';
5 |
6 | export const Context = createContext();
7 |
8 | export const insertAtIdx = (list, idx, entry) => [
9 | ...list.slice(0, idx),
10 | entry,
11 | ...list.slice(idx, list.length)
12 | ];
13 |
14 | const replaceAtIdx = (list, idx, entry) => [
15 | ...list.slice(0, idx),
16 | entry,
17 | ...list.slice(idx + 1, list.length)
18 | ];
19 |
20 | export class CourseProvider extends Component {
21 | static defaultProps = {
22 | playlist: dummyPlaylist, // @todo remove
23 | notes: []
24 | };
25 |
26 | constructor(props) {
27 | super(props);
28 |
29 | // @todo, tidyup local storage search
30 |
31 | const { playlist, notes, currentlyPlaying } =
32 | this.getSavedPlaylist(this.props.playlist.id) || {};
33 |
34 | this.state = {
35 | availableData: [], // list of playlists in localStorage
36 | playlist: playlist || this.props.playlist,
37 | notes: notes || this.props.notes,
38 | // bookmarks: [], bookmarks = notes with just a timestamp
39 | currentlyPlaying: currentlyPlaying || {
40 | video: 0, // index in array
41 | position: 0
42 | },
43 | video: null,
44 | savedPlaylists: JSON.parse(
45 | window.localStorage.getItem('savedPlaylists') || '{}'
46 | )
47 | };
48 | }
49 |
50 | componentDidMount() {
51 | // check local storage
52 | }
53 |
54 | getSavedData = () => {};
55 |
56 | // checks if playlist is on local storage and pulls data from there
57 | getSavedPlaylist = playlistId => {
58 | const playlist = window.localStorage.getItem(`playlist__${playlistId}`);
59 | if (!playlist) {
60 | return null;
61 | }
62 | const formatted = JSON.parse(playlist);
63 | // @todo: validate
64 | if (this.state) {
65 | this.setState(formatted);
66 | }
67 | return formatted;
68 | };
69 |
70 | saveData = () => {
71 | window.localStorage.setItem(
72 | [`playlist__${this.state.playlist.id}`],
73 | JSON.stringify({
74 | playlist: this.state.playlist,
75 | notes: this.state.notes,
76 | currentlyPlaying: this.state.currentlyPlaying
77 | })
78 | );
79 | const playlists = JSON.parse(
80 | window.localStorage.getItem('savedPlaylists') || '{}'
81 | );
82 | if (
83 | this.state.playlist.id &&
84 | !Object.keys(playlists).includes(this.state.playlist.id)
85 | ) {
86 | const { items, order, ...playlistInfo } = this.state.playlist;
87 | this.setState(
88 | state => ({
89 | savedPlaylists: {
90 | ...state.savedPlaylists,
91 | [state.playlist.id]: playlistInfo
92 | }
93 | }),
94 | () => {
95 | window.localStorage.setItem(
96 | 'savedPlaylists',
97 | JSON.stringify(this.state.savedPlaylists)
98 | );
99 | }
100 | );
101 | }
102 | };
103 |
104 | setNewPlaylist = playlist => {
105 | this.setState(
106 | {
107 | playlist,
108 | notes: []
109 | },
110 | this.saveData
111 | );
112 | };
113 |
114 | alterFields = key => ({
115 | // @todo tidyup
116 | add: ({ text, time }, vidId) => {
117 | const list = this.state[key] && this.state[key][vidId];
118 | const arrayLength = Array.isArray(list) && list.length;
119 |
120 | if (!arrayLength) {
121 | this.setState(
122 | state => ({
123 | [key]: {
124 | ...state[key],
125 | [vidId]: [{ text, time }]
126 | }
127 | }),
128 | this.saveData
129 | );
130 | return;
131 | }
132 |
133 | const insertionIndex = list.findIndex(entry => entry.time >= time);
134 | this.setState(
135 | state => ({
136 | [key]: {
137 | ...state[key],
138 | [vidId]: insertAtIdx(
139 | state[key][vidId],
140 | insertionIndex !== -1 ? insertionIndex : arrayLength,
141 | {
142 | id: nanoid(),
143 | text,
144 | time
145 | }
146 | )
147 | }
148 | }),
149 | this.saveData
150 | );
151 | },
152 | edit: (note, vidId, idx) =>
153 | idx < this.state[key][vidId].length &&
154 | this.setState(
155 | state => ({
156 | [key]: {
157 | ...state[key],
158 | [vidId]: {
159 | note: replaceAtIdx(state[key][vidId], idx, note)
160 | }
161 | }
162 | }),
163 | this.saveData
164 | ),
165 | delete: (vidId, idx) =>
166 | this.setState(
167 | state => ({
168 | [key]: {
169 | ...state[key],
170 | [vidId]: [
171 | ...state[key][vidId].slice(0, idx),
172 | ...state[key][vidId].slice(idx + 1, state[key].length)
173 | ]
174 | }
175 | }),
176 | this.saveData
177 | )
178 | });
179 |
180 | alterNotes = this.alterFields('notes');
181 |
182 | getSrc = idx => {
183 | const vidId =
184 | Array.isArray(this.state.playlist.order) &&
185 | this.state.playlist.order[idx];
186 | if (vidId || vidId === 0) {
187 | return `https://youtu.be/${vidId}`;
188 | }
189 | return '';
190 | };
191 |
192 | getCurrentlyPlayingId = () =>
193 | this.state.playlist.order[this.state.currentlyPlaying.video];
194 |
195 | // @todo probably quite hacky
196 | setTime = () => {
197 | this.state.video.currentTime(this.state.currentlyPlaying.position);
198 | this.state.video.off('play', this.setTime);
199 | };
200 |
201 | setTrack = (video, position = 0) => {
202 | const source = this.getSrc(video);
203 | if (!source) {
204 | console.error('no track found');
205 | return;
206 | }
207 | this.state.video.src({
208 | type: 'video/youtube',
209 | src: source
210 | });
211 | this.state.video.on('play', this.setTime);
212 | // @todo update thumbnail
213 | this.state.video.play();
214 |
215 | this.setState({ currentlyPlaying: { video, position } }, () => {
216 | this.state.video.poster(
217 | this.state.playlist.items[this.getCurrentlyPlayingId()].posterImage
218 | );
219 | });
220 | };
221 |
222 | // @todo timer before autoplay
223 | nextTrack = () => {
224 | this.setTrack(this.state.currentlyPlaying.video + 1);
225 | };
226 |
227 | setVideo = vid => {
228 | // initialize
229 | vid.on('ended', this.nextTrack);
230 |
231 | this.setState({ video: vid });
232 | };
233 |
234 | render() {
235 | return (
236 |
247 | {this.props.children}
248 |
249 | );
250 | }
251 | }
252 |
253 | export default Context;
254 |
--------------------------------------------------------------------------------
/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | const SECONDS_IN_AN_HOUR = 60 * 60;
2 |
3 | const doubleDigit = val => (val < 10 ? `0${val}` : val);
4 |
5 | export const formatTime = (seconds, videoNo) => {
6 | const hrs = Math.floor(seconds / SECONDS_IN_AN_HOUR) || '';
7 | const mins = Math.floor((seconds % SECONDS_IN_AN_HOUR) / 60);
8 | const secs = Math.ceil(seconds % 60);
9 | return `${typeof videoNo !== 'undefined' ? videoNo : ''}${
10 | hrs ? `${hrs}:` : ''
11 | }${doubleDigit(mins)}:${doubleDigit(secs)}`;
12 | };
13 |
14 | export const getVideoDetails = (searchId, playlist) =>
15 | playlist.find(({ id }) => id === searchId);
16 |
17 | export const ytTimeToSeconds = ytTime =>
18 | ytTime
19 | .split(/[A-Z]+/)
20 | .filter(x => x)
21 | .reverse()
22 | .reduce((acc, val, idx) => acc + val * 60 ** idx, 0);
23 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 | VideoJS experiment
13 |
14 |
15 |
16 |
17 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | const wrapper = document.getElementById('courseBuilder');
6 | wrapper ? ReactDOM.render( , wrapper) : false;
7 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebPackPlugin = require('html-webpack-plugin');
2 | const Dotenv = require('dotenv-webpack');
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
4 |
5 | module.exports = {
6 | module: {
7 | rules: [
8 | {
9 | test: /\.(js|jsx)$/,
10 | exclude: modulePath => /node_modules/.test(modulePath),
11 | use: [
12 | {
13 | loader: 'babel-loader'
14 | },
15 | {
16 | loader: 'linaria/loader',
17 | options: {
18 | sourceMap: process.env.NODE_ENV !== 'production'
19 | }
20 | }
21 | ]
22 | },
23 | {
24 | test: /\.css$/,
25 | use: [
26 | MiniCssExtractPlugin.loader,
27 | {
28 | loader: 'css-loader',
29 | options: {
30 | sourceMap: process.env.NODE_ENV !== 'production'
31 | }
32 | }
33 | ]
34 | },
35 | {
36 | test: /\.(png|jpg|gif|svg)$/i,
37 | use: [
38 | {
39 | loader: 'url-loader',
40 | options: {
41 | limit: 10000
42 | }
43 | }
44 | ]
45 | }
46 | ]
47 | },
48 | plugins: [
49 | new HtmlWebPackPlugin({
50 | template: './src/index.html',
51 | filename: './index.html'
52 | }),
53 | new Dotenv({
54 | path: './.env',
55 | safe: true
56 | }),
57 | new MiniCssExtractPlugin({
58 | filename: 'styles.css'
59 | })
60 | ],
61 | node: {
62 | fs: 'empty'
63 | },
64 | optimization: {
65 | minimize: false
66 | }
67 | };
68 |
--------------------------------------------------------------------------------