├── .gitignore ├── public ├── img │ ├── kanban-musume │ ├── kanban-musume.ai │ └── kanban-musume.tohru ├── errorHandler.js ├── controller.js ├── youtube-handler.js ├── search.js ├── socket-handler.js ├── main.css ├── main.js ├── view.js └── jsVideoUrlParser.min.js ├── scripts ├── install-git-hooks └── pre-commit.hook ├── config └── default-sample.json ├── route ├── api.js └── search.js ├── lib ├── dispatcher.js ├── fakeplayer.js ├── webcontroller.js ├── fetcher.js ├── playlist.js ├── discordbot.js └── listmodel.js ├── package.json ├── README.md ├── server.js └── view └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config/default.json 3 | .eslintrc.json 4 | 5 | .*.swo 6 | .*.swp 7 | -------------------------------------------------------------------------------- /public/img/kanban-musume: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccns/CCNS-Radio/HEAD/public/img/kanban-musume -------------------------------------------------------------------------------- /public/img/kanban-musume.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccns/CCNS-Radio/HEAD/public/img/kanban-musume.ai -------------------------------------------------------------------------------- /public/img/kanban-musume.tohru: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccns/CCNS-Radio/HEAD/public/img/kanban-musume.tohru -------------------------------------------------------------------------------- /public/errorHandler.js: -------------------------------------------------------------------------------- 1 | function errorHandler (data) { 2 | switch (data.code) { 3 | default: 4 | alert(data.msg) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /scripts/install-git-hooks: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if ! test -d .git; then 4 | echo "Execute scripts/install-git-hooks in the top-level directory." 5 | exit 1 6 | fi 7 | 8 | ln -sf ../../scripts/pre-commit.hook .git/hooks/pre-commit || exit 1 9 | chmod +x .git/hooks/pre-commit 10 | 11 | touch .git/hooks/applied || exit 1 12 | 13 | echo "Git commit hooks are installed successfully." 14 | -------------------------------------------------------------------------------- /config/default-sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "", 3 | "playlist": { 4 | "youtube_api_key": "", 5 | "database": "" 6 | }, 7 | "discord": { 8 | "enabled": false, 9 | "token": "", 10 | "prefix": "/", 11 | "commandChannelNames": ["radio", "music"], 12 | "replyChannelName": "radio" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /public/controller.js: -------------------------------------------------------------------------------- 1 | var controller = { 2 | pausePlay: function () { 3 | var stat = player.getPlayerState() 4 | switch (stat) { 5 | case 1: 6 | player.pauseVideo() 7 | break 8 | case 2: 9 | case 5: 10 | player.playVideo() 11 | break 12 | } 13 | }, 14 | setVolume: function (value) { 15 | player.setVolume(value) 16 | view.updateVolume(value) 17 | }, 18 | play: function (value) { 19 | player.playVideo() 20 | }, 21 | pause: function (value) { 22 | player.pauseVideo() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /route/api.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | 3 | class ApiRouter { 4 | constructor (playlist) { 5 | this.router = express.Router() 6 | this.playlist = playlist 7 | 8 | var self = this 9 | 10 | // Control api 11 | this.router.post('/play', function (req, res) { 12 | self.playlist.play() 13 | res.send('Play') 14 | }) 15 | 16 | this.router.post('/pause', function (req, res) { 17 | self.playlist.pause() 18 | res.send('Pause') 19 | }) 20 | 21 | this.router.post('/next', function (req, res) { 22 | self.playlist.nextSong() 23 | res.send('Next song') 24 | }) 25 | } 26 | 27 | getRouter () { 28 | return this.router 29 | } 30 | } 31 | 32 | module.exports = ApiRouter 33 | -------------------------------------------------------------------------------- /public/youtube-handler.js: -------------------------------------------------------------------------------- 1 | function onYouTubeIframeAPIReady () { 2 | player = new YT.Player('player', { 3 | height: '150', 4 | width: '100%', 5 | videoId: 'xkMdLcB_vNU', 6 | events: { 7 | 'onReady': onPlayerReady, 8 | 'onStateChange': onPlayerStateChange 9 | } 10 | }) 11 | } 12 | 13 | function onPlayerReady (event) { 14 | initSocketIO() 15 | getList() 16 | getPlaying() 17 | getVolume() 18 | // mute if controller 19 | if (window.location.pathname === '/control') { player.mute() } 20 | } 21 | 22 | function onPlayerStateChange (event) { 23 | if (event.data === 0) { 24 | if(window.location.pathname == '/') { nextSong() } 25 | } 26 | } 27 | 28 | 29 | function play (id) { 30 | load(id) 31 | player.playVideo() 32 | } 33 | 34 | function load (id) { 35 | player.cueVideoById(id, 0, 'highres') 36 | } 37 | -------------------------------------------------------------------------------- /scripts/pre-commit.hook: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | COMMIT_AGAIN=1 4 | 5 | files=$(git diff --cached --name-only --diff-filter=ACM | grep "\.js$") 6 | if [ "$files" = "" ]; then 7 | exit 0 8 | fi 9 | 10 | for file in ${files}; do 11 | result=$(./node_modules/.bin/eslint ${file} | grep "potentially fixable with the \`--fix\` option.") 12 | ADD_AGAIN=1 13 | if [ "$result" != "" ]; then 14 | COMMIT_AGAIN=0 15 | ADD_AGAIN=0 16 | fi 17 | 18 | if ./node_modules/.bin/eslint ${file} --fix; then 19 | echo "\t\033[32mESLint Passed: ${file}\033[0m" 20 | else 21 | echo "\t\033[31mESLint Failed: ${file}\033[0m" 22 | fi 23 | 24 | if [ $ADD_AGAIN -eq 0 ]; then 25 | git add ${file} 26 | fi 27 | done 28 | 29 | if [ $COMMIT_AGAIN -eq 0 ]; then 30 | echo "\t\033[31m[!] git added some modified files to index, please recommit again.\033[0m" 31 | exit 1 32 | fi 33 | -------------------------------------------------------------------------------- /lib/dispatcher.js: -------------------------------------------------------------------------------- 1 | class Dispatcher { 2 | constructor (io) { 3 | this.io = io 4 | } 5 | 6 | play () { 7 | this.io.emit('play') 8 | } 9 | 10 | pause () { 11 | this.io.emit('pause') 12 | } 13 | 14 | pausePlay () { 15 | this.io.emit('pauseplay') 16 | } 17 | 18 | updateList (list) { 19 | this.io.emit('update list', list) 20 | } 21 | 22 | updatePlaying (playing) { 23 | this.io.emit('update playing', playing) 24 | } 25 | 26 | updateListSocket (socket, list) { 27 | socket.emit('update list', list) 28 | } 29 | 30 | updatePlayingSocket (socket, playing) { 31 | socket.emit('update playing', playing) 32 | } 33 | 34 | getSong (list) { 35 | this.io.emit('get song', list) 36 | } 37 | 38 | setVolume (volume) { 39 | this.io.emit('set volume', volume) 40 | } 41 | } 42 | 43 | module.exports = Dispatcher 44 | -------------------------------------------------------------------------------- /route/search.js: -------------------------------------------------------------------------------- 1 | const YoutubeVideoFetcher = require('../lib/fetcher.js') 2 | const express = require('express') 3 | const bodyParser = require('body-parser') 4 | 5 | class SearchRouter { 6 | constructor (config) { 7 | this.router = express.Router() 8 | this.fetcher = new YoutubeVideoFetcher(config) 9 | 10 | this.router.use(bodyParser.urlencoded({ extended: false })) 11 | this.router.use(bodyParser.json()) 12 | 13 | var self = this 14 | 15 | this.router.post('/', function (req, res) { 16 | var q = req.body.q 17 | var pageToken = req.body.pageToken 18 | 19 | self.fetcher.search(q, pageToken).then(function (data) { 20 | res.json(data) 21 | }).catch(function (err) { 22 | res.send('Failed') 23 | }) 24 | }) 25 | } 26 | 27 | getRouter () { 28 | return this.router 29 | } 30 | } 31 | 32 | module.exports = SearchRouter 33 | -------------------------------------------------------------------------------- /public/search.js: -------------------------------------------------------------------------------- 1 | var resultListItemHtml = '
' 2 | 3 | function fetchSearchResult (query) { 4 | $.post('/search', {q: query}).done(function (data) { 5 | console.log(data) 6 | var list = $('#searchModal .ts.list') 7 | var items = data.items 8 | items.forEach(function (val, idx) { 9 | var itemHtml = resultListItemHtml 10 | .replace('{image}', val.thumbnail.url) 11 | .replace('{url}', val.url) 12 | .replace('{title}', val.title) 13 | var songId = val.id 14 | var item = $(itemHtml).click(function () { 15 | newSong({id: songId}) 16 | }) 17 | list.append(item) 18 | }) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ccns-radio", 3 | "version": "1.0.0", 4 | "description": "", 5 | "repository": "https://github.com/ccns/CCNS-Radio", 6 | "main": "server.js", 7 | "scripts": { 8 | "test": "nodemon server.js", 9 | "start": "node server.js" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@google-cloud/firestore": "^3.7.4", 15 | "config": "^1.26.2", 16 | "discord.js": "^11.2.1", 17 | "ejs": "^2.5.6", 18 | "express": "^4.15.2", 19 | "firebase-admin": "^8.10.0", 20 | "googleapis": "^48.0.0", 21 | "internal-ip": "^1.2.0", 22 | "iso8601-duration": "^1.2.0", 23 | "request": "^2.88.0", 24 | "socket.io": "^2.1.1" 25 | }, 26 | "devDependencies": { 27 | "eslint": "^4.9.0", 28 | "eslint-config-standard": "^11.0.0", 29 | "eslint-plugin-import": "^2.9.0", 30 | "eslint-plugin-node": "^6.0.1", 31 | "eslint-plugin-promise": "^3.6.0", 32 | "eslint-plugin-standard": "^3.0.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/fakeplayer.js: -------------------------------------------------------------------------------- 1 | const {parse, toSeconds} = require('iso8601-duration'); 2 | 3 | class FakePlayer { 4 | constructor (playlist) { 5 | this.playlist = playlist 6 | this.start = 0 7 | this.timeoutId = null 8 | this.remaining = 0 9 | this.song_data = null 10 | } 11 | 12 | play (song_data) { 13 | if(this.timeoutId !== null) { this.pause() } 14 | console.log("[fake] Playing " + song_data.id) 15 | this.remaining = (toSeconds(parse(song_data.duration)) + 5) * 1000 16 | this.song_data = song_data 17 | this.resume() 18 | } 19 | 20 | resume () { 21 | console.log("[fake] Resume " + this.song_data.id) 22 | this.start = Date.now() 23 | this.timeoutId = setTimeout(() => { 24 | this.song_data = null 25 | this.timeoutId = null 26 | this.playlist.nextSong() 27 | }, this.remaining); 28 | } 29 | 30 | pause () { 31 | console.log("[fake] Pause " + this.song_data.id) 32 | clearTimeout(this.timeoutId) 33 | this.remaining -= Date.now() - this.start 34 | this.timeoutId = null 35 | } 36 | 37 | pausePlay () { 38 | if (this.timeoutId) { 39 | this.pause() 40 | } else { 41 | this.resume() 42 | } 43 | } 44 | 45 | isPlaying () { 46 | return this.song_data != null 47 | } 48 | 49 | getProgress () { 50 | return remaining 51 | } 52 | } 53 | 54 | module.exports = FakePlayer 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CCNS Radio 2 | === 3 | 4 | A nodejs based radio system that accept song request by youtube url and play on the server side web ui. Support discord chatbot control. 5 | 6 | Installation 7 | --- 8 | ### edit config 9 | ``` 10 | $ cp config/default-sample.json config/default.json 11 | $ vim config/default.json 12 | ``` 13 | ### start server 14 | ``` 15 | $ npm install 16 | $ npm start 17 | ``` 18 | ### development settings 19 | ``` 20 | $ ./scripts/install-git-hooks 21 | $ ./node_modules/.bin/eslint --init 22 | ? How would you like to configure ESLint? 23 | ❯ Use a popular style guide 24 | ? Which style guide do you want to follow? 25 | ❯ Standard 26 | ? What format do you want your config file to be in? 27 | ❯ JSON 28 | ``` 29 | 30 | Costume 31 | --- 32 | ### change kanban musume 33 | Replace `public/img/kanban-musume` by your musume(? 34 | 35 | Discord bot support 36 | --- 37 | Support discord bot control. 38 | 39 | ``` 40 | [Command list] 41 | /playing : Get information of currently playing song. 42 | /add [youtube_url] : Add a song. 43 | /next : Skip current song. 44 | /playpause : Play/Pause current song. 45 | /controller : Show controller. 46 | ``` 47 | 48 | Web API 49 | --- 50 | ### POST `/play` 51 | * body: none 52 | * response: 53 | ``` 54 | Play 55 | ``` 56 | 57 | ### POST `/pause` 58 | * body: none 59 | * response: 60 | ``` 61 | Pause 62 | ``` 63 | 64 | ### POST `/next` 65 | * body: none 66 | * response: 67 | ``` 68 | Next song 69 | ``` 70 | -------------------------------------------------------------------------------- /public/socket-handler.js: -------------------------------------------------------------------------------- 1 | // socket.on 2 | function initSocketIO () { 3 | socket = io() 4 | 5 | // socket.io listeners 6 | socket.on('get song', function (data) { 7 | view.updateList(data) 8 | view.updatePlaying(data) 9 | play(data.playing.id) 10 | }) 11 | 12 | socket.on('set song', function (data) { 13 | view.updatePlaying(data) 14 | play(data.playing.id) 15 | }) 16 | 17 | socket.on('update list', function (data) { 18 | view.updateList(data) 19 | }) 20 | 21 | socket.on('update playing', function (data) { 22 | if (data.playing) { 23 | view.updatePlaying(data) 24 | load(data.playing.id) 25 | } 26 | }) 27 | 28 | socket.on('err', function (data) { 29 | errorHandler(data) 30 | }) 31 | 32 | socket.on('set volume', function (data) { 33 | controller.setVolume(data) 34 | }) 35 | 36 | socket.on('pauseplay', function (data) { 37 | controller.pausePlay(data) 38 | }) 39 | 40 | socket.on('get volume', function (data) { 41 | controller.setVolume(data) 42 | }) 43 | 44 | socket.on('play', function (data) { 45 | controller.play(data) 46 | }) 47 | 48 | socket.on('pause', function (data) { 49 | controller.pause(data) 50 | }) 51 | 52 | socket.on('user count', function (data) { 53 | console.log(data) 54 | $('#user-count').text(data) 55 | }) 56 | } 57 | 58 | // socket.emit 59 | function plus (id) { 60 | socket.emit('push queue', {id: id}) 61 | } 62 | function removeQueue (id) { 63 | socket.emit('remove queue', {id: id}) 64 | } 65 | function removeHistory (id) { 66 | socket.emit('remove history', {id: id}) 67 | } 68 | function setPlaying (id) { 69 | socket.emit('set playing', {id: id}) 70 | } 71 | function nextSong () { 72 | socket.emit('next song') 73 | } 74 | function getList () { 75 | socket.emit('get list') 76 | } 77 | function getPlaying () { 78 | socket.emit('get playing') 79 | } 80 | function newSong (data) { 81 | socket.emit('new song', data) 82 | } 83 | function newList (data) { 84 | socket.emit('new list', data) 85 | } 86 | function setVolume (value) { 87 | socket.emit('set volume', value) 88 | } 89 | function getVolume (value) { 90 | socket.emit('get volume', value) 91 | } 92 | function pausePlay (value) { 93 | socket.emit('pauseplay', value) 94 | } 95 | 96 | -------------------------------------------------------------------------------- /lib/webcontroller.js: -------------------------------------------------------------------------------- 1 | class WebController { 2 | constructor (playlist, io) { 3 | this.io = io 4 | this.playlist = playlist 5 | this.userCnt = 0 6 | } 7 | 8 | connectionHandler () { 9 | var self = this 10 | return function (socket) { 11 | self.userCnt++; 12 | console.log("[info] A new user connected!") 13 | self.io.emit('user count', self.userCnt) 14 | 15 | // Disconnect 16 | socket.on('disconnect', () => { 17 | self.userCnt--; 18 | console.log("[info] A user disconnected.") 19 | self.io.emit('user count', self.userCnt) 20 | }); 21 | 22 | // Create 23 | socket.on('new song', function (data) { 24 | var id = data.id 25 | self.playlist.newSongByYoutubeId(id).catch((err) => { 26 | socket.emit('err', err) 27 | }) 28 | }) 29 | 30 | socket.on('new list', function (data) { 31 | var id = data.id 32 | self.playlist.newListByYoutubeId(id).catch((err) => { 33 | socket.emit('err', err) 34 | }) 35 | }) 36 | 37 | // Read 38 | socket.on('get list', function (data) { 39 | self.playlist.updateList(socket) 40 | }) 41 | 42 | socket.on('get playing', function (data) { 43 | self.playlist.updatePlaying(socket) 44 | }) 45 | 46 | // Control 47 | socket.on('next song', function (data) { 48 | self.playlist.nextSong() 49 | }) 50 | 51 | // TODO: modify get volume 52 | socket.on('get volume', function () { 53 | var volume = self.playlist.getVolume() 54 | socket.emit('get volume', volume) 55 | }) 56 | 57 | // Update 58 | socket.on('push queue', function (data) { 59 | var id = data.id 60 | self.playlist.pushQueue(id) 61 | }) 62 | 63 | socket.on('set volume', function (value) { 64 | self.playlist.setVolume(value) 65 | }) 66 | 67 | socket.on('set playing', function (data) { 68 | var id = data.id 69 | self.playlist.setSong(id) 70 | }) 71 | 72 | // Delete 73 | socket.on('remove queue', function (data) { 74 | var id = data.id 75 | self.playlist.removeQueue(id) 76 | }) 77 | 78 | socket.on('remove history', function (data) { 79 | var id = data.id 80 | self.playlist.removeHistory(id) 81 | }) 82 | 83 | // Player Control 84 | socket.on('pauseplay', function () { 85 | self.playlist.pausePlay() 86 | }) 87 | } 88 | } 89 | } 90 | 91 | module.exports = WebController 92 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Setup basic express server 2 | const express = require('express') 3 | const app = express() 4 | const server = require('http').createServer(app) 5 | const io = require('socket.io')(server) 6 | const port = process.env.PORT || 3000 7 | 8 | // Other requires 9 | const path = require('path') 10 | const localip = require('internal-ip').v4() 11 | const config = require('config') 12 | const server_mode = config.get('mode') 13 | 14 | /// Module initialization 15 | // Init dispatcher 16 | const Dispatcher = require('./lib/dispatcher') 17 | var dispatcher = new Dispatcher(io) 18 | 19 | // Init playlist 20 | const playlist_config = config.get('playlist') 21 | const Playlist = require('./lib/playlist') 22 | var playlist = new Playlist(dispatcher, playlist_config, server_mode) 23 | 24 | // Init webcontroller 25 | const WebController = require('./lib/webcontroller') 26 | var webController = new WebController(playlist, io) 27 | 28 | // Init API router 29 | const ApiRouter = require('./route/api') 30 | var apiRouter = new ApiRouter(playlist) 31 | 32 | // Init Search router 33 | const SearchRouter = require('./route/search') 34 | var searchRouter = new SearchRouter(playlist_config) 35 | 36 | // Init Discord Bot 37 | const discord_config = config.get('discord') 38 | if(discord_config.enabled) { 39 | const DiscordBot = require('./lib/discordbot') 40 | const discordBot = new DiscordBot(playlist, discord_config, server_mode) 41 | discordBot.login() 42 | } 43 | 44 | /// Express setting 45 | // Init EJS 46 | app.set('views', path.join(__dirname, 'view')) 47 | app.engine('html', require('ejs').renderFile) 48 | app.set('view engine', 'html') 49 | 50 | // Static folder 51 | app.use(express.static(path.join(__dirname, '/public'))) 52 | 53 | // Routing 54 | app.get('/', function (req, res) { 55 | // in ipv6 localhost look like `::ffff:127.0.0.1` 56 | if (/127.0.0.1|::1/.test(req.ip)) { 57 | res.render('index', { 58 | serverip: localip, 59 | info: "新增歌曲: http://ccns-radio:3000" 60 | }) 61 | } else if(server_mode == 'station') { 62 | res.redirect('/control') 63 | } else if(server_mode == 'service') { 64 | res.redirect('/client') 65 | } 66 | }) 67 | 68 | app.get('/control', function (req, res) { 69 | res.render('index', { 70 | serverip: localip, 71 | info: "控制器模式" 72 | }) 73 | }) 74 | 75 | app.get('/client', function (req, res) { 76 | res.render('index', { 77 | serverip: localip, 78 | info: "歡迎光臨" 79 | }) 80 | }) 81 | 82 | app.use('/api', apiRouter.getRouter()) 83 | 84 | app.use('/search', searchRouter.getRouter()) 85 | 86 | /// Start Server 87 | server.listen(port, function () { 88 | console.log('Server listening at port %d', port) 89 | }) 90 | 91 | /// Socket.io 92 | io.on('connection', webController.connectionHandler()) 93 | -------------------------------------------------------------------------------- /public/main.css: -------------------------------------------------------------------------------- 1 | .body { 2 | padding-bottom: 80px; 3 | } 4 | .header { 5 | margin-bottom: 20px; 6 | } 7 | .control.bar { 8 | background-color: #21BCF3; 9 | width: 100%; 10 | position: fixed; 11 | text-align: center; 12 | opacity: 0.87; 13 | bottom: 0; 14 | z-index: 6; 15 | } 16 | #play { 17 | margin-bottom: 20px; 18 | } 19 | .request .ts.form .field { 20 | margin-bottom: 10px; 21 | } 22 | .songlist .ts.items button { 23 | margin-right: 5px !important; 24 | cursor: pointer; 25 | } 26 | /* 27 | #serval { 28 | position: fixed; 29 | float: right; 30 | right: 50px; 31 | bottom: -3px; 32 | z-index: -1; 33 | } 34 | #serval>img { 35 | opacity: 0.6; 36 | } 37 | */ 38 | /* #tohru { 39 | float: right; 40 | right: 30px; 41 | z-index: 1; 42 | bottom: 22px; 43 | position: fixed; 44 | } 45 | #tohru>img { 46 | opacity: 0.9; 47 | } */ 48 | #kanban { 49 | float: right; 50 | right: 50px; 51 | z-index: 10; 52 | bottom: 22px; 53 | position: fixed; 54 | } 55 | #kanban>img { 56 | opacity: 0.9; 57 | max-height: 80%; 58 | max-width: 250px; 59 | height: auto; 60 | width: auto; 61 | } 62 | .control.bar button { 63 | vertical-align: middle; 64 | } 65 | .volume-controller { 66 | display: inline-block; 67 | height: 40px; 68 | vertical-align: middle; 69 | } 70 | .volume-bar { 71 | display: inline-block; 72 | width: 150px; 73 | padding: 8px; 74 | vertical-align: middle; 75 | } 76 | .volume.icon { 77 | color: white; 78 | font-size: 20px; 79 | width: 20px !important; 80 | vertical-align: middle; 81 | margin-left: 5px; 82 | } 83 | 84 | #search-in-youtube { 85 | margin: 1em 0; 86 | } 87 | 88 | #searchModal { 89 | display: flex; 90 | flex-direction: column; 91 | height: 100%; 92 | } 93 | #searchModal > .header { 94 | margin-bottom: 0px; 95 | } 96 | #searchModal > .searchbar { 97 | padding: 10px 10px; 98 | } 99 | #searchModal > .content { 100 | overflow-y: scroll; 101 | flex-grow: 1; 102 | 103 | display: flex; 104 | flex-direction: column; 105 | } 106 | #searchModal .actions { 107 | } 108 | #searchModal .myListItem { 109 | display: flex; 110 | align-items: flex-start; 111 | } 112 | #searchModal .myListItem > .ts.image { 113 | flex: 0 0 120px; 114 | } 115 | #searchModal .myListItem > .description { 116 | padding-left: 10px; 117 | } 118 | #searchModal .myListItem > .description > .ts.header { 119 | overflow: hidden; 120 | text-overflow: ellipsis; 121 | display: -webkit-box; 122 | -webkit-box-orient: vertical; 123 | -webkit-line-clamp: 2; 124 | line-height: 1.5em; 125 | max-height: 3em; 126 | } 127 | #queue, #history { 128 | margin-top: 2em; 129 | height: 70vh; 130 | overflow: auto; 131 | } 132 | #queue::-webkit-scrollbar, #history::-webkit-scrollbar { 133 | display: none; 134 | } 135 | -------------------------------------------------------------------------------- /lib/fetcher.js: -------------------------------------------------------------------------------- 1 | const {google} = require('googleapis') 2 | 3 | class YoutubeVideoFetcher { 4 | constructor (config) { 5 | this.api = google.youtube({ 6 | version: 'v3', 7 | auth: config.youtube_api_key 8 | }) 9 | } 10 | 11 | async fetchVideo (id) { 12 | const res = await this.api.videos.list({ 13 | part: 'snippet,contentDetails', 14 | id: id 15 | }).catch((err) => { 16 | console.log('[error] Something bad happened.') 17 | console.log('[error-info]', err) 18 | throw {code: -1, msg: 'Something bad happened.'} 19 | }) 20 | 21 | if (res.data.items.length == 0) { 22 | throw {code: 2, msg: 'Song cannot be accessed.'} 23 | } 24 | 25 | var title = res.data.items[0].snippet.title 26 | var url = 'https://youtu.be/' + id 27 | var duration = res.data.items[0].contentDetails.duration 28 | var song_data = { 29 | id: id, 30 | title: title, 31 | url: url, 32 | duration: duration 33 | } 34 | 35 | return song_data 36 | } 37 | 38 | async fetchList (id) { 39 | const res = await this.api.playlistItems.list({ 40 | part: 'snippet,contentDetails', 41 | maxResults: 50, 42 | playlistId: id 43 | }).catch((err) => { 44 | console.log('[error] Something bad happened.') 45 | console.log('[error-info]', err) 46 | throw {code: -1, msg: 'Something bad happened.'} 47 | }) 48 | 49 | const items = res.data.items 50 | const songList = [] 51 | console.log('[info] List length: ' + items.length) 52 | for (const item of items) { 53 | var id = item.snippet.resourceId.videoId 54 | var song_data = await this.fetchVideo(id).catch((err) => { 55 | if(err.code < 0) throw err 56 | }) 57 | if(song_data) { songList.push(song_data) } 58 | } 59 | console.log('[info] Finish retrieving list.') 60 | return songList 61 | } 62 | 63 | async search (query, pageToken) { 64 | const res = await this.api.search.list({ 65 | q: query, 66 | maxResults: 25, 67 | type: 'video,playlist', 68 | part: 'snippet', 69 | pageToken: pageToken 70 | }).catch((err) => { 71 | console.log('[error] Something bad happened when search.') 72 | console.log('[error-info]', err) 73 | throw {code: -1, msg: 'Something bad happened.'} 74 | }) 75 | 76 | var items = res.data.items 77 | var nextPageToken = res.nextPageToken 78 | var prevPageToken = res.prevPageToken 79 | items = items.map(function (item) { 80 | return { 81 | id: item.id.videoId, 82 | title: item.snippet.title, 83 | url: 'https://youtu.be/' + item.id.videoId, 84 | thumbnail: item.snippet.thumbnails.default 85 | } 86 | }) 87 | return {items: items, nextPageToken: nextPageToken, prevPageToken: prevPageToken} 88 | } 89 | } 90 | 91 | module.exports = YoutubeVideoFetcher 92 | -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | // next song 3 | $('#next').click(function () { 4 | nextSong() 5 | }) 6 | 7 | // Button click listeners 8 | // playpause 9 | $('#playpause').click(function () { 10 | pausePlay() 11 | }) 12 | 13 | // radio 14 | $('#radio').click(function () { 15 | play('xkMdLcB_vNU') 16 | view.updatePlaying({ 17 | playing: { 18 | id: 'xkMdLcB_vNU', 19 | title: 'ようこそジャパリパークへ', 20 | url: 'https://youtu.be/xkMdLcB_vNU' 21 | } 22 | }) 23 | }) 24 | 25 | // handle user enter input 26 | $('#urls').keydown(function (event) { 27 | if (event.keyCode === 13 && !event.shiftKey) { 28 | event.preventDefault() 29 | $('#submit-request').click() 30 | $('#urls').val('') 31 | } 32 | }) 33 | 34 | // submit request 35 | $('#submit-request').click(function () { 36 | var urls = $('#urls').val().split('\n') 37 | var fail = [] 38 | urls.map(function (url) { 39 | parsed = urlParser.parse(url) 40 | if (parsed && parsed.provider === 'youtube') { 41 | let data = {} 42 | switch (parsed.mediaType) { 43 | case 'video': 44 | data.id = parsed.id 45 | newSong(data) 46 | break 47 | case 'playlist': 48 | data.id = parsed.list 49 | newList(data) 50 | break 51 | default: 52 | fail.push(url) 53 | } 54 | } 55 | else { 56 | fail.push(url) 57 | } 58 | }) 59 | if (fail.length) { 60 | fail = fail.join('\n') 61 | $('#urls').val('Invalid Urls:\n' + fail) 62 | } else { 63 | $('#urls').val('') 64 | } 65 | }) 66 | 67 | // volume change 68 | $('#volume').change(function () { 69 | var value = $(this).val() 70 | if (window.location.pathname === '/client') { // service mode 71 | player.setVolume(value) 72 | } else { // station mode 73 | setVolume(value) 74 | } 75 | 76 | }) 77 | 78 | // hide player if control 79 | if (window.location.pathname === '/control') { 80 | $('#player').hide() 81 | } 82 | 83 | // hide next if client 84 | if (window.location.pathname === '/client') { 85 | $('#radio').hide() 86 | $('#playpause').hide() 87 | $('#next').hide() 88 | } 89 | 90 | // Open search modal 91 | $('#open-search-modal').click(function () { 92 | $('#searchModalDimmer').addClass('active') 93 | }) 94 | $('#close-search-modal').click(function () { 95 | $('#searchModalDimmer').removeClass('active') 96 | }) 97 | 98 | // Fetching search result 99 | $('#searchSubmit').click(function () { 100 | var list = $('#searchModal .ts.list').empty() 101 | var query = $('#searchQuery').val() 102 | fetchSearchResult(query) 103 | }) 104 | $('#searchQuery').keypress(function (e) { 105 | if (e.which == 13) $('#searchSubmit').click() 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /public/view.js: -------------------------------------------------------------------------------- 1 | var view = { 2 | updateHistory: function (data) { 3 | var items = $('#history .ts.items').empty() 4 | $.each(data.history, function (d) { 5 | d = data.history[d] 6 | var item = $('
') 7 | .addClass('ts item') 8 | .appendTo(items) 9 | var plus_icon = $(' 48 | 49 |
50 | 51 | 61 | 62 | 63 | 64 |
65 |

Queue

66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 |
74 |
75 |
76 | 77 |
78 |

History

79 |
80 |
81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 |
89 | 90 | 91 | 92 | 93 | 94 |
95 | 96 |
97 |
98 | 99 | 100 | 101 |
102 | 103 |
104 |
105 | 106 |
107 |
108 |
109 |
110 |
111 | 112 | 113 |
114 | 115 |
116 | 找找看 117 |
118 | 124 |
125 |
126 | 127 |
128 |
129 |
130 | 133 |
134 |
135 |
136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /lib/playlist.js: -------------------------------------------------------------------------------- 1 | const YoutubeVideoFetcher = require('./fetcher.js') 2 | const FakePlayer = require('./fakeplayer.js') 3 | const {FirestoreList, LocalList} = require('./listmodel.js') 4 | 5 | function debug(...err) { 6 | if (process.env.DEBUG) { 7 | console.log(...err) 8 | } 9 | } 10 | 11 | class Playlist { 12 | constructor (dispatcher, config, mode) { 13 | if (config.database === "firestore") { 14 | this.list = new FirestoreList() 15 | } else if (config.database === "local") { 16 | this.list = new LocalList() 17 | } 18 | this.volume = 50 19 | 20 | this.dispatcher = dispatcher 21 | this.mode = mode 22 | this.fetcher = new YoutubeVideoFetcher(config) 23 | 24 | if (mode == 'service') { 25 | this.fakeplayer = new FakePlayer(this) 26 | this.nextSong() 27 | } 28 | } 29 | 30 | async newSong (song_data) { 31 | var id = song_data.id; 32 | 33 | var in_queue = (await this.list.searchQueue(id)) !== undefined 34 | var in_history = (await this.list.searchHistory(id)) !== undefined 35 | 36 | // playing is not empty -> playing.id !== id -> false 37 | // playing is empty -> false 38 | var playing = await this.list.getPlaying() 39 | var in_playing = !(playing === undefined || (playing.id !== id)) 40 | debug('[debug] Check if new song already exist') 41 | debug('[debug] In queue? ' + in_queue) 42 | debug('[debug] In history? ' + in_history) 43 | debug('[debug] In playing? ' + in_playing) 44 | 45 | if (in_queue || in_history || in_playing) { 46 | throw {code: 1, msg: 'song already in playlist!'} 47 | } else { 48 | debug('[debug] Add song to queue') 49 | await this.list.addToQueue(song_data) 50 | debug('[debug] Songs in queue after pushing') 51 | debug(await this.list.getQueue()) 52 | } 53 | } 54 | 55 | async newList (song_list) { 56 | var song_added = [] 57 | for (const song of song_list) { 58 | try { 59 | await this.newSong(song) 60 | song_added.push(song) 61 | } 62 | catch (err) { 63 | debug('[debug] Something bad catched.') 64 | debug('[debug]', err) 65 | if(err.code < 0) throw err 66 | } 67 | } 68 | return song_added 69 | } 70 | 71 | async newSongByYoutubeId (id) { 72 | console.log('[info] New song: ' + id) 73 | var song_data = await this.fetcher.fetchVideo(id) 74 | await this.newSong(song_data) 75 | this.dispatcher.updateList({queue: await this.list.getQueue(), history: await this.list.getHistory()}) 76 | return song_data 77 | } 78 | 79 | async newListByYoutubeId (id) { 80 | console.log('[info] New list: ' + id) 81 | var song_list = await this.fetcher.fetchList(id) 82 | var song_added = await this.newList(song_list) 83 | this.dispatcher.updateList({queue: await this.list.getQueue(), history: await this.list.getHistory()}) 84 | return song_added 85 | } 86 | 87 | // Read 88 | async getQueue () { 89 | console.log('[info] Get Queue') 90 | return await this.list.getQueue() 91 | } 92 | 93 | async getHistory () { 94 | console.log('[info] Get History') 95 | return await this.list.getHistory() 96 | } 97 | 98 | async getPlaying () { 99 | var playing = await this.list.getPlaying() 100 | console.log('[info] Get Playing: ' + playing) 101 | return playing 102 | } 103 | 104 | getVolume () { 105 | console.log('[info] Get volumn: ' + this.volume) 106 | return this.volume 107 | } 108 | 109 | // Socket update 110 | async updateList (socket) { 111 | console.log('[info] Update list!') 112 | this.dispatcher.updateListSocket(socket, {queue: await this.list.getQueue(), history: await this.list.getHistory()}) 113 | } 114 | 115 | async updatePlaying (socket) { 116 | console.log('[info] Update playing!') 117 | this.dispatcher.updatePlayingSocket(socket, {playing: await this.list.getPlaying()}) 118 | } 119 | 120 | // Global update 121 | async nextSong () { 122 | console.log('[info] Next song!') 123 | var song_data = await this.list.getNextSong() 124 | 125 | if (this.mode == 'service') { this.fakeplayer.play(song_data) } 126 | 127 | this.dispatcher.getSong({playing: await this.list.getPlaying(), queue: await this.list.getQueue(), history: await this.list.getHistory()}) 128 | return song_data 129 | } 130 | 131 | async setSong (id) { 132 | console.log('[info] Set song: ' + id) 133 | await this.list.pushToPlaying(id) 134 | this.dispatcher.getSong({playing: await this.list.getPlaying(), queue: await this.list.getQueue(), history: await this.list.getHistory()}) 135 | } 136 | 137 | async pushQueue (id) { 138 | console.log('[info] Push song to queue: ' + id) 139 | var song_data = await this.list.removeFromHistory(id) 140 | if (song_data) await this.list.addToQueue(song_data) 141 | this.dispatcher.updateList({queue: await this.list.getQueue(), history: await this.list.getHistory()}) 142 | return song_data 143 | } 144 | 145 | // Control 146 | setVolume (val) { 147 | console.log('[info] Set volume: ' + val) 148 | this.volume = val 149 | this.dispatcher.setVolume(this.volume) 150 | } 151 | 152 | pausePlay () { 153 | console.log('[info] Pause/Play') 154 | if (this.mode == 'station') { this.dispatcher.pausePlay() } 155 | else if (this.mode == 'service') { this.fakeplayer.pausePlay() } 156 | } 157 | 158 | async play () { 159 | console.log('[info] Play') 160 | if(!(await this.list.getPlaying())) { 161 | this.nextSong() 162 | } else { 163 | this.dispatcher.play() 164 | if (this.mode == 'service') { this.fakeplayer.resume() } 165 | } 166 | } 167 | 168 | pause () { 169 | console.log('[info] Pause') 170 | this.dispatcher.pause() 171 | if (this.mode == 'service') { this.fakeplayer.pause() } 172 | } 173 | 174 | // Delete 175 | async removeQueue (id) { 176 | console.log('[info] Del queue: ' + id) 177 | var poped = await this.list.removeFromQueue(id) 178 | this.dispatcher.updateList({playing: await this.list.getPlaying(), queue: await this.list.getQueue(), history: await this.list.getHistory()}) 179 | return poped 180 | } 181 | 182 | async removeHistory (id) { 183 | console.log('[info] Del history: ' + id) 184 | var poped = await this.list.removeFromHistory(id) 185 | this.dispatcher.updateList({playing: await this.list.getPlaying(), queue: await this.list.getQueue(), history: await this.list.getHistory()}) 186 | return poped 187 | } 188 | } 189 | 190 | module.exports = Playlist 191 | -------------------------------------------------------------------------------- /lib/discordbot.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js') 2 | 3 | class DiscordBot { 4 | constructor (playlist, config, mode) { 5 | this.playlist = playlist 6 | this.token = config.token 7 | this.prefix = config.prefix 8 | this.commandChannelNames = config.commandChannelNames 9 | this.replyChannelName = config.replyChannelName 10 | this.mode = mode 11 | 12 | // Login to discord 13 | this.discord = new Discord.Client() 14 | 15 | // Register events 16 | this.discord.on('message', this.getMessage()) 17 | this.discord.on('messageReactionAdd', this.reactionController()) 18 | this.discord.on('messageReactionRemove', this.reactionController()) 19 | } 20 | 21 | login () { 22 | this.discord.login(this.token) 23 | } 24 | 25 | getMessage (message) { 26 | var self = this 27 | 28 | return async function (message) { 29 | if (message.author.bot) return 30 | if (!self.commandChannelNames.includes(message.channel.name)) return 31 | if (message.content.indexOf(self.prefix) !== 0) return 32 | 33 | const replyChannel = message.guild.channels.find(channel => channel.name == self.replyChannelName) 34 | 35 | const args = message.content.slice(self.prefix.length).trim().split(/ +/g) 36 | const command = args.shift().toLowerCase() 37 | 38 | switch (command) { 39 | case 'help': 40 | var msg = '[Command list]\n' + 41 | '/playing : Get information of currently playing song. \n' + 42 | '/add [youtube_url] : Add a song.\n' + 43 | '/next : Skip current song.\n' 44 | if (this.mode == 'station') { 45 | msg += '/playpause : Play/Pause current song.\n' + 46 | '/controller : Show controller.\n' 47 | } 48 | replyChannel.send(msg) 49 | break 50 | case 'say': 51 | var msg = args.join(' ') 52 | message.delete().catch(O_o => {}) 53 | replyChannel.send(msg) 54 | break 55 | case 'playing': 56 | var playing = await self.playlist.getPlaying() 57 | if (playing) { 58 | var msg = playing.title + '\n' + playing.url 59 | replyChannel.send(msg) 60 | break 61 | } else { 62 | replyChannel.send('Nothing playing.') 63 | break 64 | } 65 | case 'add': 66 | var url = args[0] 67 | if (url === '') { 68 | replyChannel.send('Nothing to add!') 69 | break 70 | } 71 | var match = url.match(/(youtube.com|youtu.be)\/(watch\?)?(\S+)/) 72 | var playlist_match = url.match(/(youtube.com|youtu.be)\/(playlist\?)(\S+)/) 73 | 74 | if (playlist_match) { 75 | /// / Add playlist 76 | // Parse url params 77 | var params = {} 78 | playlist_match[3].split('&').map(function (d) { 79 | var sp = d.split('=') 80 | params[sp[0]] = sp[1] 81 | }) 82 | var id = params['list'] 83 | // Add list to playlist 84 | replyChannel.send('Loading playlist ...') 85 | await self.playlist.newListByYoutubeId(id).then(function (list) { 86 | if (list !== undefined) { 87 | replyChannel.send('**Adding songs**') 88 | list.forEach(function (song_data) { 89 | var msg = song_data.title + '\n' + 90 | '<' + song_data.url + '>' 91 | replyChannel.send(msg) 92 | }) 93 | replyChannel.send('**' + list.length + '** songs added.') 94 | } 95 | }).catch(function (err) { 96 | console.log('[error] Something bad happened.') 97 | console.log('[error-info]', err) 98 | replyChannel.send('Playlist has been corrupted') 99 | }) 100 | } else if (match) { 101 | /// / Add song 102 | // Parse url parameter 103 | var params = {} 104 | match[3].split('&').map(function (d) { 105 | var sp = d.split('=') 106 | if (sp.length === 2) { params[sp[0]] = sp[1] } else { params['v'] = sp[0] } 107 | }) 108 | var id = params['v'] 109 | // Add new song to playlist 110 | await self.playlist.newSongByYoutubeId(id).then(function (song_data) { 111 | var msg = '**Song added**\n' + 112 | song_data.title + '\n' + 113 | '<' + song_data.url + '>' 114 | replyChannel.send(msg) 115 | }).catch(function (err) { 116 | if (err.code == 1) { 117 | replyChannel.send('The song is already in the list O3O') 118 | } else { 119 | console.log('[error] Something bad happened.') 120 | console.log('[error-info]', err) 121 | replyChannel.send('An alien ate your request!') 122 | } 123 | }) 124 | } else { 125 | replyChannel.send('Invalid Youtube url!') 126 | } 127 | break 128 | case 'next': 129 | await self.playlist.nextSong() 130 | replyChannel.send('Someone wanna skip a song!') 131 | break 132 | case 'playpause': 133 | if (this.mode == 'station') { 134 | await self.playlist.pauseplay() 135 | replyChannel.send('Someone wanna play/pause a song!') 136 | break 137 | } 138 | case 'controller': 139 | if (this.mode == 'station') { 140 | Promise.resolve() 141 | .then(() => message.react('⏯')) 142 | .then(() => message.react('⏭')) 143 | .then(() => message.react('➖')) 144 | .then(() => message.react('➕')) 145 | break 146 | } 147 | } 148 | } 149 | } 150 | 151 | reactionController (messageReaction, user) { 152 | var self = this 153 | 154 | return async function (messageReaction, user) { 155 | var message = messageReaction.message 156 | if (user.id === self.discord.user.id) return 157 | if (message.channel.name !== self.replyChannelName) return 158 | var emoji = messageReaction.emoji 159 | var volumeTic = 3 160 | switch (emoji.name) { 161 | case '⏭': 162 | await self.playlist.nextSong() 163 | break 164 | case '⏯': 165 | await self.playlist.pausePlay() 166 | break 167 | case '➕': 168 | var volume = Number(await self.playlist.getVolume()) 169 | if (volume >= 100) console.log('[warning] Volume bound') 170 | else { 171 | console.log('[info] Volume up') 172 | volume += volumeTic 173 | await self.playlist.setVolume(volume) 174 | } 175 | break 176 | case '➖': 177 | var volume = Number(await self.playlist.getVolume()) 178 | if (volume <= 0) console.log('[warning] Volume bound') 179 | else { 180 | console.log('[info] Volume down') 181 | volume -= volumeTic 182 | await self.playlist.setVolume(volume) 183 | } 184 | break 185 | } 186 | } 187 | } 188 | } 189 | 190 | module.exports = DiscordBot 191 | -------------------------------------------------------------------------------- /lib/listmodel.js: -------------------------------------------------------------------------------- 1 | const Firestore = require('@google-cloud/firestore'); 2 | const FieldValue = require('firebase-admin').firestore.FieldValue; 3 | 4 | function debug(...err) { 5 | if (process.env.DEBUG) { 6 | console.log(...err) 7 | } 8 | } 9 | 10 | const japari = { 11 | id: 'xkMdLcB_vNU', 12 | title: 'TVアニメ『けものフレンズ』主題歌「ようこそジャパリパークへ / どうぶつビスケッツ×PPP」', 13 | url: 'https://youtu.be/xkMdLcB_vNU', 14 | duration: 'PT1M33S' 15 | } 16 | 17 | class FirestoreList { 18 | constructor () { 19 | this.db = new Firestore({ 20 | projectId: process.env.FIRESTORE_PROJECT_ID, 21 | credentials: JSON.parse(process.env.FIRESTORE_CREDENTIALS) 22 | }) 23 | this.queueRef = this.db.collection('queue') 24 | this.historyRef = this.db.collection('history') 25 | this.playingRef = this.db.collection('playing').doc('playing') 26 | } 27 | 28 | async getPlaying () { 29 | var doc = await this.playingRef.get().catch((err) => { 30 | console.log("[info] Error getting document:", err) 31 | }) 32 | if (doc.exists) { 33 | return doc.data() 34 | } 35 | return 36 | } 37 | 38 | async setPlaying (song_data) { 39 | await this.playingRef.set(song_data).catch((err) => { 40 | console.log("[info] Error getting document:", err) 41 | }) 42 | } 43 | 44 | async pushToPlaying (id) { 45 | var playing = await this.getPlaying() 46 | if (playing) await this.addToHistory(playing) 47 | 48 | var song_data = await this.removeFromQueue(id) 49 | if (song_data) await this.setPlaying(song_data) 50 | } 51 | 52 | async addToQueue (song_data) { 53 | await this.queueRef.doc(song_data.id).set({ ...song_data, createdAt: FieldValue.serverTimestamp()}).catch((err) => { 54 | console.log("[info] Error getting document:", err) 55 | }) 56 | } 57 | 58 | async removeFromQueue (id) { 59 | var poped 60 | var doc = await this.queueRef.doc(id).get().catch((err) => { 61 | console.log("[info] Error getting document:", err) 62 | }) 63 | 64 | if (doc.exists) { 65 | poped = doc.data() 66 | await this.queueRef.doc(id).delete().catch((err) => { 67 | console.log("[info] Error deleting document:", err) 68 | }) 69 | } 70 | return poped 71 | } 72 | 73 | async getQueue () { 74 | var queue = [] 75 | var snapshot = await this.queueRef.orderBy('createdAt').get().catch((err) => { 76 | console.log("[info] Error getting document:", err) 77 | }) 78 | 79 | if (!snapshot.empty) { 80 | snapshot.forEach((doc) => { 81 | queue.push(doc.data()) 82 | }) 83 | } 84 | return queue 85 | } 86 | 87 | async searchQueue (id) { 88 | var song_data 89 | var doc = await this.queueRef.doc(id).get().catch((err) => { 90 | console.log("[info] Error getting document:", err) 91 | }) 92 | 93 | if (doc.exists) { 94 | song_data = doc.data() 95 | } 96 | return song_data 97 | } 98 | 99 | async addToHistory (song_data) { 100 | await this.historyRef.doc(song_data.id).set(song_data).catch((err) => { 101 | console.log("[info] Error getting document:", err) 102 | }) 103 | } 104 | 105 | async removeFromHistory (id) { 106 | var poped 107 | var doc = await this.historyRef.doc(id).get().catch((err) => { 108 | console.log("[info] Error deleting document:", err) 109 | }) 110 | 111 | if (doc.exists) { 112 | poped = doc.data() 113 | await this.historyRef.doc(id).delete().catch((err) => { 114 | console.log("[info] Error deleting document:", err) 115 | }) 116 | } 117 | return poped 118 | } 119 | 120 | async getHistory () { 121 | var history = [] 122 | var snapshot = await this.historyRef.get().catch((err) => { 123 | console.log("[info] Error getting document:", err) 124 | }) 125 | 126 | if (!snapshot.empty) { 127 | snapshot.forEach((doc) => { 128 | history.push(doc.data()) 129 | }) 130 | } 131 | return history 132 | } 133 | 134 | async searchHistory (id) { 135 | var song_data 136 | var doc = await this.historyRef.doc(id).get().catch((err) => { 137 | console.log("[info] Error getting document:", err) 138 | }) 139 | 140 | if (doc.exists) { 141 | song_data = doc.data() 142 | } 143 | return song_data 144 | } 145 | 146 | async getNextSong () { 147 | var song_data 148 | var queue = await this.getQueue() 149 | var history = await this.getHistory() 150 | var playing = await this.getPlaying() 151 | 152 | if (queue.length) { 153 | song_data = queue[0] 154 | await this.removeFromQueue(song_data.id) 155 | } else if (history.length) { 156 | song_data = history[Math.floor(Math.random() * history.length)] 157 | await this.removeFromHistory(song_data.id) 158 | } else if (playing) { 159 | song_data = playing 160 | } else { 161 | console.log('[info] No thing in the list! Play japari park!') 162 | song_data = japari 163 | } 164 | 165 | if (playing && playing !== song_data) { await this.addToHistory(playing) } 166 | await this.setPlaying(song_data) 167 | 168 | return song_data 169 | } 170 | } 171 | 172 | class LocalList { 173 | constructor () { 174 | this.queue = [] 175 | this.history = [] 176 | this.playing 177 | } 178 | 179 | getPlaying () { 180 | return this.playing 181 | } 182 | 183 | setPlaying (song_data) { 184 | this.playing = song_data 185 | } 186 | 187 | pushToPlaying (id) { 188 | if (this.playing) this.addToHistory(this.playing) 189 | var song_data = this.removeFromQueue(id) 190 | 191 | this.setPlaying(song_data) 192 | } 193 | 194 | addToQueue (song_data) { 195 | this.queue.push(song_data) 196 | } 197 | 198 | removeFromQueue (id) { 199 | var poped 200 | this.queue = this.queue.filter(function (d) { 201 | if (d.id !== id) return true 202 | else { 203 | poped = d 204 | return false 205 | } 206 | }) 207 | return poped 208 | } 209 | 210 | getQueue () { 211 | return this.queue 212 | } 213 | 214 | searchQueue (id) { 215 | return this.queue.find(function (d) { return d.id === id }) 216 | } 217 | 218 | addToHistory (song_data) { 219 | this.history.push(song_data) 220 | } 221 | 222 | removeFromHistory (id) { 223 | var poped 224 | this.history = this.history.filter(function (d) { 225 | if (d.id !== id) return true 226 | else { 227 | poped = d 228 | return false 229 | } 230 | }) 231 | return poped 232 | } 233 | 234 | getHistory () { 235 | return this.history 236 | } 237 | 238 | searchHistory (id) { 239 | return this.history.find(function (d) { return d.id === id }) 240 | } 241 | 242 | getNextSong () { 243 | var song_data = null 244 | 245 | if (this.queue.length) { 246 | song_data = this.queue.shift() 247 | } else if (this.history.length) { 248 | song_data = this.history.splice(Math.floor(Math.random() * this.history.length), 1)[0] 249 | } else if (this.playing) { 250 | song_data = this.playing 251 | } else { 252 | console.log('[info] No thing in the list! Play japari park!') 253 | song_data = japari 254 | } 255 | 256 | if (this.playing && this.playing !== song_data) { this.addToHistory(this.playing) } 257 | this.setPlaying(song_data) 258 | 259 | return song_data 260 | } 261 | 262 | } 263 | 264 | exports.FirestoreList = FirestoreList 265 | exports.LocalList = LocalList 266 | -------------------------------------------------------------------------------- /public/jsVideoUrlParser.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.urlParser=t()}(this,function(){"use strict";function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}var t=function(t,i){if("object"!==e(t))return"";var r="",a=0,s=Object.keys(t);if(0===s.length)return"";for(s.sort(),i||(r+="?"+s[0]+"="+t[s[0]],a+=1);a