diff --git a/bin/build-player.js b/bin/build-player.js index 194ef71c..281e926a 100755 --- a/bin/build-player.js +++ b/bin/build-player.js @@ -8,6 +8,7 @@ var order = [ 'base.coffee', 'dailymotion.coffee', + 'niconico.coffee', 'peertube.coffee', 'soundcloud.coffee', 'twitch.coffee', diff --git a/player/niconico.coffee b/player/niconico.coffee new file mode 100644 index 00000000..1236d533 --- /dev/null +++ b/player/niconico.coffee @@ -0,0 +1,66 @@ +window.NicoPlayer = class NicoPlayer extends Player + constructor: (data) -> + if not (this instanceof NicoPlayer) + return new NicoPlayer(data) + + @load(data) + + load: (data) -> + @setMediaProperties(data) + + waitUntilDefined(window, 'NicovideoEmbed', => + @nico = new NicovideoEmbed({ playerId: 'ytapiplayer', videoId: data.id }) + removeOld($(@nico.iframe)) + + @nico.on('ended', => + if CLIENT.leader + socket.emit('playNext') + ) + + @nico.on('pause', => + @paused = true + if CLIENT.leader + sendVideoUpdate() + ) + + @nico.on('play', => + @paused = false + if CLIENT.leader + sendVideoUpdate() + ) + + @nico.on('ready', => + @play() + @setVolume(VOLUME) + ) + ) + + play: -> + @paused = false + if @nico + @nico.play() + + pause: -> + @paused = true + if @nico + @nico.pause() + + seekTo: (time) -> + if @nico + @nico.seek(time * 1000) + + setVolume: (volume) -> + if @nico + @nico.volumeChange(volume) + + getTime: (cb) -> + if @nico + cb(parseFloat(@nico.state.currentTime / 1000)) + else + cb(0) + + getVolume: (cb) -> + if @nico + cb(parseFloat(@nico.state.volume)) + else + cb(VOLUME) diff --git a/player/update.coffee b/player/update.coffee index 5b4a3dc2..a25b6ad7 100644 --- a/player/update.coffee +++ b/player/update.coffee @@ -18,6 +18,7 @@ TYPE_MAP = bc: IframeChild bn: IframeChild od: OdyseePlayer + nv: NicoPlayer window.loadMediaPlayer = (data) -> try @@ -109,7 +110,8 @@ window.removeOld = (replace) -> $('#soundcloud-volume-holder').remove() replace ?= $('
').addClass('embed-responsive-item') old = $('#ytapiplayer') + old.attr('id', 'ytapiplayer-old') + replace.attr('id', 'ytapiplayer') replace.insertBefore(old) old.remove() - replace.attr('id', 'ytapiplayer') return replace diff --git a/src/get-info.js b/src/get-info.js index 2739a447..ad741281 100644 --- a/src/get-info.js +++ b/src/get-info.js @@ -10,6 +10,7 @@ const Odysee = require("@cytube/mediaquery/lib/provider/odysee"); const PeerTube = require("@cytube/mediaquery/lib/provider/peertube"); const BitChute = require("@cytube/mediaquery/lib/provider/bitchute"); const BandCamp = require("@cytube/mediaquery/lib/provider/bandcamp"); +const Nicovideo = require("@cytube/mediaquery/lib/provider/nicovideo"); const Streamable = require("@cytube/mediaquery/lib/provider/streamable"); const TwitchVOD = require("@cytube/mediaquery/lib/provider/twitch-vod"); const TwitchClip = require("@cytube/mediaquery/lib/provider/twitch-clip"); @@ -416,6 +417,16 @@ var Getters = { }).catch(error => { callback(error.message || error); }); + }, + + /* Niconico */ + nv: function (id, callback) { + Nicovideo.lookup(id).then(video => { + video = new Media(video.id, video.title, video.duration, "nv", video.meta); + callback(null, video); + }).catch(error => { + callback(error.message || error); + }); } }; diff --git a/src/utilities.js b/src/utilities.js index c5ed5264..92fa8cdc 100644 --- a/src/utilities.js +++ b/src/utilities.js @@ -216,6 +216,8 @@ case "od": const [user,video] = id.split(';'); return `https://odysee.com/@${user}/${video}`; + case "nv": + return `https://www.nicovideo.jp/watch/${id}`; default: return ""; } diff --git a/templates/channel.pug b/templates/channel.pug index 8b52f3fc..a2c04795 100644 --- a/templates/channel.pug +++ b/templates/channel.pug @@ -252,6 +252,7 @@ html(lang="en") script(defer, src="https://www.youtube.com/iframe_api") script(defer, src="https://api.dmcdn.net/all.js") script(defer, src="https://player.vimeo.com/api/player.js") + script(defer, src="/js/niconico.js") script(defer, src="/js/peertube.js") script(defer, src="/js/sc.js") script(defer, src="/js/video.js") diff --git a/www/js/niconico.js b/www/js/niconico.js new file mode 100644 index 00000000..1cb2d572 --- /dev/null +++ b/www/js/niconico.js @@ -0,0 +1,234 @@ +/* + * Niconico iframe embed api + * Written by Xaekai + * Copyright (c) 2022 Radiant Feather; Licensed AGPLv3 + * + */ +class NicovideoEmbed { + static origin = 'https://embed.nicovideo.jp'; + static methods = [ + 'loadComplete', + 'mute', + 'pause', + 'play', + 'seek', + 'volumeChange', + ]; + static frames = [ + 'error', + 'loadComplete', + 'playerMetadataChange', + 'playerStatusChange', + 'seekStatusChange', + 'statusChange', + //'player-error:video:play', + //'player-error:video:seek', + ]; + static events = [ + 'ended', + 'error', + 'muted', + 'pause', + 'play', + 'progress', + 'ready', + 'timeupdate', + 'unmuted', + 'volumechange', + ]; + + constructor(options) { + this.handlers = Object.fromEntries(NicovideoEmbed.frames.map(key => [key,[]])); + this.listeners = Object.fromEntries(NicovideoEmbed.events.map(key => [key,[]])); + this.state = ({ + ready: false, + playerStatus: 1, + currentTime: 0.0, // ms + muted: false, + volume: 0.99, + maximumBuffered: 0, + }); + + this.setupHandlers(); + this.scaffold(options); + } + + scaffold({ iframe = null, playerId = 1, videoId = null }){ + this.playerId = playerId; + this.messageListener(); + if(iframe === null){ + if(videoId === null){ + throw new Error('You must provide either an existing iframe or a videoId'); + } + const iframe = this.iframe = document.createElement('iframe'); + + const source = new URL(`${NicovideoEmbed.origin}/watch/${videoId}`); + source.search = new URLSearchParams({ + jsapi: 1, + playerId + }); + iframe.setAttribute('src', source); + iframe.setAttribute('id', playerId); + iframe.setAttribute('allow', 'autoplay; fullscreen'); + iframe.addEventListener('load', ()=>{ + this.observe(); + }) + } else { + this.iframe = iframe; + this.observe(); + } + } + + setupHandlers() { + this.handlers.loadComplete.push((data) => { + this.emit('ready'); + this.state.ready = true; + Object.assign(this, data); + }); + this.handlers.error.push((data) => { + this.emit('error', data); + }); + this.handlers.playerStatusChange.push((data) => { + let event; + switch (data.playerStatus) { + case 1: /* Buffering */ return; + case 2: event = 'play'; break; + case 3: event = 'pause'; break; + case 4: event = 'ended'; break; + } + this.state.playerStatus = data.playerStatus; + this.emit(event); + }); + this.handlers.playerMetadataChange.push(({ currentTime, volume, muted, maximumBuffered }) => { + const self = this.state; + + if (currentTime !== self.currentTime) { + self.currentTime = currentTime; + this.emit('timeupdate', currentTime); + } + + if (muted !== self.muted) { + self.muted = muted; + this.emit(muted ? 'muted' : 'unmuted'); + } + + if (volume !== self.volume) { + self.volume = volume; + this.emit('volumechange', volume); + } + + if (maximumBuffered !== self.maximumBuffered) { + self.maximumBuffered = maximumBuffered; + this.emit('progress', maximumBuffered); + } + }); + this.handlers.seekStatusChange.push((data) => { + // + }); + this.handlers.statusChange.push((data) => { + // + }); + } + + messageListener() { + const dispatcher = (event) => { + if (event.origin === NicovideoEmbed.origin && event.data.playerId === this.playerId) { + const { data } = event.data; + this.dispatch(event.data.eventName, data); + } + } + window.addEventListener('message', dispatcher); + + /* Clean up */ + this.observer = new MutationObserver((alterations) => { + alterations.forEach((change) => { + change.removedNodes.forEach((deletion) => { + if(deletion.nodeName === 'IFRAME') { + window.removeEventListener('message', dispatcher) + this.observer.disconnect(); + } + }); + }); + }); + } + + observe(){ + this.state.receptive = true; + this.observer.observe(this.iframe.parentElement, { subtree: true, childList: true }); + } + + dispatch(frame, data = null){ + if(!NicovideoEmbed.frames.includes(frame)){ + console.error(JSON.stringify(data, undefined, 4)); + throw new Error(`NicovideoEmbed ${frame}`); + } + [...this.handlers[frame]].forEach(handler => { + handler.call(this, data); + }); + } + + emit(event, data = null){ + [...this.listeners[event]].forEach(listener => { + listener.call(this, data); + }); + if(event === 'ready'){ + this.listeners.ready.length = 0; + } + } + + postMessage(request) { + if(!this.state.receptive){ + setTimeout(() => { this.postMessage(request) }, 1000 / 24); + return; + } + const message = Object.assign({ + sourceConnectorType: 1, + playerId: this.playerId + }, request); + + this.iframe.contentWindow.postMessage(message, NicovideoEmbed.origin); + } + + on(event, listener){ + if(!NicovideoEmbed.events.includes(event)){ + throw new Error('Unrecognized event name'); + } + if(event === 'ready'){ + if(this.state.ready){ + listener(); + return this; + } else { + setTimeout(() => { this.loadComplete() }, 1000 / 60); + } + } + this.listeners[event].push(listener); + return this; + } + + mute(state){ + this.postMessage({ eventName: 'pause', data: { mute: state } }); + } + + pause(){ + this.postMessage({ eventName: 'pause' }); + } + + play(){ + this.postMessage({ eventName: 'play' }); + } + + loadComplete(){ + this.postMessage({ eventName: 'loadComplete' }); + } + + seek(ms){ + this.postMessage({ eventName: 'seek', data: { time: ms } }); + } + + volumeChange(volume){ + this.postMessage({ eventName: 'pause', data: { volume } }); + } + +} + +window.NicovideoEmbed = NicovideoEmbed; diff --git a/www/js/util.js b/www/js/util.js index aeef49e7..bdc3bfbf 100644 --- a/www/js/util.js +++ b/www/js/util.js @@ -67,6 +67,8 @@ function formatURL(data) { case "od": const [user,video] = data.id.split(';'); return `https://odysee.com/@${user}/${video}`; + case "nv": + return `https://www.nicovideo.jp/watch/${data.id}`; default: return "#"; } @@ -1406,6 +1408,11 @@ function parseMediaLink(url) { return { type: 'bc', id: `${data.pathname.slice(7).split('/').shift()}` } } + case 'nicovideo.jp': + if(data.pathname.startsWith('/watch/')){ + return { type: 'nv', id: `${data.pathname.slice(7).split('/').shift()}` } + } + case 'odysee.com': const format = new RegExp('/@(?[^:]+)(?::\\w)?/(?