Bläddra i källkod

Working search, add, ui and conversion. to add download

Benoit Sida 3 år sedan
förälder
incheckning
d670bbfe6f

+ 2
- 0
.gitignore Visa fil

@@ -1 +1,3 @@
1 1
 node_modules/
2
+uploaded/
3
+packages/

+ 8
- 0
.meteor/packages Visa fil

@@ -21,3 +21,11 @@ shell-server@0.2.2            # Server-side component of the `meteor shell` comm
21 21
 react-meteor-data
22 22
 accounts-ui
23 23
 accounts-password
24
+underscore
25
+fortawesome:fontawesome
26
+numeral:numeral
27
+meteorhacks:npm
28
+
29
+
30
+npm-container
31
+momentjs:moment

+ 6
- 0
.meteor/versions Visa fil

@@ -29,6 +29,7 @@ ejson@1.0.13
29 29
 email@1.1.18
30 30
 es5-shim@4.6.15
31 31
 fastclick@1.0.13
32
+fortawesome:fontawesome@4.7.0
32 33
 geojson-utils@1.0.10
33 34
 hot-code-push@1.0.4
34 35
 html-tools@1.0.11
@@ -43,6 +44,8 @@ localstorage@1.0.12
43 44
 logging@1.1.17
44 45
 meteor@1.6.1
45 46
 meteor-base@1.0.4
47
+meteorhacks:async@1.0.0
48
+meteorhacks:npm@1.5.0
46 49
 minifier-css@1.2.16
47 50
 minifier-js@1.2.17
48 51
 minimongo@1.0.20
@@ -50,10 +53,13 @@ mobile-experience@1.0.4
50 53
 mobile-status-bar@1.0.14
51 54
 modules@0.7.9
52 55
 modules-runtime@0.7.9
56
+momentjs:moment@2.18.1
53 57
 mongo@1.1.15
54 58
 mongo-id@1.0.6
55 59
 npm-bcrypt@0.9.2
60
+npm-container@1.2.0
56 61
 npm-mongo@2.2.16_1
62
+numeral:numeral@1.5.3_1
57 63
 observe-sequence@1.0.15
58 64
 ordered-dict@1.0.9
59 65
 promise@0.8.8

+ 138
- 15
client/main.css Visa fil

@@ -56,39 +56,100 @@ h1 {
56 56
   margin-right: 1em;
57 57
 }
58 58
  
59
-form {
60
-  margin-top: 10px;
61
-  margin-bottom: -10px;
62
-  position: relative;
63
-}
64
- 
65
-.new-link input {
59
+input.new-link {
66 60
   box-sizing: border-box;
67
-  padding: 10px 0;
68 61
   background: transparent;
69 62
   border: none;
70 63
   width: 100%;
71
-  padding-right: 80px;
72
-  font-size: 1em;
64
+  padding: 10px;
65
+  font-size: 1.5em;
66
+  color: rgba(20, 20, 20);
73 67
 }
74 68
  
75
-.new-link input:focus{
69
+input.new-link:focus{
76 70
   outline: 0;
77 71
 }
78
- 
72
+
79 73
 ul {
74
+  padding: 0;
75
+  margin: 0;
76
+}
77
+
78
+.options-all {
79
+  background: #d2edf4;
80
+  background-image: linear-gradient(to top, #d0edf5, #e1e5f0 100%);
81
+  display: flex;
82
+  padding: 20px 15px 15px 15px;
83
+}
84
+
85
+button.option {
86
+  background: white;
87
+  border: none;
88
+  margin: 0 5px;
89
+  padding: 5px 10px;
90
+  border: 1px solid rgba(0, 0, 0, 0.2);
91
+  position: relative;
92
+}
93
+
94
+button.unreachable {
95
+  background: rgba(117, 0, 0, 0.51);
96
+  color: white;
97
+}
98
+
99
+button.option:hover {
100
+  -webkit-box-shadow:  0 1px 5px 0 rgba(0, 0, 0, 0.3);
101
+  -moz-box-shadow:  0 1px 5px 0 rgba(0, 0, 0, 0.3);
102
+  box-shadow:  0 1px 5px 0 rgba(0, 0, 0, 0.3);
103
+  cursor: pointer;
104
+}
105
+
106
+button.unreachable:hover {
107
+  cursor: not-allowed;
108
+}
109
+
110
+button.option .option-progress {
111
+  background: rgba(0, 128, 0, 0.25);
112
+  height: 100%;
113
+  position: absolute;
114
+  top: 0;
115
+  left: 0;
116
+}
117
+
118
+.options-all .label {
119
+  flex: 2;
120
+}
121
+ 
122
+.box-container {
80 123
   margin: 0;
81 124
   padding: 0;
82 125
   background: white;
83 126
 }
127
+
128
+.link {
129
+  display: flex;
130
+  flex-direction: row;
131
+}
132
+
133
+.link .link-text {
134
+  flex: 10;
135
+  -ms-text-overflow: ellipsis;
136
+  text-overflow: ellipsis;
137
+  white-space: nowrap;
138
+  overflow: hidden;
139
+  padding: 8px 10px 0;
140
+}
141
+
142
+.link .link-options {
143
+  flex: 4;
144
+}
84 145
  
85
-.delete {
86
-  float: right;
146
+.link .link-delete {
87 147
   font-weight: bold;
88 148
   background: none;
89 149
   font-size: 1em;
90 150
   border: none;
91
-  position: relative;
151
+  flex: 1;
152
+  cursor: pointer;
92 153
 }
93 154
  
94 155
 li {
@@ -136,4 +197,66 @@ header #accounts-ui {
136 197
   .new-link input {
137 198
     padding-bottom: 5px;
138 199
   }
200
+}
201
+
202
+#search-result {
203
+  margin-top:20px;
204
+  background: none;
205
+}
206
+
207
+#search-result .result-link {
208
+  display: block;
209
+  padding: 10px;
210
+  border-bottom: 1px solid rgba(0,0,0,0.1);
211
+  background: rgba(255,255,255,0.3);
212
+  height: 45px;
213
+}
214
+
215
+#search-result .result-link:hover {
216
+  background: rgba(255,255,255,0.5);
217
+  -webkit-box-shadow:  0 5px 5px -5px black;
218
+  -moz-box-shadow:  0 5px 5px -5px black;
219
+  box-shadow:  0 5px 5px -5px black;
220
+  cursor: pointer;
221
+}
222
+
223
+#search-result .result-thumb {
224
+  float: left;
225
+}
226
+
227
+#search-result .result-info {
228
+  vertical-align: top;
229
+  margin-left: 100px;
230
+  //line-height: 45px;
231
+  display: flex;
232
+  flex-direction: column;
233
+}
234
+
235
+#search-result .result-info .result-head {
236
+  flex-direction: row;
237
+  display: flex;
238
+}
239
+
240
+#search-result .result-info .result-head .result-duration {
241
+  flex: 1;
242
+  padding: 5px 0 10px;
243
+  text-align: right;
244
+}
245
+
246
+#search-result .result-info .result-head .result-title {
247
+  -ms-text-overflow: ellipsis;
248
+  text-overflow: ellipsis;
249
+  white-space: nowrap;
250
+  overflow: hidden;
251
+  padding: 5px 10px 10px 0;
252
+  flex:10;
253
+}
254
+#search-result .result-info .result-stats {
255
+  display: flex;
256
+  flex-direction: row;
257
+  font-size: 0.8em;
258
+}
259
+
260
+#search-result .result-info .result-stats div {
261
+  flex: 1;
139 262
 }

+ 77
- 15
imports/api/links.js Visa fil

@@ -2,32 +2,94 @@ import { Meteor } from 'meteor/meteor';
2 2
 import { Mongo } from 'meteor/mongo';
3 3
 import { check, Match } from 'meteor/check';
4 4
 
5
-export const Links = new Mongo.Collection('links');
5
+export const Converted = new Mongo.Collection('links');
6 6
 
7 7
 const YTExp = /http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-_]*)/;
8 8
 
9 9
 if (Meteor.isServer) {
10
-	// This code only runs on the server
11 10
 	Meteor.publish('links', function linksPublication() {
12
-		return Links.find({
13
-			 completed: {$ne: true}
14
-		});
11
+		return Converted.find();
15 12
 	});
16 13
 }
17 14
 
15
+const convert = (url, filename, id) => {
16
+	if (Meteor.isServer) {
17
+		console.log('Converting ...');
18
+		const fs = Meteor.npmRequire("fs");
19
+		const ytdl = Meteor.npmRequire('ytdl-core');
20
+		const path = Meteor.npmRequire('path');
21
+		const ffmpeg = Meteor.npmRequire('fluent-ffmpeg');
22
+		const filePath = path.resolve('/home/kod3/projects/SoundWave/uploaded/', filename)
23
+		const videoPath = filePath + '.mp4';
24
+		const audioPath = filePath + '.mp3';
25
+		ytdl.getInfo(url, {}, Meteor.bindEnvironment((err, info) => {
26
+			console.info(info ? 'ok' : 'ko');
27
+			if (err) {
28
+				console.log(url);
29
+				console.error(err);
30
+				Converted.update({ _id: id }, { $set: { error: 'Video is unreachable' } });
31
+			} else {
32
+				const totalSeconds = info ? info.length_seconds : 0;
33
+				const dl = ytdl(url);
34
+				dl.pipe(fs.createWriteStream(videoPath));
35
+				ffmpeg(dl)
36
+					.noVideo()
37
+					.on('end', Meteor.bindEnvironment(() => {
38
+						Converted.update({ _id: id }, { $set: { audio: audioPath, audio_progress: 100 } });
39
+					}))
40
+					.on('progress', Meteor.bindEnvironment((params) => {
41
+						const time = moment(new Date(...[0, 0, 0, ...params.timemark.split(':')]));
42
+						const seconds = time.hours() * 3600 + time.minutes() * 60 + time.seconds();
43
+						let percent = ((seconds / totalSeconds) * 100).toFixed(0);
44
+						Converted.update({ _id: id }, { $set: { audio_progress: percent } });
45
+					}))
46
+					.on('error', (err) => { console.error('MP3 encoding error: ' + err.message); })
47
+					.format('mp3')
48
+					.output(fs.createWriteStream(audioPath))
49
+					.run();
50
+				dl.on('info', (info, format) => console.log);
51
+				dl.on('response', Meteor.bindEnvironment((res) => {
52
+					const totalSize = res.headers['content-length'];
53
+					let dataRead = 0;
54
+					res.on('data', Meteor.bindEnvironment((data) => {
55
+						dataRead += data.length;
56
+						let percent = dataRead / totalSize;
57
+						Converted.update({ _id: id }, { $set: { video_progress: (percent * 100).toFixed(0) } });
58
+					}));
59
+					res.on('end', Meteor.bindEnvironment(() => {
60
+						Converted.update({ _id: id }, { $set: { video: videoPath, video_progress: 100 } });
61
+					}));
62
+					res.on('error', (err) => { console.error('Error: ' + err.message) });
63
+				}));
64
+			}
65
+		}));
66
+	} else return false;
67
+};
68
+
18 69
 Meteor.methods({
19 70
 
20
-  'links.insert'(url) {
21
-	check(url, Match.Where((url) => YTExp.test(url)));
22
-    Links.insert({
23
-      url,
24
-      createdAt: new Date(),
25
-    });
26
-  },
71
+  'links.add'({video, type}) {
72
+  	check(video.url, Match.Where(url => YTExp.test(url)));
73
+	check(type, Match.Where(t => ['audio'].includes(t)));
27 74
 
28
-  'links.remove'(linkId) {
29
-    check(linkId, String);
30
-    Links.remove(linkId);
75
+ 	let converted = Converted.findOne({
76
+		url: video.url
77
+	});
78
+
79
+	if (!converted) {
80
+		const filename = video.title.replace(/ /g, '_');
81
+		var inserted_id = Converted.insert({
82
+			id: video.id,
83
+			url: video.url,
84
+			title: video.title,
85
+			video: '',
86
+			video_progress: 0,
87
+			audio: '',
88
+			audio_progress: 0,
89
+			createdAt: new Date()
90
+		});
91
+		convert(video.url, filename, inserted_id);
92
+	}
31 93
   },
32 94
 
33 95
 });

+ 80
- 37
imports/ui/App.jsx Visa fil

@@ -1,36 +1,27 @@
1 1
 import React, { Component, PropTypes } from 'react';
2
-import ReactDOM from 'react-dom';
3 2
 import { Meteor } from 'meteor/meteor';
3
+import { Tracker } from 'meteor/tracker';
4 4
 import { createContainer } from 'meteor/react-meteor-data';
5
-import { Links } from '../api/links.js';
6
-import Link from './Link.jsx';
7
-import AccountsUIWrapper from './AccountsUIWrapper.jsx';
5
+import { Converted } from '../api/links';
6
+import Links from './Links';
7
+import { YouTubeSearch, YouTubeVideoInfo } from './YouTubeAPI';
8
+import Video from './Video';
9
+import Input from "./Input";
10
+import Results from "./Results";
11
+import OptionsAll from "./OptionsAll";
8 12
 
9
-// App component - represents the whole app
10 13
 class App extends Component {
11 14
 	constructor(props) {
12 15
 		super(props);
13
-		this.state = { };
14
-	}
15
-
16
-	renderLinks() {
17
-		return this.props.links.map((link) => {
18
-			return (
19
-				<Link key={link._id} link={link} />
20
-			)
21
-		});
22
-	}
23
-
24
-	handleSubmit(event) {
25
-		event.preventDefault();
26
-
27
-		// Find the text field via the React ref
28
-		const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();
29
-
30
-		Meteor.call('links.insert', text);
31
-
32
-		// Clear form
33
-		ReactDOM.findDOMNode(this.refs.textInput).value = '';
16
+		this.state = { result: [], links: [], value: '' };
17
+		this.options = [
18
+			{ type: 'MP3', handler: this.downloadAudio.bind(this) },
19
+			{ type: 'MP4', handler: this.downloadVideo.bind(this) },
20
+		];
21
+		this.optionsAll = [
22
+			{ type: 'MP3', handler: this.downloadAllAudio.bind(this) },
23
+			{ type: 'MP4', handler: this.downloadAllVideo.bind(this) },
24
+		];
34 25
 	}
35 26
 
36 27
 	render() {
@@ -39,28 +30,80 @@ class App extends Component {
39 30
 				<h1 className="logo">SoundWave</h1>
40 31
 				<div className="box-container">
41 32
 					<header>
42
-						<AccountsUIWrapper />
43
-						<form className="new-link" onSubmit={this.handleSubmit.bind(this)}>
44
-							<input type="text" ref="textInput" placeholder="Search a video" />
45
-						</form>
33
+						<Input value={this.state.value} change={this.inputChange.bind(this)} />
34
+						<Results results={this.state.result} select={this.selectResult.bind(this)} />
46 35
 					</header>
47
-					<ul>{ this.renderLinks() }</ul>
36
+					<Links links={this.state.links} remove={this.removeLink.bind(this)} handlers={this.options}/>
37
+					<OptionsAll count={this.state.links.length} handlers={this.optionsAll}
38
+								links={this.state.links} converted={this.props.converted} />
48 39
 				</div>
49 40
 			</div>
50 41
 		);
51 42
 	}
43
+
44
+	downloadAudio(video) {
45
+		console.log('Requesting mp3 for ' + video.id);
46
+	}
47
+
48
+	downloadVideo(video) {
49
+		console.log('Requesting mp4 for ' + video.id);
50
+	}
51
+
52
+	downloadAllAudio() {
53
+		console.log('Requesting mp3 for all');
54
+	}
55
+
56
+	downloadAllVideo() {
57
+		console.log('Requesting mp4 for all');
58
+	}
59
+
60
+	inputChange(text) {
61
+		this.setState({ value: text }, this.search);
62
+	}
63
+
64
+	removeLink(link) {
65
+		if (_.some(this.state.links, e => e.id === link.id))
66
+			this.setState({ links: _.reject(this.state.links, e => e.id === link.id) });
67
+	}
68
+
69
+	selectResult(video) {
70
+		if (_.every(this.state.links, e => e.id !== video.id)) {
71
+			if (_.some(this.props.converted, e => e.id === video.id)) {
72
+				video.converted = _.find(this.props.converted, e => e.id === video.id)._id;
73
+			} else
74
+				Meteor.call('links.add', {video, type: 'audio'}, (err, res) => {
75
+					if (err)
76
+						console.error(err);
77
+					else
78
+						video.converted = _.find(this.props.converted, e => e.id === video.id)._id;
79
+				});
80
+			this.setState({links: [...this.state.links, video]});
81
+		}
82
+		this.setState({ value: '', result: [] });
83
+	}
84
+
85
+	search() {
86
+		if (!this.state.value) {
87
+			this.setState({ result: [] });
88
+		} else {
89
+			YouTubeSearch(this.state.value)
90
+				.then(e => e.items.map(e => new Video(e)))
91
+				.then(e => { this.setState({ result: e }); return e; })
92
+				.then(e => e.forEach(i => YouTubeVideoInfo(i.id)
93
+					.then(r => i.update(r.items[0].statistics, r.items[0].contentDetails.duration))
94
+					.then(() => this.setState({ result: e }))
95
+				));
96
+		}
97
+	}
52 98
 }
53 99
 
54 100
 App.propTypes = {
55
-	links: PropTypes.array.isRequired,
56
-	currentUser: PropTypes.object,
101
+	converted: PropTypes.array.isRequired,
57 102
 };
58 103
 
59 104
 export default createContainer(() => {
60 105
 	Meteor.subscribe('links');
61
-
62 106
 	return {
63
-		links: Links.find({}, {sort: {createdAt: -1}}).fetch(),
64
-		currentUser: Meteor.user(),
107
+		converted: Converted.find({}).fetch(),
65 108
 	};
66
-}, App);
109
+}, App)

+ 19
- 0
imports/ui/Input.jsx Visa fil

@@ -0,0 +1,19 @@
1
+import React, {Component, PropTypes} from 'react';
2
+
3
+export default class Input extends Component {
4
+	handleChange(e) {
5
+		this.props.change(e.target.value);
6
+	}
7
+
8
+	render() {
9
+		return (
10
+			<input type="text" ref="textInput" placeholder="Search a video" autoFocus
11
+				   value={this.props.value} className="new-link" onChange={this.handleChange.bind(this)} />
12
+		);
13
+	}
14
+}
15
+
16
+Input.propTypes = {
17
+	change: PropTypes.func.isRequired,
18
+	value: PropTypes.string.isRequired
19
+};

+ 22
- 22
imports/ui/Link.jsx Visa fil

@@ -1,33 +1,33 @@
1 1
 import React, {Component, PropTypes} from 'react';
2
-import classnames from 'classnames';
3
-
4
-// Task component - represents a single todo item
5
-export default class Link extends Component {
6
-
7
-	deleteThisLink() {
8
-		Meteor.call('links.remove', this.props.link._id);
9
-	}
2
+import { createContainer } from 'meteor/react-meteor-data';
3
+import { Converted } from '../api/links';
4
+import Options from './Options';
5
+import Video from './Video';
10 6
 
7
+class Link extends Component {
11 8
 	render() {
12
-		// Give tasks a different className when they are checked off,
13
-		// so that we can style them nicely in CSS
14
-		const linkClassName = classnames({
15
-			completed: this.props.link.completed,
16
-		});
17
-
18 9
 		return (
19
-			<li className={linkClassName}>
20
-				<button className="delete" onClick={this.deleteThisLink.bind(this)}>
21
-					&times;
22
-				</button>
23
-				<span className="text">{this.props.link.url}</span>
10
+			<li className="link">
11
+				<span className="link-text" title={this.props.link.title}>{this.props.link.title}</span>
12
+				<Options className="link-options" handlers={this.props.options}
13
+						 video={this.props.link} converted={this.props.converted}/>
14
+				<button className="link-delete" onClick={this.props.remove}>&times;</button>
24 15
 			</li>
25 16
 		);
26 17
 	}
27 18
 }
28 19
 
29 20
 Link.propTypes = {
30
-	// This component gets the task to display through a React prop.
31
-	// We can use propTypes to indicate it is required
32
-	link: PropTypes.object.isRequired,
21
+	link: PropTypes.instanceOf(Video).isRequired,
22
+	remove: PropTypes.func.isRequired,
23
+	options: PropTypes.arrayOf(PropTypes.shape({
24
+		type: PropTypes.string,
25
+		handler: PropTypes.func
26
+	})).isRequired,
27
+	converted: PropTypes.object
33 28
 };
29
+
30
+export default createContainer(({link, converted_id, remove, options}) => {
31
+	const converted = Converted.findOne(converted_id);
32
+	return { link, remove, options, converted }
33
+}, Link);

+ 27
- 0
imports/ui/Links.jsx Visa fil

@@ -0,0 +1,27 @@
1
+import React, {Component, PropTypes} from 'react';
2
+import { createContainer } from 'meteor/react-meteor-data';
3
+import Link from './Link';
4
+import Video from './Video';
5
+
6
+export default class Links extends Component {
7
+	render() {
8
+		return (
9
+			<ul>
10
+				{ this.props.links.map(link => (
11
+					<Link key={link.id} converted_id={link.converted}
12
+						  link={link} options={this.props.handlers}
13
+						  remove={() => this.props.remove(link)}/>
14
+				)) }
15
+			</ul>
16
+		);
17
+	}
18
+}
19
+
20
+Links.propTypes = {
21
+	links: PropTypes.arrayOf(PropTypes.instanceOf(Video)).isRequired,
22
+	remove: PropTypes.func.isRequired,
23
+	handlers: PropTypes.arrayOf(PropTypes.shape({
24
+		type: PropTypes.string,
25
+		handler: PropTypes.func
26
+	})).isRequired
27
+};

+ 51
- 0
imports/ui/Options.jsx Visa fil

@@ -0,0 +1,51 @@
1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+import classnames from 'classnames';
4
+
5
+export default class Options extends Component {
6
+
7
+	getProgress(type) {
8
+		return !this.props.converted ? 0 :
9
+			this.props.converted[type === 'MP3' ? 'audio_progress' : 'video_progress'];
10
+	}
11
+
12
+	isDisabled(type) {
13
+		return !this.props.converted ||
14
+			!this.props.converted[type === 'MP3' ? 'audio' : 'video'];
15
+	}
16
+
17
+	getClasses() {
18
+		return classnames({
19
+			option: true,
20
+			unreachable: this.props.converted.error
21
+		});
22
+	}
23
+
24
+	getErrorMessage() {
25
+		return this.props.converted.error || null;
26
+	}
27
+
28
+	render() {
29
+		return (
30
+			<div>
31
+				{ _.map(this.props.handlers, (h) => (
32
+					<button key={h.type} className={this.getClasses()}
33
+							onClick={() => h.handler(this.props.video)}
34
+							disabled={this.isDisabled(h.type)} title={this.getErrorMessage()}>
35
+						<div className="option-progress" style={{width: this.getProgress(h.type) + '%'}}></div>
36
+						<i className="fa fa-download"/> {h.type}
37
+					</button>
38
+				)) }
39
+			</div>
40
+		);
41
+	}
42
+}
43
+
44
+Options.propTypes = {
45
+	handlers: PropTypes.arrayOf(PropTypes.shape({
46
+		type: PropTypes.string,
47
+		handler: PropTypes.func
48
+	})),
49
+	video: PropTypes.instanceOf(Video),
50
+	converted: PropTypes.object
51
+};

+ 43
- 0
imports/ui/OptionsAll.jsx Visa fil

@@ -0,0 +1,43 @@
1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+
4
+export default class OptionsAll extends Component {
5
+
6
+	renderCount() {
7
+		return (this.props.count || 0) + ' link' + (this.props.count > 1 ? 's' : '' );
8
+	}
9
+
10
+	isDisabled(type) {
11
+		const prop = (type === 'MP3' ? 'audio' : 'video');
12
+		return !_.every(this.props.links, (e) => {
13
+			const converted = _.find(this.props.converted, i => e.id === i.id);
14
+			return converted ? converted[prop] : true;
15
+		});
16
+	}
17
+
18
+	render() {
19
+		if (!this.props.count) return null;
20
+		return (
21
+			<div className="options-all">
22
+				<div className="label">ALL: {this.renderCount()}</div>
23
+				<div>
24
+					{ _.map(this.props.handlers, (h) => (
25
+						<button key={h.type} className="option" onClick={h.handler} disabled={this.isDisabled(h.type)}>
26
+							<i className="fa fa-download"/> {h.type}
27
+						</button>
28
+					)) }
29
+				</div>
30
+			</div>
31
+		);
32
+	}
33
+}
34
+
35
+OptionsAll.propTypes = {
36
+	count: PropTypes.number.isRequired,
37
+	links: PropTypes.arrayOf(PropTypes.instanceOf(Video)),
38
+	converted: PropTypes.arrayOf(PropTypes.object),
39
+	handlers: PropTypes.arrayOf(PropTypes.shape({
40
+		type: PropTypes.string,
41
+		handler: PropTypes.func
42
+	})).isRequired,
43
+};

+ 42
- 0
imports/ui/Result.jsx Visa fil

@@ -0,0 +1,42 @@
1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+
4
+export default class Result extends Component {
5
+	render() {
6
+		return (
7
+			<a className="result-link" onClick={this.props.click}>
8
+				<img src={this.props.result.thumb.url} alt="thumbnail" className="result-thumb"
9
+					 height={this.props.result.thumb.height / 2}
10
+					 width={this.props.result.thumb.width / 2}/>
11
+				<div className="result-info">
12
+					<div className="result-head" title={this.props.result.title}>
13
+						<div className="result-title">{this.props.result.title}</div>
14
+						<div className="result-duration">{this.props.result.duration}</div>
15
+					</div>
16
+					<div className="result-stats">
17
+						<div className="result-views" title={this.props.result.stats.viewCount}>
18
+							<i className="fa fa-eye"/> {this.props.result.stats.viewCount}
19
+						</div>
20
+						<div className="result-likes" title={this.props.result.stats.likeCount}>
21
+							<i className="fa fa-thumbs-o-up"/> {this.props.result.stats.likeCount}
22
+						</div>
23
+						<div className="result-dislikes" title={this.props.result.stats.dislikeCount}>
24
+							<i className="fa fa-thumbs-o-down"/> {this.props.result.stats.dislikeCount}
25
+						</div>
26
+						<div className="result-comments" title={this.props.result.stats.commentCount}>
27
+							<i className="fa fa-comments-o"/> {this.props.result.stats.commentCount}
28
+						</div>
29
+						<div className="result-favorites" title={this.props.result.stats.favoriteCount}>
30
+							<i className="fa fa-star-o"/> {this.props.result.stats.favoriteCount}
31
+						</div>
32
+					</div>
33
+				</div>
34
+			</a>
35
+		);
36
+	}
37
+}
38
+
39
+Result.propTypes = {
40
+	result: PropTypes.instanceOf(Video).isRequired,
41
+	click: PropTypes.func
42
+};

+ 21
- 0
imports/ui/Results.jsx Visa fil

@@ -0,0 +1,21 @@
1
+import React, {Component, PropTypes} from 'react';
2
+import Video from './Video';
3
+import Result from './Result';
4
+
5
+export default class Results extends Component {
6
+	render() {
7
+		if (!this.props.results.length) return null;
8
+		return (
9
+			<ul id="search-result">
10
+				{ this.props.results.map(res => (
11
+					<Result key={res.id} result={res} click={() => this.props.select(res) }/>
12
+				)) }
13
+			</ul>
14
+		);
15
+	}
16
+}
17
+
18
+Results.propTypes = {
19
+	results: PropTypes.arrayOf(PropTypes.instanceOf(Video)).isRequired,
20
+	select: PropTypes.func.isRequired
21
+};

+ 25
- 0
imports/ui/Video.js Visa fil

@@ -0,0 +1,25 @@
1
+export default class Video {
2
+	constructor(result) {
3
+		this.id = result.id.videoId;
4
+		this.title = result.snippet.title;
5
+		this.thumb = result.snippet.thumbnails.default;
6
+		this.url = `https://www.youtube.com/watch?v=${this.id}`;
7
+		this.stats = {
8
+			viewCount: 0,
9
+			likeCount: 0,
10
+			dislikeCount: 0,
11
+			commentCount: 0,
12
+			favoriteCount: 0,
13
+		};
14
+		this.duration = 0;
15
+		this.converted = {};
16
+	}
17
+
18
+	update(stats, duration) {
19
+		for (count in stats)
20
+			this.stats[count] = numeral(stats[count]).format('0.[00]a');
21
+		console.log(duration);
22
+		const time = moment.utc(moment.duration(duration).asMilliseconds())
23
+		this.duration = time.format(time.hours() ? 'k:mm:ss' : 'm:ss');
24
+	}
25
+}

+ 42
- 0
imports/ui/YouTubeAPI.js Visa fil

@@ -0,0 +1,42 @@
1
+import 'whatwg-fetch';
2
+import querystring from 'querystring';
3
+
4
+const KEY = 'AIzaSyDh641dLzxYexCASA3TdYMlc6zzS793ppQ';
5
+const ENDPOINT = 'https://www.googleapis.com/youtube/v3';
6
+
7
+const checkStatus = (response) => {
8
+	if (response.status >= 200 && response.status < 300) {
9
+		return response
10
+	} else {
11
+		let error = new Error(response.statusText);
12
+		error.response = response;
13
+		throw error
14
+	}
15
+};
16
+
17
+const parseJson = response => response.json();
18
+
19
+export function YouTubeSearch(query) {
20
+	const params = {
21
+		part: 'snippet',
22
+		type: 'video',
23
+		key: KEY,
24
+		q: query
25
+	};
26
+
27
+	return fetch(`${ENDPOINT}/search?${querystring.stringify(params)}`)
28
+		.then(checkStatus)
29
+		.then(parseJson);
30
+}
31
+
32
+export function YouTubeVideoInfo(id) {
33
+	const params = {
34
+		part: 'statistics,contentDetails',
35
+		key: KEY,
36
+		id: id
37
+	};
38
+
39
+	return fetch(`${ENDPOINT}/videos?${querystring.stringify(params)}`)
40
+		.then(checkStatus)
41
+		.then(parseJson);
42
+}

+ 8
- 1
package.json Visa fil

@@ -8,9 +8,16 @@
8 8
     "babel-runtime": "^6.20.0",
9 9
     "bcrypt": "^1.0.2",
10 10
     "classnames": "^2.2.5",
11
+    "fetch": "^1.1.0",
12
+    "gapi": "0.0.3",
11 13
     "meteor-node-stubs": "~0.2.4",
14
+    "querystring": "^0.2.0",
12 15
     "react": "^15.4.2",
13 16
     "react-addons-pure-render-mixin": "^15.4.2",
14
-    "react-dom": "^15.4.2"
17
+    "react-dom": "^15.4.2",
18
+    "youtube-dl": "^1.11.1"
19
+  },
20
+  "devDependencies": {
21
+    "babel-plugin-transform-class-properties": "^6.23.0"
15 22
   }
16 23
 }

+ 4
- 0
packages.json Visa fil

@@ -0,0 +1,4 @@
1
+{
2
+  "ytdl-core":"0.7.9",
3
+  "fluent-ffmpeg":"2.1.0"
4
+}