diff --git a/web/css/layout-other.css b/web/css/layout-other.css index f8b0caa4..8984b3c6 100644 --- a/web/css/layout-other.css +++ b/web/css/layout-other.css @@ -1042,3 +1042,16 @@ button.ranked-poll__submit-button { .berryemote[title$=" from /r/discordserver"] { background-repeat: no-repeat; } + +/* Maximize the players */ +#ytapiplayer, +#ytapiplayer > iframe:not(#scPlayer), +#ytapiplayer #vjs_player { + width: 100% !important; + height: 100% !important; + position: relative; +} + +#scVolumeSlider .ui-slider-range { + background: #C600AD; +} diff --git a/web/headers.php b/web/headers.php index 4b35541d..994264d4 100644 --- a/web/headers.php +++ b/web/headers.php @@ -97,30 +97,29 @@ - - - + + + + + + - + - - - - - + diff --git a/web/js/callbacks.js b/web/js/callbacks.js index 3808f1ec..3dc8ebc0 100644 --- a/web/js/callbacks.js +++ b/web/js/callbacks.js @@ -1,18 +1,9 @@ -function vimeo_player_loaded(id) { - //id is automatically passed - setVal("VIMEOPLAYERLOADED", true); -} function onYouTubeIframeAPIReady() { setVal("YTAPREADY", true); } -function onDailymotionPlayerReady() { - setVal("DMPLAYERREADY", true); -} function videoEnded() { - // Playlist progression is controlled by the server now, but if someone has berry, it should still ask for next. - // TODO: Should we really be doing this? Could this be causing the berry delay bug? - if (LEADER) { - //socket.emit("playNext"); + if (controlsVideo()) { + forceStateChange(); } } function videoSeeked(time) { @@ -23,7 +14,6 @@ function videoSeeked(time) { } } function videoPlaying() { - //PLAYING_VID = getLiteralPlayingVidID(); if (controlsVideo()) { videoGetTime(function (time) { SEEK_TO = time; @@ -49,10 +39,6 @@ function videoPaused() { socket.on("createPlayer", function (data) { console.log('createPlayer', data); - if (!INIT_TIME) { - INIT_TIME = data.time; - } - const isNew = ACTIVE.videoid != data.video.videoid; unfuckPlaylist(); @@ -85,55 +71,49 @@ socket.on("recvPlaylist", function (data) { }); }); socket.on("hbVideoDetail", function (data) { + if (controlsVideo()) { + return; + } - //if(videoGetState() == -1 || videoGetState() == 3 ) return; - if (controlsVideo()) return; - dbg('hbVideoDetail data'); - dbg(data); - //Check if video ID is the same as ours. - if (ACTIVE.videoid != data.video.videoid) { - // Ask server for a videochange/update. - dbg("SHIT: " + ACTIVE.videoid + " != " + data.video.videoid); + dbg('hbVideoDetail', data); + + //not matching, refresh player + if (ACTIVE.videoid !== data.video.videoid) { + dbg(`ID mismatch: ${ACTIVE.videoid} !== ${data.video.videoid}`); socket.emit("refreshMyVideo"); + return; } - /*else if(ACTIVE.videoid == data.video.videoid && videoGetState() == 0) // We've already finished. - { - // Ho hum. - dbg("SHIT: ho-hum"); - }*/ - else if (getStorage('syncAtAll') == 1) { - dbg("SYNCH_AT_ALL"); - videoGetTime(function (time) { - if (Math.abs(time - data.time) > getStorage('syncAccuracy')) { - dbg("SHIT: " + (time - data.time) + " > " + getStorage('syncAccuracy')); - videoSeekTo(data.time); - } - if (videoGetState() == 2) { - dbg("SHIT: " + videoGetState() + " > 2"); - videoSeekTo(data.time); - } + //do not sync + if (getStorage('syncAtAll') === 0 || !PLAYER) { + return; + } - if (data.state == 1 && videoGetState() != 1) { - dbg("SHIT: " + data.state + " == 1 && " + videoGetState() + " != 1"); - videoPlay(); - } + const accuracy = getStorage('syncAccuracy'); + const flags = { + play: false, + seek: [false, -1] + }; - if (data.state == 2 && videoGetState() != 2) { - dbg("SHIT: " + data.state + " == 2 && " + videoGetState() + " != 2"); - videoPause(); - videoSeekTo(data.time); - } + videoGetTime((time) => { + const videoState = videoGetState(); - if (data.state == 3 && videoGetState() != 2) // Intentionally 2 - { - dbg("SHIT: " + data.state + " == 3 && " + videoGetState() + " != 2"); - videoPause(); - videoSeekTo(data.time); - } - }); - } - dbg("hbVideoDetail Complete"); + flags.seek = Math.abs(time - data.time) > accuracy ? [true, data.time]: flags.seek; + flags.play = data.state === 1 && videoState !== 1; + + if (data.state !== videoState && !flags.play) { + dbg(`Player states don't match: ${data.state} !== ${videoState}`); + flags.seek = [true, data.time]; + } + + if (flags.play) { + videoPlay(); + } + + if (flags.seek[0] && time !== -1) { + videoSeekTo(flags.seek[1]); + } + }); }); socket.on("sortPlaylist", function (data) { unfuckPlaylist(); diff --git a/web/js/functions.js b/web/js/functions.js index bff8fac6..ed194d54 100644 --- a/web/js/functions.js +++ b/web/js/functions.js @@ -1,8 +1,6 @@ let lastPollCountdown = null; -let selectedQuality = null; const integerRegex = /^\d+$/; -const QUALITY_LOCAL_STORAGE_KEY = "quality"; class Countdown { constructor(totalTimeInSeconds, startedAt, handlers) { @@ -868,19 +866,7 @@ function recalcStats() { numberMan.text(PLAYLIST.length + " Videos"); } -// TODO: I don't think this us used anymore? -function hbVideoDetail() { - if (controlsVideo()) { - if (videoGetState() == 0 || videoGetState() == 1 || videoGetState() == 2) { - videoGetTime(function (t) { - socket.emit("hbVideoDetail", { - time: t, - state: videoGetState() - }); - }); - } - } -} + function setToggleable(name, state, label) { var opt = $(".tgl-" + name); if (typeof label == "undefined") { @@ -909,11 +895,10 @@ function forceStateChange() { var s = videoGetState(); if (controlsVideo()) { if (LAST_EMIT_STATE != s) { - if (s == 1 || s == 2) { - socket.emit("forceStateChange", { - state: s - }); - } + socket.emit("forceStateChange", { + state: s + }); + LAST_EMIT_STATE = s; } } @@ -2170,25 +2155,17 @@ function videoPlayNext() { } } function videoGetTime(callback) { - if (PLAYER.getTime) { - PLAYER.getTime(callback); - } + PLAYER.getTime(callback); } function videoGetState() { - if (PLAYER.getVideoState) { - return PLAYER.getVideoState(); - } + return PLAYER.getVideoState(); } function videoSeekTo(pos) { console.log("Got seek to", secToTime(pos)); - if (PLAYER.seek) { - PLAYER.seek(pos); - } + PLAYER.seek(pos); } function videoPlay() { - if (PLAYER.play) { - PLAYER.play(); - } + PLAYER.play(); } function videoLoadAtTime(vidObj, time) { const { @@ -2199,26 +2176,27 @@ function videoLoadAtTime(vidObj, time) { //instead of attempt to acquire from players, get from volume manager const volume = window.volume.get(ptype); - const change = VIDEO_TYPE != ptype || !PLAYERS[ptype].playVideo; + const change = VIDEO_TYPE !== ptype; if (change) { //we need to stop the volume grabbing before removing the player window.volume.stop(); - removeCurrentPlayer(); - PLAYER = PLAYERS[ptype]; - VIDEO_TYPE = ptype; + //destroy current and get new one + [PLAYER, VIDEO_TYPE] = Players.switch(VIDEO_TYPE, ptype); + + //load the actual video PLAYER.loadPlayer(id, time, volume, length, vidObj.meta); + //listen again window.volume.listen(PLAYER, ptype); } else { - PLAYER.playVideo(id, time, volume); + PLAYER.resetRetries(); + PLAYER.playVideo(id, time, volume, length, vidObj.meta); } } function videoPause() { - if (PLAYER.pause) { - PLAYER.pause(); - } + PLAYER.pause(); } /* Utilities */ function parseVideoURL(url, callback) { @@ -2742,32 +2720,3 @@ function secondsToHuman(seconds) { return `${seconds} second${seconds != 1 ? "s" : ""}`; } - -function getUserQualityPreference() { - if (selectedQuality === null) { - selectedQuality = getStorageInteger(QUALITY_LOCAL_STORAGE_KEY, 1080); - } - - return selectedQuality; -} - -function setUserQualityPreference(value) { - if (typeof (value) !== "number") { - return; - } - - selectedQuality = value; - setStorageInteger(QUALITY_LOCAL_STORAGE_KEY, value); -} - -function pickSourceAtQuality(sources, quality) { - // make this smarter sometime? dunno - - for (const source of sources) { - if (source.quality === quality) { - return source; - } - } - - return sources.length > 0 ? sources[0] : null; -} diff --git a/web/js/init.js b/web/js/init.js index 0702a092..98eb421e 100644 --- a/web/js/init.js +++ b/web/js/init.js @@ -153,14 +153,9 @@ var IGNORELIST = []; var CONNECTED = 0; var PLAYLIST = new LinkedList.Circular(); var ACTIVE = new Video(); -var PLAYING_VID; -var HB_DELAY = 5000; -var leaderHeartbeat = false; var PLAYLIST_DRAGFROM = 0; var PLAYLIST_DRAGTO = 0; var PLAYLIST_DRAGSANITY = ''; -var LEGACY_PLAYER = false; -var INIT_TIME = 0; var SEEK_FROM = 0; var SEEK_TO = 0; var HISTORY = []; @@ -186,7 +181,11 @@ var BANLIST = false; var PLUGINS = []; var NAMEFLAUNT = false; var VOLUME = false; + +//the only thing this does is prevent maltweaks from breaking \\fsnotmad +//Note: Deprecated and legacy, use Players instead var PLAYERS = {}; + var IGNORE_GHOST_MESSAGES = false; var ADMIN_LOG = []; var HIGHLIGHT_LIST = []; diff --git a/web/js/modules/main.js b/web/js/modules/main.js index 3594125e..4b370ce7 100644 --- a/web/js/modules/main.js +++ b/web/js/modules/main.js @@ -1,6 +1,7 @@ import { RankedPoll } from "./ranked-poll.js"; import { loadWorker } from "./lib.js"; import { VolumeManager } from "./volume.js"; +import { Players } from "./player.js"; // header countdown loadWorker(window.WORKER_URLS.countdown).addEventListener("message", ({ data }) => { @@ -52,6 +53,7 @@ window.rankedPolls = { activePoll = null; }, }; +window.Players = new Players(); window.isModuleLoaded = true; diff --git a/web/js/modules/player.js b/web/js/modules/player.js new file mode 100644 index 00000000..1f1cb678 --- /dev/null +++ b/web/js/modules/player.js @@ -0,0 +1,64 @@ +import { Dailymotion } from "./players/dailymotion.js"; +import { Raw } from "./players/raw.js"; +import { Soundcloud } from "./players/soundcloud.js"; +import { Twitch } from "./players/twitch.js"; +import { Twitchclip } from "./players/twitchclip.js"; +import { Vimeo } from "./players/vimeo.js"; +import { Youtube } from "./players/youtube.js"; + +export class Players { + constructor() { + this.Youtube = new Youtube(); + this.Vimeo = new Vimeo(); + this.Dailymotion = new Dailymotion(); + this.Soundcloud = new Soundcloud(); + this.Twitch = new Twitch(); + this.Twitchclip = new Twitchclip(); + this.Raw = new Raw(); + + this.mappings = new Map([ + ['yt', this.Youtube], + ['vimeo', this.Vimeo], + ['dm', this.Dailymotion], + ['twitch', this.Twitch], + ['twitchclip', this.Twitchclip], + + ['soundcloud', this.Soundcloud], + ['osmf', this.Raw], + ['manifest', this.Raw], + ['file', this.Raw], + ['dash', this.Raw], + ['hls', this.Raw], + ]); + } + + hasPlayer(videotype) { + return this.mappings.has(videotype); + } + + playerFromVideoType(video) { + return this.mappings.get(video); + } + + switch(from, to) { + const [now, next] = [from, to].map(kind => this.playerFromVideoType(kind)); + + if (now) { + now.destroy(); + now.resetRetries(); + } + + return [next, to]; + } + + register(name, player) { + this.mappings.set( + name, + player + ); + } + + unregister(name) { + this.mappings.delete(name); + } +} \ No newline at end of file diff --git a/web/js/modules/players/base.js b/web/js/modules/players/base.js new file mode 100644 index 00000000..b740e089 --- /dev/null +++ b/web/js/modules/players/base.js @@ -0,0 +1,153 @@ +import { Errors } from "./errors.js"; + +export const Event = { + Error: -1, + Initialise: 0, + Load: 1, + Play: 2, + End: 3, + Pause: 4, + Seek: 5, + Volume: 6, + Ready: 7, + API: 8, + Quality: 9, + Buffer: 10, +}; + +export const Status = { + UNREADY: 0, + READY: 1, + ERROR: 2, +}; + +//these are based on YT players states, assuming server uses similar +export const State = { + ENDED: 0, + PLAYING: 1, + PAUSED: 2, + BUFFER: 3, +}; + +//Maybe make this into a setting? +const MAX_REFRESH_RETRIES = 10; + +export class Base { + constructor() { + this.height = window.videoHeight; //player height + this.width = window.videoWidth; //player width + + this.retries = 0; //number of attempts when error occurred + this.video = {}; //keep the video information + + //player status and state + this.status = Status.UNREADY; + } + + //this done due to legacy player removal (+ maltweaks) + //the #ytapiplayer was deleted and then recreated, therefore + //needing a dynamic grabbing of the frame + frame() { + return document.querySelector('#ytapiplayer'); + } + + error(error) { + if (window.DEBUG) { + console.error( + `Player ${window.VIDEO_TYPE} errored:`, + error + ); + } + + //something is more broke, stop trying and fix + if (this.retries >= MAX_REFRESH_RETRIES || error === Errors.PLAYER_UNPLAYABLE_VIDEO) { + return; + } else { + if (window.DEBUG) { + console.warn( + 'Error is probably recoverable, attempting to refresh player' + ); + } + + this.destroy(); + this.loadPlayer( + this.video.id, + this.video.timestamp, + window.volume.get(window.VIDEO_TYPE), + this.video.length, + this.video.meta, + ); + } + + this.retries += 1; + } + + event(event, data) { + switch (event) { + case Event.End: window.videoEnded(); break; + case Event.Pause: window.videoPaused(); break; + case Event.Seek: window.videoSeeked(data.time); break; + case Event.Play: window.videoPlaying(); break; + case Event.Volume: window.volume.set(data.volume); break; + + //incase the player has error event mixed + case Event.Error: this.error(data.error, data.player); break; + + //there are more events that are not handled atm, but could be in the future + //only bugger in debug mode + default: { + if (window.DEBUG) { + console.info(`Player ${window.VIDEO_TYPE} gave an unhandled event ${event}`); + } + } + } + } + + resetRetries() { + this.retries = 0; + } + + setReady() {} + + ready(_cb) {} + + loadPlayer(_id, _timestamp, _volume, _length, _meta) {} + + playVideo(_id, _timestamp) {} + + //we have autoplay enabled everywhere + //yet we actively don't let it autoplay, smh + delay(timestamp) { + if (timestamp < 0) { + setTimeout(() => this.play(), timestamp * -1000); + } else { + //this -> the player, not base class + this.play(); + this.seek(timestamp); + } + } + + pause() {} + + play() {} + + seek(_to) {} + + getTime(cb) { + cb(-1); + } + + getVolume(cb) { + cb(-1); + } + + getVideoState() { + return State.PLAYING; + } + + isReady() { + return this.status === Status.READY; + } + + destroy() {} +} \ No newline at end of file diff --git a/web/js/modules/players/dailymotion.js b/web/js/modules/players/dailymotion.js new file mode 100644 index 00000000..3cd8331e --- /dev/null +++ b/web/js/modules/players/dailymotion.js @@ -0,0 +1,141 @@ +import { Base, Event, State, Status } from "./base.js"; +import { Errors } from "./errors.js"; + +export class Dailymotion extends Base { + constructor() { + super(); + + this.player = null; + this.events = new Map([ + ['seeked', Event.Seek], + ['pause', Event.Pause], + ['play', Event.Play], + ['video_start', Event.Play], + ['video_end', Event.End], + + ['volumechange', Event.Volume], + ['playback_ready', Event.Ready], + ['error', Event.Error], + ]); + + this.options = { + 'queue-autoplay-next': false, + 'queue-enable': false, + 'sharing-enable': false, + 'ui-highlight': 'c600ad', + 'ui-logo': false, + 'ui-start-screen-info': false + }; + + this.errors = new Map([ + ['DM001', Errors.PLAYER_INVALID_ID], + ['DM002', Errors.PLAYER_UNPLAYABLE_VIDEO], + ['DM004', Errors.PLAYER_UNPLAYABLE_VIDEO], + ['DM005', Errors.PLAYER_UNPLAYABLE_VIDEO], + ['DM007', Errors.PLAYER_UNPLAYABLE_VIDEO], + ['DM014', Errors.PLAYER_UNPLAYABLE_VIDEO], + ['DM016', Errors.PLAYER_UNPLAYABLE_VIDEO], + ]); + + this.state = State.PLAYING; + } + + ready(cb) { + if (this.status === Status.READY) { + cb(); + } + } + + //dailymotion events have no data, so in some cases need to + //attach some data + event(event) { + let payload = {}; + + //dailymotion loves its 1 early, my eartubes dont + if (event === 'volumechange' && this.status !== Status.READY) { + return; + } + + switch (event) { + case 'pause': this.state = State.PAUSED; break; + case 'play': this.state = State.PLAYING; break; + case 'video_end': this.state = State.ENDED; break; + case 'playback_ready': { + this.status = Status.READY; + this.delay(this.video.timestamp); + break; + } + case 'video_start': this.player.setVolume(this.video.volume); break; + case 'seeked': payload.time = this.player.currentTime; break; + case 'volumechange': payload.volume = this.player.volume; break; + case 'error': + payload = {error: this.errors.get(this.player.error), player: this }; break; + } + + super.event(this.events.get(event), payload); + } + + loadPlayer(id, timestamp, volume) { + this.video = {id, timestamp, volume}; + this.player = window.DM.player(super.frame().id, { + video: id, + params: { + autoplay: timestamp >= 0, + start: Math.max(timestamp, 0), + mute: volume === 0, + ...this.options, + }, + }); + + this.player.addEventListener('apiready', () => { + //listen to the rest of the events + for (const event of this.events.keys()) { + this.player.addEventListener(event, () => this.event(event)); + } + }); + } + + playVideo(id, timestamp, volume) { + this.video = {id, timestamp, volume}; + this.player.load(id, { + autoplay: !(timestamp < 0), + start: Math.max(timestamp, 0) + }); + + this.delay(timestamp); + } + + play() { + this.ready(() => this.player.play()); + } + + load(id) { + this.ready(() => this.player.load(id)); + } + + pause() { + this.ready(() => this.player.pause()); + } + + seek(to) { + this.video.timestamp = to; + this.ready(() => this.player.seek(to)); + } + + getTime(cb) { + this.ready(() => cb(this.player.currentTime)); + } + + getVolume(cb) { + this.ready(() => cb(this.player.muted ? 0 : this.player.volume)); + } + + getVideoState() { + return this.state; + } + + destroy() { + this.status = Status.UNREADY; + this.player.destroy(super.frame().id); + } +} diff --git a/web/js/modules/players/errors.js b/web/js/modules/players/errors.js new file mode 100644 index 00000000..9743a1c9 --- /dev/null +++ b/web/js/modules/players/errors.js @@ -0,0 +1,6 @@ +export const Errors = { + PLAYER_UNKNOWN_ERROR: {code: 0, str: "PLAYER_UNKNOWN_ERROR"}, + PLAYER_INVALID_ID: {code: 1, str: "PLAYER_INVALID_ID"}, + PLAYER_UNPLAYABLE_VIDEO: {code: 2, str: "PLAYER_UNPLAYABLE_VIDEO"}, + PLAYER_GEOBLOCKED_VIDEO: {code: 3, str: "PLAYER_GEOBLOCKED_VIDEO"}, +}; diff --git a/web/js/modules/players/raw.js b/web/js/modules/players/raw.js new file mode 100644 index 00000000..e348e196 --- /dev/null +++ b/web/js/modules/players/raw.js @@ -0,0 +1,224 @@ +import { Event, Base, State } from "./base.js"; +import { Errors } from "./errors.js"; + +const QUALITY_LOCAL_STORAGE_KEY = "quality"; +let selectedQuality = null; + +const fileExtensionRegex = /\.([\w]+)$/; +const fileMimeTypes = new Map([ + ['mp4', 'video/mp4'], + ['m4v', 'video/mp4'], + ['webm', 'video/webm'], + ['m3u8', 'application/x-mpegURL'], + ['mpd', 'application/dash+xml'], + ['rtmp', 'rtmp/mp4'] +]); + +function getFileExtension(path) { + const match = path.match(fileExtensionRegex); + + if (!match) { + return null; + } + + return match[1]; +} + +function getUserQualityPreference() { + if (selectedQuality === null) { + selectedQuality = window.getStorageInteger(QUALITY_LOCAL_STORAGE_KEY, 1080); + } + + return selectedQuality; +} + +function setUserQualityPreference(value) { + if (typeof (value) !== "number") { + return; + } + + selectedQuality = value; + window.setStorageInteger(QUALITY_LOCAL_STORAGE_KEY, value); +} + +function pickSourceAtQuality(sources, quality) { + // make this smarter sometime? dunno + + for (const source of sources) { + if (source.quality === quality) { + return source; + } + } + + return sources.length > 0 ? sources[0] : null; +} + +function sourcesFromManifest(manifest) { + const target = pickSourceAtQuality(manifest.sources, getUserQualityPreference()); + const sources = manifest.sources.map((source) => { + const $source = { + src: source.url, + type: source.contentType, + label: source.quality + }; + + if (target == source) { + $source.selected = true; + } + + return $source; + }); + + return sources; +} + +export class Raw extends Base { + constructor() { + super(); + + this.player = null; + this.events = new Map([ + ['volumechange', Event.Volume], + ['ended', Event.End], + ['pause', Event.Pause], + ['seeked', Event.Seek], + ['play', Event.Play], + ['qualitySelected', Event.Quality], + ['error', Event.Error] + ]); + + this.errors = new Map([ + [MediaError.MEDIA_ERR_NETWORK, Errors.PLAYER_UNKNOWN_ERROR], + [MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, Errors.PLAYER_UNPLAYABLE_VIDEO], + [MediaError.MEDIA_ERR_DECODE, Errors.PLAYER_UNKNOWN_ERROR], + ]); + + this.sources = []; + this.state = State.PLAYING; + this.config = { + autoplay: false, + controls: true, + }; + } + + ready(cb) { + this.player.ready(cb); + } + + event(event, data) { + switch (event) { + case Event.Volume: data.volume = this.player.volume(); break; + case Event.Pause: this.state = State.PAUSED; break; + case Event.Play: this.state = State.PLAYING; break; + case Event.Seek: data.time = this.player.currentTime(); break; + case Event.End: this.state = State.ENDED; break; + //label can be undefined so keep the quality preference + case Event.Quality: setUserQualityPreference(data.label || getUserQualityPreference()); break; + } + + super.event( + event, + data + ); + } + + getSources(file, manifest) { + let extension = manifest ? null : getFileExtension(file); + + //TODO: Implement better handling for RTMP and + //other extensionless links (currently only rtmp) + //maybe have the info in meta? + if (!extension && !manifest) { + extension = file.startsWith('rtmp') ? 'rtmp' : null; + } + + if (manifest) { + return sourcesFromManifest(manifest); + } else { + return [{src: file, type: fileMimeTypes.get(extension) || 'video/mp4'}]; + } + } + + loadPlayer(id, timestamp, volume, length, meta) { + if (meta.manifest && meta.manifest.sources.length === 0) { + console.error('Manifest has no items'); + return; + } + + this.video = {id, meta, timestamp, sync: length > 0}; + this.frame = window.$("