Browse Source

Merge pull request #23 from Musare/staging

Some bugfixes.
Vos 8 năm trước cách đây
mục cha
commit
9000f6b469

+ 4 - 0
backend/config/template.json

@@ -32,5 +32,9 @@
 	},
   	"mongo": {
 	  	"url": "mongodb://mongo:27017/musare"
+	},
+  	"cookie": {
+	  	"domain": "",
+	  	"secure": false
 	}
 }

+ 2 - 1
backend/index.js

@@ -15,7 +15,8 @@ const notifications = require('./logic/notifications');
 const config = require('config');
 
 process.on('uncaughtException', err => {
-	console.log(`ERROR: ${err}`);
+	//console.log(`ERROR: ${err.message}`);
+	console.log(`ERROR: ${err.stack}`);
 });
 
 async.waterfall([

+ 5 - 1
backend/logic/actions/apis.js

@@ -48,6 +48,10 @@ module.exports = {
 			utils.socketJoinRoom(session.socketId, `admin.${page}`);
 		}
 		cb({});
-	})
+	}),
+
+	ping: (session, cb) => {
+		cb({date: Date.now()});
+	}
 
 };

+ 5 - 1
backend/logic/actions/reports.js

@@ -113,10 +113,14 @@ module.exports = {
 			},
 			
 			(next) => {
+				let issues = [];
+
 				for (let r = 0; r < data.issues.length; r++) {
-					if (data.issues[r].reasons.length === 0) data.issues.splice(r, 1);
+					if (!data.issues[r].reasons.length <= 0) issues.push(data.issues[r]);
 				}
 
+				data.issues = issues;
+
 				next();
 			},
 

+ 13 - 6
backend/logic/actions/songs.js

@@ -195,12 +195,19 @@ module.exports = {
 
 	getOwnSongRatings: hooks.loginRequired(function(session, songId, cb, userId) {
 		db.models.user.findOne({_id: userId}, (err, user) => {
-			return cb({
-				status: 'success',
-				songId: songId,
-				liked: (user.liked.indexOf(songId) !== -1),
-				disliked: (user.disliked.indexOf(songId) !== -1)
-			});
+			if (!err && user) {
+				return cb({
+					status: 'success',
+					songId: songId,
+					liked: (user.liked.indexOf(songId) !== -1),
+					disliked: (user.disliked.indexOf(songId) !== -1)
+				});
+			} else {
+				return cb({
+					status: 'failure',
+					message: 'You are not logged in.'
+				});
+			}
 		});
 	})
 

+ 6 - 2
backend/logic/app.js

@@ -77,7 +77,9 @@ const lib = {
 									let sessionId = utils.guid();
 									cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), err => {
 										if (err) return redirectOnErr(res, err.message);
-										res.cookie('SID', sessionId);
+										let date = new Date();
+										date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
+										res.cookie('SID', sessionId, {expires: date, secure: config.get("cookie.secure"), path: "/", domain: config.get("cookie.domain")});
 										res.redirect(`${config.get('domain')}/`);
 									});
 								});
@@ -115,7 +117,9 @@ const lib = {
 												let sessionId = utils.guid();
 												cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), err => {
 													if (err) return redirectOnErr(res, err.message);
-													res.cookie('SID', sessionId);
+													let date = new Date();
+													date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
+													res.cookie('SID', sessionId, {expires: date, secure: config.get("cookie.secure"), path: "/", domain: config.get("cookie.domain")});
 													res.redirect(`${config.get('domain')}/`);
 												});
 											});

+ 5 - 2
backend/logic/stations.js

@@ -72,6 +72,7 @@ module.exports = {
 						if (station.currentSong) {
 							let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
 							if (isNaN(timeLeft)) timeLeft = -1;
+							timeLeft = Math.floor(timeLeft);
 							if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
 								this.skipStation(station._id)((err, station) => {
 									cb(err, station);
@@ -427,8 +428,10 @@ module.exports = {
 										cache.hget('sessions', session.sessionId, (err, session) => {
 											if (!err && session) {
 												db.models.user.findOne({_id: session.userId}, (err, user) => {
-													if (user.role === 'admin') socket.emit("event:station.nextSong", station._id, station.currentSong);
-													else if (station.type === "community" && station.owner === session.userId) socket.emit("event:station.nextSong", station._id, station.currentSong);
+													if (!err && user) {
+														if (user.role === 'admin') socket.emit("event:station.nextSong", station._id, station.currentSong);
+														else if (station.type === "community" && station.owner === session.userId) socket.emit("event:station.nextSong", station._id, station.currentSong);
+													}
 												});
 											}
 										});

+ 30 - 0
docker-compose-production.yml

@@ -0,0 +1,30 @@
+version: '2'
+services:
+  backend:
+    build: ./backend
+    ports:
+    - "8081:8081"
+    volumes:
+    - ./backend:/opt/app
+    links:
+    - mongo
+    - redis
+    environment:
+    - NGINX_PORT=81
+  frontend:
+    build: ./frontend
+    ports:
+    - "81:81"
+    volumes:
+    - ./frontend:/opt/app
+  mongo:
+    image: mongo
+    ports:
+    - "27017:27017"
+  mongoclient:
+    image: mongoclient/mongoclient
+    ports:
+    - "3000:3000"
+  redis:
+    image: redis
+    command: "--notify-keyspace-events Ex"

+ 2 - 0
docker-compose.yml

@@ -9,6 +9,8 @@ services:
     links:
     - mongo
     - redis
+    environment:
+    - NGINX_PORT=80
   frontend:
     build: ./frontend
     ports:

+ 67 - 63
frontend/App.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<h1 v-if="!socketConnected" class="socketNotConnected">Could not connect to the server.</h1>
+		<h1 v-if="!socketConnected" class="alert">Could not connect to the server.</h1>
 		<router-view></router-view>
 		<toast></toast>
 		<what-is-new></what-is-new>
@@ -91,10 +91,12 @@
 						Toast.methods.addToast(`You have successfully registered.`, 4000);
 						setTimeout(() => {
 							if (result.SID) {
-								let date = new Date();
-								date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-								document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; path=/`;
-								location.reload();
+								lofig.get('cookie', cookie => {
+									let date = new Date();
+									date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
+									document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; secure=${cookie.secure}; path=/`;
+									location.reload();
+								});
 							} else _this.$router.go('/login');
 						}, 4000);
 					} else Toast.methods.addToast(result.message, 8000);
@@ -105,12 +107,14 @@
 				let _this = this;
 				this.socket.emit('users.login', email, password, result => {
 					if (result.status === 'success') {
-						let date = new Date();
-						date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-						document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; path=/`;
-						Toast.methods.addToast(`You have been successfully logged in`, 2000);
-						_this.$router.go('/');
-						location.reload();
+						lofig.get('cookie', cookie => {
+							let date = new Date();
+							date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
+							document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; secure=${cookie.secure}; path=/`;
+							Toast.methods.addToast(`You have been successfully logged in`, 2000);
+							_this.$router.go('/');
+							location.reload();
+						});
 					} else Toast.methods.addToast(result.message, 2000);
 				});
 			},
@@ -150,7 +154,7 @@
 		left: 0;
 	}
 
-	.socketNotConnected {
+	.alert {
 		padding: 20px;
 		color: white;
 		background-color: red;
@@ -164,72 +168,72 @@
 
 	.tooltip {
 		position: relative;
-		
-		&:after {
-			position: absolute;
-			min-width: 80px;
-			margin-left: -75%;
-			text-align: center;
-			padding: 7.5px 6px;
-			border-radius: 2px;
-			background-color: #323232;
-			font-size: .9em;
-			color: #fff;
-			content: attr(data-tooltip);
-			opacity: 0;
-			transition: all .2s ease-in-out .1s;
-			visibility: hidden;
-		}
-		
-		&:hover:after {
-			opacity: 1;
-			visibility: visible;
-		}
+
+	&:after {
+		 position: absolute;
+		 min-width: 80px;
+		 margin-left: -75%;
+		 text-align: center;
+		 padding: 7.5px 6px;
+		 border-radius: 2px;
+		 background-color: #323232;
+		 font-size: .9em;
+		 color: #fff;
+		 content: attr(data-tooltip);
+		 opacity: 0;
+		 transition: all .2s ease-in-out .1s;
+		 visibility: hidden;
+	 }
+
+	&:hover:after {
+		 opacity: 1;
+		 visibility: visible;
+	 }
 	}
 
 	.tooltip-top {
-		&:after {
-			bottom: 150%;
-		}
+	&:after {
+		 bottom: 150%;
+	 }
 
-		&:hover {
-			&:after { bottom: 120%; }
-		}
+	&:hover {
+	&:after { bottom: 120%; }
+	}
 	}
 
 
 	.tooltip-bottom {
-		&:after {
-			top: 155%;
-		}
+	&:after {
+		 top: 155%;
+	 }
 
-		&:hover {
-			&:after { top: 125%; }
-		}
+	&:hover {
+	&:after { top: 125%; }
+	}
 	}
 
 	.tooltip-left {
-		&:after {
-			bottom: -10px;
-			right: 130%;
-			min-width: 100px;
-		}
-
-		&:hover {
-			&:after { right: 110%; }
-		}
+	&:after {
+		 bottom: -10px;
+		 right: 130%;
+		 min-width: 100px;
+	 }
+
+	&:hover {
+	&:after { right: 110%; }
+	}
 	}
 
 	.tooltip-right {
-		&:after {
-			bottom: -10px;
-			left: 190%;
-			min-width: 100px;
-		}
-
-		&:hover {
-			&:after { left: 200%; }
-		}
+	&:after {
+		 bottom: -10px;
+		 left: 190%;
+		 min-width: 100px;
+	 }
+
+	&:hover {
+	&:after { left: 200%; }
+	}
 	}
 
 	.button:focus, .button:active { border-color: #dbdbdb !important; }

+ 5 - 4
frontend/build/config/template.json

@@ -1,9 +1,10 @@
 {
-	"socket": {
-		"url": ""
-	},
 	"recaptcha": {
 		"key": ""
 	},
-  	"serverDomain": ""
+  	"serverDomain": "",
+  	"cookie": {
+		"domain": "",
+		"secure": false
+	}
 }

+ 94 - 60
frontend/components/Admin/QueueSongs.vue

@@ -1,39 +1,37 @@
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>Thumbnail</td>
-						<td>Title</td>
-						<td>YouTube ID</td>
-						<td>Artists</td>
-						<td>Genres</td>
-						<td>Requested By</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, song) in songs' track-by='$index'>
-						<td>
-							<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-						</td>
-						<td>
-							<strong>{{ song.title }}</strong>
-						</td>
-						<td>{{ song._id }}</td>
-						<td>{{ song.artists.join(', ') }}</td>
-						<td>{{ song.genres.join(', ') }}</td>
-						<td>{{ song.requestedBy }}</td>
-						<td>
-							<a class='button is-primary' href='#' @click='edit(song, index)'>Edit</a>
-							<a class='button is-success' href='#' @click='add(song)'>Add</a>
-							<a class='button is-danger' href='#' @click='remove(song._id, index)'>Remove</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Thumbnail</td>
+					<td>Title</td>
+					<td>YouTube ID</td>
+					<td>Artists</td>
+					<td>Genres</td>
+					<td>Requested By</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, song) in songs' track-by='$index'>
+					<td>
+						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
+					</td>
+					<td>
+						<strong>{{ song.title }}</strong>
+					</td>
+					<td>{{ song._id }}</td>
+					<td>{{ song.artists.join(', ') }}</td>
+					<td>{{ song.genres.join(', ') }}</td>
+					<td>{{ song.requestedBy }}</td>
+					<td>
+						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
+						<button class='button is-success' @click='add(song)'>Add</button>
+						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
 	<edit-song v-show='isEditActive'></edit-song>
 </template>
@@ -57,29 +55,32 @@
 				video: {
 					player: null,
 					paused: false,
-					settings: function (type) {
-						switch(type) {
-							case 'stop':
-								this.player.stopVideo();
-								this.paused = true;
-								break;
-							case 'pause':
-								this.player.pauseVideo();
-								this.paused = true;
-								break;
-							case 'play':
-								this.player.playVideo();
-								this.paused = false;
-								break;
-							case 'skipToLast10Secs':
-								this.player.seekTo(this.player.getDuration() - 10);
-								break;
-						}
-					}
-				}
+					playerReady: false
+				},
+				timeout: 0
 			}
 		},
 		methods: {
+			settings: function (type) {
+				let _this = this;
+				switch(type) {
+					case 'stop':
+						_this.video.player.stopVideo();
+						_this.video.paused = true;
+						break;
+					case 'pause':
+						_this.video.player.pauseVideo();
+						_this.video.paused = true;
+						break;
+					case 'play':
+						_this.video.player.playVideo();
+						_this.video.paused = false;
+						break;
+					case 'skipToLast10Secs':
+						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
+						break;
+				}
+			},
 			changeVolume: function() {
 				let local = this;
 				let volume = $("#volumeSlider").val();
@@ -89,7 +90,7 @@
 			},
 			toggleModal: function () {
 				this.isEditActive = !this.isEditActive;
-				this.video.settings('stop');
+				this.settings('stop');
 			},
 			addTag: function (type) {
 				if (type == 'genres') {
@@ -114,7 +115,7 @@
 			},
 			edit: function (song, index) {
 				if (this.video.player) {
-					this.video.player.loadVideoById(song._id);
+					this.video.player.loadVideoById(song._id, this.editing.song.skipDuration);
 					let songCopy = {};
 					for (let n in song) {
 						songCopy[n] = song[n];
@@ -123,11 +124,22 @@
 					this.isEditActive = true;
 				}
 			},
-			save: function (song) {
+			save: function (song, close) {
 				let _this = this;
 				this.socket.emit('queueSongs.update', song._id, song, function (res) {
-					if (res.status == 'success' || res.status == 'error') Toast.methods.addToast(res.message, 2000);
-					_this.toggleModal();
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						_this.songs.forEach((lSong) => {
+							if (song._id === lSong._id) {
+								for (let n in song) {
+									lSong[n] = song[n];
+								}
+							}
+						});
+					}
+					if (close) {
+						_this.toggleModal();
+					}
 				});
 			},
 			add: function (song) {
@@ -168,26 +180,48 @@
 				});
 			});
 
+			setInterval(() => {
+				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
+					_this.video.paused = false;
+					_this.video.player.stopVideo();
+				}
+			}, 200);
+
 			this.video.player = new YT.Player('player', {
 				height: 315,
 				width: 560,
 				videoId: this.editing.song._id,
 				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
+				startSeconds: _this.editing.song.skipDuration,
 				events: {
 					'onReady': () => {
 						let volume = parseInt(localStorage.getItem("volume"));
 						volume = (typeof volume === "number") ? volume : 20;
+						_this.video.player.seekTo(_this.editing.song.skipDuration);
 						_this.video.player.setVolume(volume);
 						if (volume > 0) _this.video.player.unMute();
+						_this.playerReady = true;
 					},
 					'onStateChange': event => {
 						if (event.data === 1) {
+							_this.video.paused = false;
 							let youtubeDuration = _this.video.player.getDuration();
 							youtubeDuration -= _this.editing.song.skipDuration;
 							if (_this.editing.song.duration > youtubeDuration) {
 								this.video.player.stopVideo();
+								_this.video.paused = true;
 								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
+							} else if (_this.editing.song.duration <= 0) {
+								this.video.player.stopVideo();
+								_this.video.paused = true;
+								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
+							}
+
+							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
+								_this.video.player.seekTo(10);
 							}
+						} else if (event.data === 2) {
+							this.video.paused = true;
 						}
 					}
 				}

+ 32 - 34
frontend/components/Admin/Reports.vue

@@ -1,38 +1,36 @@
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>Song ID</td>
-						<td>Created By</td>
-						<td>Created At</td>
-						<td>Description</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, report) in reports' track-by='$index'>
-						<td>
-							<span>{{ report.songId }}</span>
-						</td>
-						<td>
-							<span>{{ report.createdBy }}</span>
-						</td>
-						<td>
-							<span>{{ report.createdAt }}</span>
-						</td>
-						<td>
-							<span>{{ report.description }}</span>
-						</td>
-						<td>
-							<a class='button is-warning' href='#' @click='toggleModal(report)'>Issues</a>
-							<a class='button is-primary' href='#' @click='resolve(report._id)'>Resolve</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Song ID</td>
+					<td>Created By</td>
+					<td>Created At</td>
+					<td>Description</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, report) in reports' track-by='$index'>
+					<td>
+						<span>{{ report.songId }}</span>
+					</td>
+					<td>
+						<span>{{ report.createdBy }}</span>
+					</td>
+					<td>
+						<span>{{ report.createdAt }}</span>
+					</td>
+					<td>
+						<span>{{ report.description }}</span>
+					</td>
+					<td>
+						<a class='button is-warning' href='#' @click='toggleModal(report)'>Issues</a>
+						<a class='button is-primary' href='#' @click='resolve(report._id)'>Resolve</a>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
 
 	<issues-modal v-if='isModalActive'></issues-modal>

+ 92 - 59
frontend/components/Admin/Songs.vue

@@ -1,38 +1,36 @@
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>Thumbnail</td>
-						<td>Title</td>
-						<td>YouTube ID</td>
-						<td>Artists</td>
-						<td>Genres</td>
-						<td>Requested By</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, song) in songs' track-by='$index'>
-						<td>
-							<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-						</td>
-						<td>
-							<strong>{{ song.title }}</strong>
-						</td>
-						<td>{{ song._id }}</td>
-						<td>{{ song.artists.join(', ') }}</td>
-						<td>{{ song.genres.join(', ') }}</td>
-						<td>{{ song.requestedBy }}</td>
-						<td>
-							<a class='button is-primary' href='#' @click='edit(song, index)'>Edit</a>
-							<a class='button is-danger' href='#' @click='remove(song._id, index)'>Remove</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Thumbnail</td>
+					<td>Title</td>
+					<td>YouTube ID</td>
+					<td>Artists</td>
+					<td>Genres</td>
+					<td>Requested By</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, song) in songs' track-by='$index'>
+					<td>
+						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
+					</td>
+					<td>
+						<strong>{{ song.title }}</strong>
+					</td>
+					<td>{{ song._id }}</td>
+					<td>{{ song.artists.join(', ') }}</td>
+					<td>{{ song.genres.join(', ') }}</td>
+					<td>{{ song.requestedBy }}</td>
+					<td>
+						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
+						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
 	<edit-song v-show='isEditActive'></edit-song>
 </template>
@@ -56,29 +54,31 @@
 				video: {
 					player: null,
 					paused: false,
-					settings: function (type) {
-						switch(type) {
-							case 'stop':
-								this.player.stopVideo();
-								this.paused = true;
-								break;
-							case 'pause':
-								this.player.pauseVideo();
-								this.paused = true;
-								break;
-							case 'play':
-								this.player.playVideo();
-								this.paused = false;
-								break;
-							case 'skipToLast10Secs':
-								this.player.seekTo(this.player.getDuration() - 10);
-								break;
-						}
-					}
+					playerReady: false
 				}
 			}
 		},
 		methods: {
+			settings: function (type) {
+				let _this = this;
+				switch(type) {
+					case 'stop':
+						_this.video.player.stopVideo();
+						_this.video.paused = true;
+						break;
+					case 'pause':
+						_this.video.player.pauseVideo();
+						_this.video.paused = true;
+						break;
+					case 'play':
+						_this.video.player.playVideo();
+						_this.video.paused = false;
+						break;
+					case 'skipToLast10Secs':
+						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
+						break;
+				}
+			},
 			changeVolume: function() {
 				let local = this;
 				let volume = $("#volumeSlider").val();
@@ -88,7 +88,7 @@
 			},
 			toggleModal: function () {
 				this.isEditActive = !this.isEditActive;
-				this.video.settings('stop');
+				this.settings('stop');
 			},
 			addTag: function (type) {
 				if (type == 'genres') {
@@ -113,7 +113,7 @@
 			},
 			edit: function (song, index) {
 				if (this.video.player) {
-					this.video.player.loadVideoById(song._id);
+					this.video.player.loadVideoById(song._id, this.editing.song.skipDuration);
 					let songCopy = {};
 					for (let n in song) {
 						songCopy[n] = song[n];
@@ -122,11 +122,22 @@
 					this.isEditActive = true;
 				}
 			},
-			save: function (song) {
+			save: function (song, close) {
 				let _this = this;
 				this.socket.emit('songs.update', song._id, song, function (res) {
-					if (res.status == 'success' || res.status == 'error') Toast.methods.addToast(res.message, 2000);
-					_this.toggleModal();
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						_this.songs.forEach((lSong) => {
+							if (song._id === lSong._id) {
+								for (let n in song) {
+									lSong[n] = song[n];
+								}
+							}
+						});
+					}
+					if (close) {
+						_this.toggleModal();
+					}
 				});
 			},
 			remove: function (id, index) {
@@ -163,26 +174,48 @@
 				});
 			});
 
+			setInterval(() => {
+				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
+					_this.video.paused = false;
+					_this.video.player.stopVideo();
+				}
+			}, 200);
+
 			this.video.player = new YT.Player('player', {
 				height: 315,
 				width: 560,
 				videoId: this.editing.song._id,
 				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
+				startSeconds: _this.editing.song.skipDuration,
 				events: {
 					'onReady': () => {
 						let volume = parseInt(localStorage.getItem("volume"));
 						volume = (typeof volume === "number") ? volume : 20;
+						_this.video.player.seekTo(_this.editing.song.skipDuration);
 						_this.video.player.setVolume(volume);
 						if (volume > 0) _this.video.player.unMute();
+						_this.playerReady = true;
 					},
 					'onStateChange': event => {
-						if (event.data == 1) {
+						if (event.data === 1) {
+							_this.video.paused = false;
 							let youtubeDuration = _this.video.player.getDuration();
 							youtubeDuration -= _this.editing.song.skipDuration;
 							if (_this.editing.song.duration > youtubeDuration) {
 								this.video.player.stopVideo();
+								_this.video.paused = true;
 								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
+							} else if (_this.editing.song.duration <= 0) {
+								this.video.player.stopVideo();
+								_this.video.paused = true;
+								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
+							}
+
+							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
+								_this.video.player.seekTo(10);
 							}
+						} else if (event.data === 2) {
+							this.video.paused = true;
 						}
 					}
 				}

+ 71 - 75
frontend/components/Admin/Stations.vue

@@ -1,84 +1,80 @@
 <template>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<table class='table is-striped'>
-				<thead>
-					<tr>
-						<td>ID</td>
-						<td>Type</td>
-						<td>Display Name</td>
-						<td>Description</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for='(index, station) in stations' track-by='$index'>
-						<td>
-							<span>{{ station._id }}</span>
-						</td>
-						<td>
-							<span>{{ station.type }}</span>
-						</td>
-						<td>
-							<span>{{ station.description }}</span>
-						</td>
-						<td>
-							<span>{{ station.description }}</span>
-						</td>
-						<td>
-							<a class='button is-danger' @click='removeStation(index)' href='#'>Remove</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>ID</td>
+					<td>Type</td>
+					<td>Display Name</td>
+					<td>Description</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, station) in stations' track-by='$index'>
+					<td>
+						<span>{{ station._id }}</span>
+					</td>
+					<td>
+						<span>{{ station.type }}</span>
+					</td>
+					<td>
+						<span>{{ station.description }}</span>
+					</td>
+					<td>
+						<span>{{ station.description }}</span>
+					</td>
+					<td>
+						<a class='button is-danger' @click='removeStation(index)' href='#'>Remove</a>
+					</td>
+				</tr>
+			</tbody>
+		</table>
 	</div>
-	<div class='columns is-mobile'>
-		<div class='column is-8-desktop is-offset-2-desktop is-12-mobile'>
-			<div class='card is-fullwidth'>
-				<header class='card-header'>
-					<p class='card-header-title'>Create official station</p>
-				</header>
-				<div class='card-content'>
-					<div class='content'>
-						<div class='control is-horizontal'>
-							<div class='control is-grouped'>
-								<p class='control is-expanded'>
-									<input class='input' type='text' placeholder='Unique Identifier' v-model='newStation._id'>
-								</p>
-								<p class='control is-expanded'>
-									<input class='input' type='text' placeholder='Display Name' v-model='newStation.displayName'>
-								</p>
-							</div>
+	<div class='container'>
+		<div class='card is-fullwidth'>
+			<header class='card-header'>
+				<p class='card-header-title'>Create official station</p>
+			</header>
+			<div class='card-content'>
+				<div class='content'>
+					<div class='control is-horizontal'>
+						<div class='control is-grouped'>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Unique Identifier' v-model='newStation._id'>
+							</p>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Display Name' v-model='newStation.displayName'>
+							</p>
 						</div>
-						<label class='label'>Description</label>
-						<p class='control is-expanded'>
-							<input class='input' type='text' placeholder='Short description' v-model='newStation.description'>
-						</p>
-						<label class='label'>Genres</label>
-						<p class='control has-addons'>
-							<input class='input' id='new-genre' type='text' placeholder='Genre' v-on:keyup.enter='addGenre()'>
-							<a class='button is-info' href='#' @click='addGenre()'>Add genre</a>
-						</p>
-						<span class='tag is-info' v-for='(index, genre) in newStation.genres' track-by='$index'>
-							{{ genre }}
-							<button class='delete is-info' @click='removeGenre(index)'></button>
-						</span>
-						<label class='label'>Blacklisted Genres</label>
-						<p class='control has-addons'>
-							<input class='input' id='new-blacklisted-genre' type='text' placeholder='Blacklisted Genre' v-on:keyup.enter='addBlacklistedGenre()'>
-							<a class='button is-info' href='#' @click='addBlacklistedGenre()'>Add blacklisted genre</a>
-						</p>
-						<span class='tag is-info' v-for='(index, genre) in newStation.blacklistedGenres' track-by='$index'>
-							{{ genre }}
-							<button class='delete is-info' @click='removeBlacklistedGenre(index)'></button>
-						</span>
 					</div>
+					<label class='label'>Description</label>
+					<p class='control is-expanded'>
+						<input class='input' type='text' placeholder='Short description' v-model='newStation.description'>
+					</p>
+					<label class='label'>Genres</label>
+					<p class='control has-addons'>
+						<input class='input' id='new-genre' type='text' placeholder='Genre' v-on:keyup.enter='addGenre()'>
+						<a class='button is-info' href='#' @click='addGenre()'>Add genre</a>
+					</p>
+					<span class='tag is-info' v-for='(index, genre) in newStation.genres' track-by='$index'>
+						{{ genre }}
+						<button class='delete is-info' @click='removeGenre(index)'></button>
+					</span>
+					<label class='label'>Blacklisted Genres</label>
+					<p class='control has-addons'>
+						<input class='input' id='new-blacklisted-genre' type='text' placeholder='Blacklisted Genre' v-on:keyup.enter='addBlacklistedGenre()'>
+						<a class='button is-info' href='#' @click='addBlacklistedGenre()'>Add blacklisted genre</a>
+					</p>
+					<span class='tag is-info' v-for='(index, genre) in newStation.blacklistedGenres' track-by='$index'>
+						{{ genre }}
+						<button class='delete is-info' @click='removeBlacklistedGenre(index)'></button>
+					</span>
 				</div>
-				<footer class='card-footer'>
-					<a class='card-footer-item' @click='createStation()' href='#'>Create</a>
-				</footer>
 			</div>
+			<footer class='card-footer'>
+				<a class='card-footer-item' @click='createStation()' href='#'>Create</a>
+			</footer>
 		</div>
 	</div>
 </template>

+ 19 - 15
frontend/components/Modals/EditSong.vue

@@ -14,18 +14,18 @@
 							</p>
 						</form>
 						<p class='control has-addons'>
-							<a class='button' @click='$parent.video.settings("pause")' v-if='!$parent.video.paused' href='#'>
+							<button class='button' @click='$parent.settings("pause")' v-if='!$parent.video.paused'>
 								<i class='material-icons'>pause</i>
-							</a>
-							<a class='button' @click='$parent.video.settings("play")' v-if='$parent.video.paused' href='#'>
+							</button>
+							<button class='button' @click='$parent.settings("play")' v-if='$parent.video.paused'>
 								<i class='material-icons'>play_arrow</i>
-							</a>
-							<a class='button' @click='$parent.video.settings("stop")' href='#'>
+							</button>
+							<button class='button' @click='$parent.settings("stop")'>
 								<i class='material-icons'>stop</i>
-							</a>
-							<a class='button' @click='$parent.video.settings("skipToLast10Secs")' href='#'>
+							</button>
+							<button class='button' @click='$parent.settings("skipToLast10Secs")'>
 								<i class='material-icons'>fast_forward</i>
-							</a>
+							</button>
 						</p>
 					</div>
 				</div>
@@ -66,7 +66,7 @@
 						<div>
 							<p class='control has-addons'>
 								<input class='input' id='new-artist' type='text' placeholder='Artist'>
-								<a class='button is-info' @click='$parent.addTag("artists")' href='#'>Add Artist</a>
+								<button class='button is-info' @click='$parent.addTag("artists")'>Add Artist</button>
 							</p>
 							<span class='tag is-info' v-for='(index, artist) in $parent.editing.song.artists' track-by='$index'>
 								{{ artist }}
@@ -76,7 +76,7 @@
 						<div>
 							<p class='control has-addons'>
 								<input class='input' id='new-genre' type='text' placeholder='Genre'>
-								<a class='button is-info' @click='$parent.addTag("genres")' href='#'>Add Genre</a>
+								<button class='button is-info' @click='$parent.addTag("genres")'>Add Genre</button>
 							</p>
 							<span class='tag is-info' v-for='(index, genre) in $parent.editing.song.genres' track-by='$index'>
 								{{ genre }}
@@ -96,13 +96,17 @@
 
 			</section>
 			<footer class='modal-card-foot'>
-				<a class='button is-success' @click='$parent.save($parent.editing.song)' href='#'>
+				<button class='button is-success' @click='$parent.save($parent.editing.song, false)'>
 					<i class='material-icons save-changes'>done</i>
 					<span>&nbsp;Save</span>
-				</a>
-				<a class='button is-danger' @click='$parent.toggleModal()' href='#'>
-					<span>&nbsp;Cancel</span>
-				</a>
+				</button>
+				<button class='button is-success' @click='$parent.save($parent.editing.song, true)'>
+					<i class='material-icons save-changes'>done</i>
+					<span>&nbsp;Save and close</span>
+				</button>
+				<button class='button is-danger' @click='$parent.toggleModal()'>
+					<span>&nbsp;Close</span>
+				</button>
 			</footer>
 		</div>
 	</div>

+ 2 - 7
frontend/components/Modals/Report.vue

@@ -213,13 +213,8 @@
 <style type='scss' scoped>
 	h6 { margin-bottom: 15px; }
 
-	.song-types {
-		margin-right: 0;
-	}
-
-	.song-type:first-of-type {
-		padding-left: 0;
-	}
+	.song-type:first-of-type { padding-left: 0; }
+	.song-type:last-of-type { padding-right: 0; }
 
 	.media-content {
 		display: flex;

+ 29 - 6
frontend/components/Station/Station.vue

@@ -122,7 +122,8 @@
 				station: {},
 				skipVotes: 0,
 				privatePlaylistQueueSelected: null,
-				automaticallyRequestedSongId: null
+				automaticallyRequestedSongId: null,
+				systemDifference: 0
 			}
 		},
 		methods: {
@@ -163,7 +164,7 @@
 									local.player.seekTo(local.timeBeforePause / 1000, true);
 									local.player.pauseVideo();
 								}
-								if (event.data === 2 && !local.paused && !local.noSong && (local.getTimeElapsed() / 1000) < local.currentSong.duration) {
+								if (event.data === 2 && !local.paused && !local.noSong && (local.player.getDuration() / 1000) < local.currentSong.duration) {
 									local.player.seekTo(local.getTimeElapsed() / 1000 + local.currentSong.skipDuration, true);
 									local.player.playVideo();
 								}
@@ -174,7 +175,7 @@
 			},
 			getTimeElapsed: function() {
 				let local = this;
-				if (local.currentSong) return Date.now() - local.startedAt - local.timePaused;
+				if (local.currentSong) return Date.now2() - local.startedAt - local.timePaused;
 				else return 0;
 			},
 			playVideo: function() {
@@ -199,18 +200,19 @@
 			},
 			formatTime: function(duration) {
 				let d = moment.duration(duration, 'seconds');
+				if (duration < 0) return "0:00";
 				return ((d.hours() > 0) ? (d.hours() < 10 ? ("0" + d.hours() + ":") : (d.hours() + ":")) : "") + (d.minutes() + ":") + (d.seconds() < 10 ? ("0" + d.seconds()) : d.seconds());
 			},
 			calculateTimeElapsed: function() {
 				let local = this;
-				let currentTime = Date.now();
+				let currentTime = Date.now2();
 
 				if (local.currentTime !== undefined && local.paused) {
-					local.timePaused += (Date.now() - local.currentTime);
+					local.timePaused += (Date.now2() - local.currentTime);
 					local.currentTime = undefined;
 				}
 
-				let duration = (Date.now() - local.startedAt - local.timePaused) / 1000;
+				let duration = (Date.now2() - local.startedAt - local.timePaused) / 1000;
 				let songDuration = local.currentSong.duration;
 				if (songDuration <= duration) local.player.pauseVideo();
 				if ((!local.paused) && duration <= songDuration) local.timeElapsed = local.formatTime(duration);
@@ -356,11 +358,32 @@
 					_this.socket.emit('stations.getPlaylist', _this.stationId, res => {
 				 		if (res.status == 'success') _this.songsList = res.data;
 				 	});
+					// UNIX client time before ping
+					let beforePing = Date.now();
+					_this.socket.emit('apis.ping', res => {
+						// UNIX client time after ping
+						let afterPing = Date.now();
+						// Average time in MS it took between the server responding and the client receiving
+						let connectionLatency = (afterPing - beforePing) / 2;
+						console.log(connectionLatency, beforePing - afterPing);
+						// UNIX server time
+						let serverDate = res.date;
+						// Difference between the server UNIX time and the client UNIX time after ping, with the connectionLatency added to the server UNIX time
+						let difference = (serverDate + connectionLatency) - afterPing;
+						console.log("Difference: ", difference);
+						if (difference > 3000 || difference < -3000) {
+							console.log("System time difference is bigger than 3 seconds.");
+						}
+						_this.systemDifference = difference;
+					});
 				});
 			}
 		},
 		ready: function() {
 			let _this = this;
+			Date.now2 = function() {
+				return new Date().getTime() + _this.systemDifference;
+			};
 			_this.stationId = _this.$route.params.id;
 			window.stationInterval = 0;
 

+ 1 - 1
frontend/nginx.conf

@@ -13,7 +13,7 @@ http {
     keepalive_timeout  65;
 
     server {
-        listen       80;
+        listen       ${NGINX_PORT};
         server_name  localhost;
 
         location / {