diff --git a/assets/js/embed.js b/assets/js/embed.js index b11b5e5a6..92350662b 100644 --- a/assets/js/embed.js +++ b/assets/js/embed.js @@ -22,6 +22,8 @@ function get_playlist(plid) { player.on('ended', function () { var url = new URL('https://example.com/embed/' + response.nextVideo); + const search_params = new URLSearchParams(location.search); + url.searchParams.set('list', plid); if (!plid.startsWith('RD')) url.searchParams.set('index', response.index); @@ -33,6 +35,12 @@ function get_playlist(plid) { url.searchParams.set('speed', video_data.params.speed); if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); + if (search_params.get('widgetid') !== null) + url.searchParams.set('widgetid', search_params.get('widgetid')); + if (search_params.get('origin') !== null) + url.searchParams.set('origin', search_params.get('origin')); + if (search_params.get('enablejsapi') !== null) + url.searchParams.set('enablejsapi', search_params.get('enablejsapi')); location.assign(url.pathname + url.search); }); @@ -62,3 +70,160 @@ addEventListener('load', function (e) { }); } }); + +function return_message(message, target_window) { + if (target_window === undefined) { + target_window = window.parent; + } + let url_params = new URLSearchParams(location.search); + let widgetid = url_params.get('widgetid'); + let additional_info = { from: 'invidious_control' }; + + if (widgetid !== null) { + additional_info.widgetid = widgetid; + } + + if (message.message_kind === 'event') { + if (message.eventname === 'timeupdate' || message.eventname === 'loadedmetadata') { + additional_info['value'] = { + getvolume: player.volume(), + getduration: player.duration(), + getcurrenttime: player.currentTime(), + getplaystatus: player.paused(), + getplaybackrate: player.playbackRate(), + getloopstatus: player.loop(), + getmutestatus: player.muted(), + getfullscreenstatus: player.isFullscreen(), + getavailableplaybackrates: options.playbackRates, + gettitle: player_data.title, + getplaylistindex: video_data.index, + getplaylistid: video_data.plid + }; + } + } + + if (message.eventname === 'error') { + additional_info['value'] = { geterrorcode: player.error().code }; + } + + message = Object.assign(additional_info, message); + let target_origin = url_params.get('origin') || '*'; + target_window.postMessage(message, target_origin); +} + +function control_embed_iframe(message) { + const url_params = new URLSearchParams(location.search); + const origin = url_params.get('origin'); + const origin_equal = origin === null || origin === message.origin; + if (origin_equal) { + const widgetid = url_params.get('widgetid'); + const widgetid_equal = widgetid === message.data.widgetid; + const target_name_equal = message.data.target === 'invidious_control'; + const eventname_string_check = typeof message.data.eventname === 'string'; + if (widgetid_equal && target_name_equal && eventname_string_check) { + let message_return_value; + switch (message.data.eventname) { + case 'play': + player.play(); + break; + case 'pause': + player.pause(); + break; + + case 'setvolume': + player.volume(message.data.value); + break; + case 'seek': + const duration = player.duration(); + let newTime = helpers.clamp(message.data.value, 0, duration); + if (player.paused() && player.currentTime() === 0) { + player.play(); + } + player.currentTime(newTime); + break; + case 'setplaybackrate': + player.playbackRate(message.data.value); + break; + case 'setloopstatus': + player.loop(message.data.value); + break; + case 'requestfullscreen': + player.requestFullscreen(); + break; + case 'exitfullscreen': + player.exitFullscreen(); + break; + + case 'getvolume': + message_return_value = player.volume(); + break; + case 'getduration': + message_return_value = player.duration(); + break; + case 'getcurrenttime': + message_return_value = player.currentTime(); + break; + case 'getplaystatus': + message_return_value = player.paused(); + break; + case 'getplaybackrate': + message_return_value = player.playbackRate(); + break; + case 'getavailableplaybackrates': + message_return_value = options.playbackRates; + break; + case 'getloopstatus': + message_return_value = player.loop(); + break; + case 'getmutestatus': + message_return_value = player.muted(); + break; + case 'gettitle': + message_return_value = player_data.title; + break; + case 'getfullscreenstatus': + message_return_value = player.isFullscreen(); + break; + case 'geterrorcode': + message_return_value = player.error().code; + break; + case 'getplaylistindex': + message_return_value = video_data.index; + break; + case 'getplaylistid': + message_return_value = video_data.plid; + break; + default: + console.info("Unhandled event name: " + message.data.eventname); + break; + } + + if (message_return_value !== undefined) { + return_message({ command: message.data.eventname, value: message_return_value, message_kind: 'info_return' }, message.source); + } + } + } +} + +if (new URLSearchParams(location.search).get('enablejsapi') === '1') { + window.addEventListener('message', control_embed_iframe); + const event_list = [ + 'ended', + 'error', + 'ratechange', + 'volumechange', + 'waiting', + 'timeupdate', + 'loadedmetadata', + 'play', + 'seeking', + 'seeked', + 'playerresize', + 'pause' + ]; + event_list.forEach(event_name => { + player.on(event_name, function () { + return_message({ message_kind: 'event', eventname: event_name }) + }); + }); +} diff --git a/assets/js/invidious_iframe_api.js b/assets/js/invidious_iframe_api.js new file mode 100644 index 000000000..d9f645551 --- /dev/null +++ b/assets/js/invidious_iframe_api.js @@ -0,0 +1,1459 @@ +class invidious_embed { + static widgetid = 0; + + static eventname_table = { + onPlaybackRateChange: 'ratechange', + onStateChange: 'statechange', + onError: 'error', + onReady: 'ready' + }; + + static available_event_name = [ + 'ready', + 'ended', + 'error', + 'ratechange', + 'volumechange', + 'waiting', + 'timeupdate', + 'loadedmetadata', + 'play', + 'seeking', + 'seeked', + 'playerresize', + 'pause' + ]; + + /** + * Recive event response synchronization or asynchronous. + * + * Default false mean synchronization + * @type {boolean} + */ + static api_promise = false; + static invidious_instance = ''; + + /** + * @type {[string]} + */ + static api_instance_list = []; + + /** + * @type {Object} + */ + static instance_status_list = {}; + + /** + * @typedef {{ + * title:string, + * videoId:string, + * videoThumbnails:[{ + * quarity:string, + * url:string, + * height:number, + * width:number + * }], + * storyboards:[{ + * url:string, + * templateUrl:string, + * width:number, + * height:number, + * count:number, + * interval:number, + * storyboardWidth:number, + * storyboardHeight:number, + * storyboardCount:number + * }] + * description:string, + * descriptionHtml:string, + * published:number, + * publishedText:string, + * keywords:[string], + * viewCount:number, + * likeCount:number, + * dislikeCount:number, + * paid:boolean, + * premium:boolean, + * isFamilyFriendly:boolean, + * allowedRegions:[string], + * genre:string, + * genreUrl:string, + * author:string, + * authorId:string, + * authorUrl:string, + * authorThumbnails:[{ + * url:string, + * width:number, + * height:number + * }] + * subCountText:string, + * lengthSeconds:number, + * allowRatings:string, + * rating:number, + * isListed:boolean, + * liveNow:boolean, + * isUpcoming:boolean, + * dashUrl:string, + * adaptiveFormats:[{ + * init:string, + * index:string, + * bitrate:string, + * url:string, + * itag:string, + * type:string, + * clen:string, + * lmt:string, + * projectionType:string, + * fps:number, + * container:string, + * encoding:string, + * audioQuality:string, + * audioSampleRate:number, + * audioChannels:number + * }] + * formatStreams:[{ + * url:string, + * itag:string, + * type:string, + * quarity:string, + * fps:number, + * container:string, + * encoding:string, + * resolution:string, + * qualityLabel:string, + * size:string + * }] + * captions:[{ + * label:string, + * language_code:string, + * url:string + * }] + * recommendedVideos:[{ + * videoId:string, + * title:string, + * videoThumbnails:[{ + * quarity:string, + * url:string, + * height:number, + * width:number + * }], + * author:string, + * authorId:string, + * authorUrl:string, + * lengthSeconds:number, + * viewCountText:string, + * viewCount:number + * }] + * }} videoDataApi + */ + + /** + * @type {Object} + */ + static videodata_cahce = {}; + + /** + * Add event execute function for player + * @param {string} eventname + * @param {Function} event_execute_function + */ + addEventListener(eventname, event_execute_function) { + if (typeof event_execute_function === 'function') { + if (eventname in invidious_embed.eventname_table) { + this.eventobject[invidious_embed.eventname_table[eventname]].push(event_execute_function); + } else if (invidious_embed.available_event_name.includes(eventname)) { + this.eventobject[eventname].push(event_execute_function); + } else { + console.warn('addEventListener cannot find such eventname : ' + eventname); + } + } else { + console.warn("addEventListner secound args must be function"); + } + } + + /** + * remove spacific event execute function + * @param {string} eventname + * @param {Function} delete_event_function + */ + removeEventListener(eventname, delete_event_function) { + if (typeof delete_event_function === 'function') { + let internal_eventname; + if (eventname in invidious_embed.eventname_table) { + internal_eventname = invidious_embed.eventname_table[eventname]; + } else if (invidious_embed.available_event_name.includes(eventname)) { + internal_eventname = eventname; + } else { + console.warn('removeEventListner cannot find such eventname : ' + eventname); + return; + } + + this.eventobject[internal_eventname] = this.eventobject[internal_eventname].filter(listed_function => { + const allowFunctionDetected = listed_function.toString()[0] === '('; + if (allowFunctionDetected) { + listed_function.toString() !== delete_event_function.toString(); + } else { + listed_function !== delete_event_function; + } + }); + } else { + console.warn("removeEventListener secound args must be function"); + } + } + + /** + * return whether instance_origin origin can use or not + * @param {string} instance_origin + * @returns {Promise} + */ + async instance_access_check(instance_origin) { + let return_status; + const status_cahce_exist = instance_origin in invidious_embed.instance_status_list; + if (status_cahce_exist) { + return invidious_embed.instance_status_list[instance_origin]; + } + + try { + const instance_stats = await fetch(instance_origin + '/api/v1/stats'); + if (instance_stats.ok) { + const instance_stats_json = await instance_stats.json(); + return_status = (instance_stats_json.software.name === 'invidious'); + } else { + return_status = false; + } + } catch { + return_status = false; + } + invidious_embed.instance_status_list[instance_origin] = return_status; + return return_status; + } + + /** + * Need to use await + * + * Add invidious_embed.api_instance_list + * + * fetch from api.invidious.io + */ + async get_instance_list() { + invidious_embed.api_instance_list = []; + const instance_list_api = await (await fetch('https://api.invidious.io/instances.json?pretty=1&sort_by=type,users')).json(); + instance_list_api.forEach(instance_data => { + const http_check = instance_data[1]['type'] === 'https'; + let status_check_api_data; + if (instance_data[1]['monitor'] !== null) { + status_check_api_data = instance_data[1]['monitor']['statusClass'] === 'success'; + } + const api_available = instance_data[1]['api'] && instance_data[1]['cors']; + if (http_check && status_check_api_data && api_available) { + invidious_embed.api_instance_list.push(instance_data[1]['uri']); + } + }); + } + + /** + * Need to use await + * + * Auto select invidious instance and set invidious_embed.invidious_instance + */ + async auto_instance_select() { + if (await this.instance_access_check(invidious_embed.invidious_instance)) { + return; + } else { + if (invidious_embed.api_instance_list.length === 0) { + await this.get_instance_list(); + } + for (let x = 0; x < invidious_embed.api_instance_list.length; x++) { + if (await this.instance_access_check(invidious_embed.api_instance_list[x])) { + invidious_embed.invidious_instance = invidious_embed.api_instance_list[x]; + break; + } + } + } + } + + /** + * Return videoData using invidious videos api + * @param {string} videoid + * @returns {Promise} + */ + async videodata_api(videoid) { + const not_in_videodata_cahce = !(videoid in invidious_embed.videodata_cahce); + if (not_in_videodata_cahce) { + const video_api_response = await fetch(invidious_embed.invidious_instance + "/api/v1/videos/" + videoid); + if (video_api_response.ok) { + invidious_embed.videodata_cahce[videoid] = Object.assign({}, { status: true }, await video_api_response.json()); + } else { + invidious_embed.videodata_cahce[videoid] = { status: false }; + } + } + return invidious_embed.videodata_cahce[videoid]; + } + + /** + * check whether videoid exist or not + * @param {string} videoid + * @returns {promise} + */ + async videoid_accessable_check(videoid) { + return (await this.videodata_api(videoid)).status; + } + + /** + * return array of videoid in playlistid + * @param {string} playlistid + * @returns {Promise<[string]>} + */ + async getPlaylistVideoids(playlistid) { + const playlist_api_response = await fetch(invidious_embed.invidious_instance + "/api/v1/playlists/" + playlistid); + if (playlist_api_response.ok) { + const playlist_api_json = await playlist_api_response.json(); + let tmp_videoid_list = []; + playlist_api_json.videos.forEach(videodata => tmp_videoid_list.push(videodata.videoId)); + return tmp_videoid_list; + } else { + return []; + } + } + + /** + * + * @param {string|Node} element + * @param {{ + * videoId:string, + * host:string, + * width:number, + * height:number, + * playerVars:{ + * start:number|string, + * end:number|string, + * autoplay:number|string + * }, + * events:{ + * onReady:Function, + * onError:Function, + * onStateChange:Function, + * onPlaybackRateChange:Function + * } + * }} options + * @returns + */ + async Player(element, options) { + this.player_status = -1; + this.error_code = 0; + this.volume = 100; + this.loop = false; + + /** + * @type {[string]} + */ + this.playlistVideoIds = []; + + /** + * @type {{ + * ready:Function, + * ended:Function, + * error:Function, + * ratechange:Function, + * volumechange:Function, + * waiting:Function, + * timeupdate:Function, + * loadedmetadata:Function, + * play:Function, + * seeking:Function, + * seeked:Function, + * playerresize:Function, + * pause:Function, + * statechange:Function + * }} + */ + this.eventobject = { + ready: [], + ended: [], + error: [], + ratechange: [], + volumechange: [], + waiting: [], + timeupdate: [], + loadedmetadata: [], + play: [], + seeking: [], + seeked: [], + playerresize: [], + pause: [], + statechange: [] + }; + + let replace_elemnt; + this.isPlaylistVideoList = false; + if (element === undefined || element === null) { + throw 'Please, pass element id or HTMLElement as first argument'; + } else if (typeof element === 'string') { + replace_elemnt = document.getElementById(element); + + if (replace_elemnt === null) { + throw 'Can not find spacific element' + } + } else { + replace_elemnt = element; + } + + let iframe_src = ''; + if (options.host !== undefined && options.host !== "") { + iframe_src = new URL(options.host).origin; + } else if (invidious_embed.invidious_instance !== '') { + iframe_src = invidious_embed.invidious_instance; + } + + if (!await this.instance_access_check(iframe_src)) { + await this.auto_instance_select(); + iframe_src = invidious_embed.invidious_instance; + } + + invidious_embed.invidious_instance = iframe_src; + this.target_origin = iframe_src; + iframe_src += '/embed/'; + + if (typeof options.videoId === 'string' && options.videoId.length === 11) { + iframe_src += options.videoId; + this.videoId = options.videoId; + if (!await this.videoid_accessable_check(options.videoId)) { + this.error_code = 100; + this.event_executor('error'); + return; + } + } else { + this.error_code = 2; + this.event_executor('error'); + return; + } + + let search_params = new URLSearchParams(''); + search_params.append('widgetid', invidious_embed.widgetid); + this.widgetid = invidious_embed.widgetid; + invidious_embed.widgetid++; + search_params.append('origin', location.origin); + search_params.append('enablejsapi', '1'); + + let no_start_parameter = true; + if (typeof options.playerVars === 'object') { + this.option_playerVars = options.playerVars; + Object.keys(options.playerVars).forEach(key => { + if (typeof key === 'string') { + let keyValue = options.playerVars[key]; + switch (typeof keyValue) { + case 'number': + keyValue = keyValue.toString(); + break; + case 'string': + break; + default: + console.warn('player vars key value must be string or number'); + } + search_params.append(key, keyValue); + } else { + console.warn('player vars key must be string'); + } + }); + + if (options.playerVars.start !== undefined) { + no_start_parameter = false; + } + + if (options.playerVars.autoplay === undefined) { + search_params.append('autoplay', '0'); + } + + } else { + search_params.append('autoplay', '0'); + } + + if (no_start_parameter) { + search_params.append('start', '0'); + } + + iframe_src += "?" + search_params.toString(); + + if (typeof options.events === 'object') { + Object.keys(options.events).forEach(key => { + if (typeof options.events[key] === 'function') { + this.addEventListener(key, options.events[key]); + } else { + console.warn('event function must be function'); + } + }); + } + + this.player_iframe = document.createElement("iframe"); + this.loaded = false; + this.addEventListener('loadedmetadata', () => { this.event_executor('ready'); this.loaded = true; }); + this.addEventListener('loadedmetadata', () => { this.setVolume(this.volume); }); + this.addEventListener('ended', () => { if (this.isPlaylistVideoList) { this.nextVideo() } }) + this.player_iframe.src = iframe_src; + + if (typeof options.width === 'number') { + this.player_iframe.width = options.width; + } else { + this.player_iframe.width = 640; + this.player_iframe.style.maxWidth = '100%'; + } + + if (typeof options.height === 'number') { + this.player_iframe.height = options.height; + } else { + this.player_iframe.height = this.player_iframe.width * (9 / 16); + } + + this.player_iframe.style.border = "none"; + replace_elemnt.replaceWith(this.player_iframe); + /** + * @type {Object.} + */ + this.eventdata = {}; + return this; + } + + /** + * send message to iframe player + * @param {Object} data + */ + postMessage(data) { + const additionalInfo = { + 'origin': location.origin, + 'widgetid': this.widgetid.toString(), + 'target': 'invidious_control' + }; + data = Object.assign(additionalInfo, data); + this.player_iframe.contentWindow.postMessage(data, this.target_origin); + } + + /** + * execute eventname event + * @param {string} eventname + */ + event_executor(eventname) { + const execute_functions = this.eventobject[eventname]; + let return_data = { + type: eventname, + data: null, + target: this + }; + switch (eventname) { + case 'statechange': + return_data.data = this.getPlayerState(); + break; + case 'error': + return_data.data = this.error_code; + } + execute_functions.forEach(func => { + try { + func(return_data); + } catch (e) { + console.error(e); + } + }); + } + + /** + * recieve message from iframe player + * @param {{ + * data:{ + * from:string, + * message_kind:string, + * widgetid:string, + * command:string, + * value:string|number|object|null, + * eventname:string + * } + * }} message + */ + receiveMessage(message) { + const onControlAndHasWidgetId = message.data.from === 'invidious_control' && message.data.widgetid === this.widgetid.toString(); + if (onControlAndHasWidgetId) { + switch (message.data.message_kind) { + case 'info_return': + const promise_array = this.message_wait[message.data.command]; + promise_array.forEach(element => { + if (message.data.command === 'getvolume') { + element(message.data.value * 100); + } else { + element(message.data.value); + } + }); + this.message_wait[message.data.command] = []; + break; + case 'event': + if (typeof message.data.eventname === 'string') { + this.event_executor(message.data.eventname); + const previous_status = this.player_status; + switch (message.data.eventname) { + case 'ended': + this.player_status = 0; + break; + case 'play': + this.player_status = 1; + break; + case 'timeupdate': + this.player_status = 1; + this.eventdata = Object.assign({}, this.eventdata, message.data.value); + break; + case 'pause': + this.player_status = 2; + break; + case 'waiting': + this.player_status = 3; + break; + case 'loadedmetadata': + this.eventdata = Object.assign({}, this.eventdata, message.data.value); + break; + } + if (previous_status !== this.player_status) { + this.event_executor('statechange'); + } + } + } + } + } + + /** + * Default return no Promise value. + * + * But if set invidious_embed.api_promise true, return Promise value + * + * send eventname event to player iframe + * @param {'getvolume'|'setvolume'|'getmutestatus'|'getplaybackrate'|'getavailableplaybackrates'|'getplaylistindex'|'getduration'|'gettitle'|'getplaylistid'|'getcurrenttime'} event_name + * @returns {number|boolean|[number]|string|Promise|Promise|Promise<[number]>|Promise} + */ + promise_send_event(event_name) { + if (invidious_embed.api_promise) { + const promise_object = new Promise((resolve, reject) => this.message_wait[event_name].push(resolve)); + this.postMessage({ + eventname: event_name + }); + return promise_object; + } else { + return this.eventdata[event_name]; + } + } + + /** + * return playerstatus same as youtube iframe api + * + * -1:unstarted + * + * 0:ended + * + * 1:playing + * + * 2:paused + * + * 3:buffering + * + * 5:video cued + * @returns {number} + * @example + * const player_statrus = player.getPlayerState(); + * //player_statrus = 1; + */ + getPlayerState() { + return this.player_status; + } + + /** + * send play command to iframe player + * @example + * player.playVideo(); + */ + playVideo() { + this.postMessage({ eventname: 'play' }); + } + + /** + * send pause command to iframe player + * @example + * player.pauseVideo(); + */ + pauseVideo() { + this.postMessage({ eventname: 'pause' }); + } + + /** + * Default return number range 0 to 100 + * + * But if set invidious_embed.api_promise true, return Promise + * @returns {number|Promise} + * @example + * const volume = player.getVolume();//invidious_embed.api_promise is false + * const volume = await player.getVolume();//invidious_embed.api_promise is true + * //volume = 100 + */ + getVolume() { + return this.promise_send_event('getvolume'); + } + + /** + * Send set volume event to iframe player + * + * volume must be range 0 to 100 + * @param {number} volume + * @example + * player.setVolume(50);//set volume 50% + */ + setVolume(volume) { + if (typeof volume === 'number') { + this.volume = volume; + if (volume !== NaN && volume >= 0 && volume <= 100) { + this.postMessage({ eventname: 'setvolume', value: volume / 100 }); + } + } else { + console.warn("setVolume first argument must be number"); + } + } + + /** + * Get player iframe node + * @returns {Node} + * @example + * const invidious_player_node = player.getIframe(); + */ + getIframe() { + return this.player_iframe; + } + + /** + * delete player iframe + * @example + * player.destroy(); + */ + destroy() { + this.player_iframe.remove(); + } + + /** + * send mute event to iframe player + * @example + * player.mute(); + */ + mute() { + this.postMessage({ eventname: 'setmutestatus', value: true }); + } + + /** + * send unmute event to iframe player + * @example + * player.unMute(); + */ + unMute() { + this.postMessage({ eventname: 'setmutestatus', value: false }); + } + + /** + * Whether mute or not. + * + * Default return boolean. + * + * But if set invidious_embed.api_promise true, return Promise. + * @returns {boolean|Promise} + * @example + * const muteStatus = player.isMuted();//invidious_embed.api_promise false + * const muteStatus = await player.isMuted();//invidious_embed.api_promise true + * //muteStatus = false + */ + isMuted() { + return this.promise_send_event('getmutestatus'); + } + + /** + * send command seek video to seconds to iframe player. + * + * seconds count start with video 0 seconds. + * @param {number} seconds + * @param {boolean} allowSeekAhead ignore. only maintained for compatibility of youtube iframe player + * @example + * player.seekTo(100);//seek to 100 seconds of video which counts start with 0 seconds of the video + */ + seekTo(seconds, allowSeekAhead) { + if (typeof seconds === 'number') { + if (seconds !== NaN && seconds !== undefined) { + this.postMessage({ eventname: 'seek', value: seconds }); + } + } else { + console.warn('seekTo first argument type must be number') + } + } + + /** + * set iframe size + * @param {number} width + * @param {number} height + * @example + * player.setSize(480,270); + */ + setSize(width, height) { + if (typeof width === 'number' && typeof height === 'number') { + this.player_iframe.width = width; + this.player_iframe.height = height; + } else { + console.warn('setSize first and secound argument type must be number'); + } + } + + /** + * get playback rate. + * + * Default return number. + * + * But if set invidious_embed.api_promise true, return Promise. + * @returns {number|Promise} + * @example + * const now_playback_rate = player.getPlaybackRate();//invidious_embed.api_promise is false + * const now_playback_rate = await player.getPlaybackRate();//invidious_embed.api_promise is true + * //now_playback_rate = 1.0 + */ + getPlaybackRate() { + return this.promise_send_event('getplaybackrate'); + } + + /** + * Set video play back rate + * @param {number} suggestedRate + * @example + * player.setPlaybackRate(0.5);//play video 0.5x + * player.setPlaybackRate(1.2);//play video 1.2x + */ + setPlaybackRate(suggestedRate) { + if (typeof suggestedRate === 'number') { + if (suggestedRate !== NaN) { + this.postMessage({ eventname: 'setplaybackrate', value: suggestedRate }); + } else { + console.warn('setPlaybackRate first argument NaN is no valid'); + } + } else { + console.warn('setPlaybackRate first argument type must be number'); + } + } + + /** + * get available playback rates. + * + * Default return [number]. + * + * But if set invidious_embed.api_promise true, return Promise<[number]> + * @returns {[number]|Promise<[number]>} + * @example + * const available_playback_rates = player.getAvailablePlaybackRates();//invidious_embed.api_promise is false + * const available_playback_rates = player.getAvailablePlaybackRates();//invidious_embed.api_promise is true + * //available_playback_rates = [0.25,0.5,0.75,1,1.25,1.5,1.75,2.0]; + */ + getAvailablePlaybackRates() { + return this.promise_send_event('getavailableplaybackrates'); + } + + /** + * Internal function, so use such as loadVideoById() instead of this function. + * @param {string|{ + * videoId:string|undefined, + * mediaContentUrl:string|undefined, + * startSeconds:number, + * endSeconds:number + * }} option + * @param {boolean} autoplay + * @param {number|undefined} startSeconds_arg + * @param {Object.} additional_argument + * @returns + */ + async playOtherVideoById(option, autoplay, startSeconds_arg, additional_argument) {//internal fuction + let videoId = ''; + let startSeconds = 0; + let endSeconds = -1; + let mediaContetUrl = ''; + + if (typeof option === 'string') { + if (option.length === 11) { + videoId = option; + } else { + mediaContetUrl = option; + } + + if (typeof startSeconds_arg === 'number') { + startSeconds = startSeconds_arg; + } + } else if (typeof option === 'object') { + if (typeof option.videoId === 'string') { + + if (option.videoId.length == 11) { + videoId = option.videoId; + } else { + this.error_code = 2; + this.event_executor('error'); + return; + } + } else if (typeof option.mediaContentUrl === 'string') { + mediaContetUrl = option.mediaContentUrl; + } else { + this.error_code = 2; + this.event_executor('error'); + return; + } + + if (typeof option.startSeconds === 'number' && option.startSeconds >= 0) { + startSeconds = option.startSeconds; + } + + if (typeof option.endSeconds === 'number' && option.endSeconds >= 0) { + endSeconds = option.endSeconds; + } + } + if (mediaContetUrl.length > 0) { + const match_result = mediaContetUrl.match(/\/([A-Za-z0-9]{11})/); + if (match_result !== null && match_result.length === 2) { + videoId = match_result[1]; + } else { + this.error_code = 2; + this.event_executor('error'); + return; + } + } + + let iframe_sorce = this.target_origin.slice(); + iframe_sorce += "/embed/" + videoId; + this.videoId = videoId; + + if (!await this.videoid_accessable_check(videoId)) { + this.error_code = 100; + this.event_executor('error'); + return; + } + + let search_params = new URLSearchParams(''); + search_params.append('origin', location.origin); + search_params.append('enablejsapi', '1'); + search_params.append('widgetid', invidious_embed.widgetid); + this.widgetid = invidious_embed.widgetid; + invidious_embed.widgetid++; + search_params.append('autoplay', Number(autoplay)); + + if (this.option_playerVars !== undefined) { + const ignore_keys = ['autoplay', 'start', 'end', 'index', 'list']; + Object.keys(this.option_playerVars).forEach(key => { + if (!ignore_keys.includes(key)) { + search_params.append(key, this.option_playerVars[key]); + } + }) + } + + if (typeof additional_argument === 'object') { + const ignore_keys = ['autoplay', 'start', 'end']; + Object.keys(additional_argument).forEach(key => { + if (!ignore_keys.includes(key)) { + search_params.append(key, additional_argument[key]); + } + }) + } + + search_params.append('start', startSeconds); + if (endSeconds !== -1 && endSeconds >= 0) { + if (endSeconds > startSeconds) { + search_params.append('end', endSeconds); + } else { + throw 'Invalid end seconds because end seconds before start seconds'; + } + } + + iframe_sorce += "?" + search_params.toString(); + this.player_iframe.src = iframe_sorce; + + if (autoplay) { + this.player_status = 5; + } + this.eventdata = {}; + } + + /** + * Load video using videoId + * @param {string|{ + * videoId:string, + * startSeconds:number|undefined, + * endSeconds:number|undefined + * }} option + * @param {number|undefined} startSeconds + * @example + * player.loadVideoById('INHasAVlzI8');//load video INHasAVlzI8 + * player.loadVideoById('INHasAVlzI8',52);//load video INHasAVlzI8 and start with 52 seconds + * player.loadVideoById({videoId:'INHasAVlzI8',startSeconds:52,endSeconds:76});//load video INHasAVlzI8 ,start with 52 seconds and end 76 seconds + */ + loadVideoById(option, startSeconds) { + this.isPlaylistVideoList = false; + this.playOtherVideoById(option, true, startSeconds, {}); + } + + /** + * Cue video using videoId + * + * Cue mean before playing video only show video thumbnail and title + * @param {string|{ + * videoId:string, + * startSeconds:number|undefined, + * endSeconds:number|undefined + * }} option + * @param {number|undefined} startSeconds + * @example + * player.cueVideoById('INHasAVlzI8');//load video INHasAVlzI8 + * player.cueVideoById('INHasAVlzI8',52);//load video INHasAVlzI8 and start with 52 seconds + * player.cueVideoById({videoId:'INHasAVlzI8',startSeconds:52,endSeconds:76});//load video INHasAVlzI8 ,start with 52 seconds and end 76 seconds + */ + cueVideoById(option, startSeconds) { + this.isPlaylistVideoList = false; + this.playOtherVideoById(option, false, startSeconds, {}); + } + + /** + * Cue video using media content url + * + * Cue mean before playing video only show video thumbnail and title + * + * Media content url like https://youtube.com/v/INHasAVlzI8 .Cannot run like https://youtube.com/watch/?v=INHasAVlzI8 this behavior is same as youtube iframe api + * @param {string|{ + * mediaContentUrl:string, + * startSeconds:number|undefined, + * endSeconds:number|undefined + * }} option + * @param {number|undefined} startSeconds + * @example + * player.cueVideoByUrl('https://youtube.com/v/INHasAVlzI8');//load video INHasAVlzI8 + * player.cueVideoByUrl('https://youtube.com/v/INHasAVlzI8',52);//load video INHasAVlzI8 and start with 52 seconds + * player.cueVideoByUrl({mediaContentUrl:'https://youtube.com/v/INHasAVlzI8',startSeconds:52,endSeconds:76});//load video INHasAVlzI8 ,start with 52 seconds and end 76 seconds + */ + cueVideoByUrl(option, startSeconds) { + this.isPlaylistVideoList = false; + this.playOtherVideoById(option, false, startSeconds, {}); + } + + /** + * Load video using media content url + * + * Media content url like https://youtube.com/v/INHasAVlzI8 .Cannot run like https://youtube.com/watch/?v=INHasAVlzI8 this behavior is same as youtube iframe api + * @param {string|{ + * mediaContentUrl:string, + * startSeconds:number|undefined, + * endSeconds:number|undefined + * }} option + * @param {number|undefined} startSeconds + * @example + * player.loadVideoByUrl('https://youtube.com/v/INHasAVlzI8');//load video INHasAVlzI8 + * player.loadVideoByUrl('https://youtube.com/v/INHasAVlzI8',52);//load video INHasAVlzI8 and start with 52 seconds + * player.loadVideoByUrl({mediaContentUrl:'https://youtube.com/v/INHasAVlzI8',startSeconds:52,endSeconds:76});//load video INHasAVlzI8 ,start with 52 seconds and end 76 seconds + */ + loadVideoByUrl(option, startSeconds) { + this.isPlaylistVideoList = false; + this.playOtherVideoById(option, true, startSeconds, {}); + } + + /** + * Internal function, so use such as loadPlaylist() instead of this function. + * @param {string|[string]|{index:number|undefined,list:string,listType:string|undefined}} playlistData + * @param {boolean} autoplay + * @param {number} index + * @param {number} startSeconds + */ + async playPlaylist(playlistData, autoplay, index, startSeconds) { + /** + * @type {string} + */ + let playlistId; + if (typeof playlistData === 'string') { + this.playlistVideoIds = [playlistData]; + this.isPlaylistVideoList = true; + } else if (typeof playlistData === 'object') { + if (Array.isArray(playlistData)) { + this.playlistVideoIds = playlistData; + this.isPlaylistVideoList = true; + } else { + index = playlistData['index']; + let listType = 'playlist'; + if (typeof playlistData['listType'] === 'string') { + listType = playlistData['listType']; + } + + switch (listType) { + case 'playlist': + if (typeof playlistData['list'] === 'string') { + this.playlistVideoIds = await this.getPlaylistVideoids(playlistData['list']); + playlistId = playlistData['list']; + } else { + console.error('playlist data list must be string'); + return; + } + break; + case 'user_uploads': + console.warn('sorry user_uploads not support'); + return; + default: + console.error('listType : ' + listType + ' is unknown'); + return; + } + } + + if (typeof playlistData.startSeconds === 'number') { + startSeconds = playlistData.startSeconds; + } + } else { + console.error('playlist function first argument must be string or array of string'); + return; + } + + if (this.playlistVideoIds.length === 0) { + console.error('playlist length 0 is invalid'); + return; + } + let parameter = { index: 0 }; + if (typeof index === 'undefined') { + index = 0; + } else if (typeof index === 'number') { + parameter.index = index; + } else { + console.error('index must be number of undefined'); + } + + if (typeof playlistId === 'string') { + parameter['list'] = playlistId; + this.playlistId = playlistId; + } + this.sub_index = parameter.index; + + if (index >= this.playlistVideoIds.length) { + index = 0; + parameter.index = 0; + } + this.playOtherVideoById(this.playlistVideoIds[index], autoplay, startSeconds, parameter); + } + + /** + * Cue playlist and play video at index number + * @param {string|[string]|{index:number|undefined,list:string,listType:string|undefined}} data + * @param {number|undefined} index count start with 0 + * @param {number|undefined} startSeconds only affect first video + * @example + * player.loadPlaylist('i50sUufNbzY');//play i50sUufNbzY video start with 0 second + * player.loadPlaylist(['i50sUufNbzY','BgNVwiX7K8E','L7PCS7afS3Y'],1,10);//play index second playlist (BgNVwiX7K8E) and play start with 10 seconds + * player.loadPlaylist({list:'PL84LbRiy3noqhbyqr-IcCKhyXE6mFoQzF'});//play playlist first index of PL84LbRiy3noqhbyqr-IcCKhyXE6mFoQzF and start with 0 second + */ + cuePlaylist(data, index, startSeconds) { + this.playPlaylist(data, false, index, startSeconds); + } + + /** + * Load playlist and play video at index number + * + * Cue mean before playing video only show video thumbnail and title + * @param {string|[string]|{index:number|undefined,list:string,listType:string|undefined}} data + * @param {number|undefined} index count start with 0 + * @param {number|undefined} startSeconds only affect first video + * @example + * player.loadPlaylist('i50sUufNbzY');//play i50sUufNbzY video start with 0 second + * player.loadPlaylist(['i50sUufNbzY','BgNVwiX7K8E','L7PCS7afS3Y'],1,10);//play index second playlist (BgNVwiX7K8E) and play start with 10 seconds + * player.loadPlaylist({list:'PL84LbRiy3noqhbyqr-IcCKhyXE6mFoQzF'});//play playlist first index of PL84LbRiy3noqhbyqr-IcCKhyXE6mFoQzF and start with 0 second + */ + loadPlaylist(data, index, startSeconds) { + this.playPlaylist(data, true, index, startSeconds); + } + + /** + * Play video spacific number of index + * @param {number} index count start with 0 + * @example + * player.playVideoAt(5);//play video playlist index 6th + */ + playVideoAt(index) { + if (typeof index === 'number') { + let parameter = { index: index }; + if (this.playlistId !== undefined) { + parameter['list'] = this.playlistId; + } + this.playOtherVideoById(this.playlistVideoIds[index], true, 0, parameter); + } else { + console.error('playVideoAt first argument must be number'); + } + } + + /** + * play next video of playlist + * + * if end of playlist,if loop is true, load first video of playlist. + * @example + * player.nextVideo(); + */ + async nextVideo() { + let now_index = this.promise_send_event('getplaylistindex'); + if (now_index === null) { + now_index = this.sub_index; + } + + if (now_index === this.playlistVideoIds.length - 1) { + if (this.loop) { + now_index = 0; + } else { + console.log('end of playlist'); + return; + } + } else { + now_index++; + } + this.sub_index = now_index; + let parameter = { index: now_index }; + if (this.playlistId !== undefined) { + parameter['list'] = this.playlistId; + } + this.playOtherVideoById(this.playlistVideoIds[now_index], true, 0, parameter); + } + + /** + * play previous video of playlist + * + * if start of playlist,if loop is true, load end video of playlist. + * @example + * player.previousVideo(); + */ + async previousVideo() { + let now_index = this.promise_send_event('getplaylistindex'); + if (now_index === null) { + now_index = this.sub_index; + } + if (now_index === 0) { + if (this.loop) { + now_index = this.playlistVideoIds.length - 1; + } else { + console.log('back to start of playlist'); + return; + } + } else { + now_index--; + } + this.sub_index = now_index; + let parameter = { index: now_index }; + if (this.playlistId !== undefined) { + parameter['list'] = this.playlistId; + } + this.playOtherVideoById(this.playlistVideoIds[now_index], true, 0, parameter); + } + + /** + * Get dulation of video + * + * Default return number + * + * But if set invidious_embed.api_promise true, return Promise. + * @returns {number|Promise} + * @example + * const player_dulation = player.getDuration();//invidious_embed.api_promise is false + * const player_dulation = await player.getDuration();//invidious_embed.api_promise is true + * //player_dulation = 80 + */ + getDuration() { + return this.promise_send_event('getduration'); + } + + /** + * Get url of loaded video + * @returns {string} + * @example + * const video_url = player.getVideoUrl(); + * //video_url = "https://yewtu.be/watch?v=KqE7Bwhd-rE" + */ + getVideoUrl() { + return this.target_origin + "/watch?v=" + this.videoId; + } + + /** + * Get title of loaded video. + * + * Default return string + * + * But if set invidious_embed.api_promise true, return Promise. + * @returns {string,Promise} + * @example + * const title = player.getTitle();//invidious_embed.api_promise is false + * const title = await player.getTitle();//invidious_embed.api_promise is true + * //title = "【夏の終わりに】夏祭り/ときのそら【歌ってみた】" + */ + getTitle() { + return this.promise_send_event('gettitle'); + } + + /** + * Get video embed iframe string + * + * Default return string + * + * But if set invidious_embed.api_promise true, return Promise. + * @returns {string,Promise} + * @example + * const embed_code = player.getVideoEmbedCode();//invidious_embed.api_promise is false + * const embed_code = await player.getVideoEmbedCode();//invidious_embed.api_promise is true + * //embed_code = `` + */ + getVideoEmbedCode() { + const embed_url = encodeURI(`${this.target_origin}/embed/${this.videoId}`); + const html_escape = (html) => { + const html_escaped = html.replace(/[&'`"<>]/g, match => { + return { + '&': '&', + "'": ''', + '`': '`', + '"': '"', + '<': '<', + '>': '>', + }[match] + }); + return html_escaped; + } + const iframe_constractor = (raw_title) => { + const html_escaped_title = html_escape(raw_title); + return ``; + } + if (invidious_embed.api_promise) { + return new Promise(async (resolve, reject) => { + resolve(iframe_constractor(await this.getTitle())); + }) + } + else { + return iframe_constractor(this.getTitle()); + } + } + + /** + * Get current playing time start with video 0 seconds + * + * Default return number + * + * But if set invidious_embed.api_promise true, return Promise. + * @returns {number|Promise} + * @example + * const player_time = player.getCurrentTime();//invidious_embed.api_promise is false + * const player_time = await player.getCurrentTime();//invidious_embed.api_promise is true + * //player_time = 80 + */ + getCurrentTime() { + return this.promise_send_event('getcurrenttime'); + } + + /** + * Get video related data. + * + * This function is not compatible with youtube iframe api + * @returns {Promise<{ + * video_id:string, + * title:string, + * list:?string, + * isListed:boolean, + * isLibe:boolean, + * isPremiere:boolean + * }>} + * @example + * const video_data = await player.getVideoData(); + * //video_data = {"video_id": "KqE7Bwhd-rE","title": "【夏の終わりに】夏祭り/ときのそら【歌ってみた】","list": null,"isListed": true,"isLive": false,"isPremiere": false} + */ + async getVideoData() { + const videoData = await this.videodata_api(this.videoId); + return { + video_id: this.videoId, + title: await this.promise_send_event('gettitle'), + list: await this.promise_send_event('getplaylistid'), + isListed: videoData.isListed, + isLive: videoData.liveNow, + isPremiere: videoData.premium + }; + } + + /** + * Get playlist index which count start with 0 + * + * Default return number + * + * But if set invidious_embed.api_promise true, return Promise. + * @returns {number|Promise} + * @example + * const playlist_index = player.getPlaylistIndex();//invidious_embed.api_promise is false + * const playlist_index = await player.getPlaylistIndex();//invidious_embed.api_promise is true + * //playlist_index = 3 + */ + getPlaylistIndex() { + return this.promise_send_event('getplaylistindex'); + } + + /** + * Get playlist videoIds + * @returns {[string]|undefined} + * @example + * const playlist_videoids = player.getPlaylist(); + * //playlist_videoids = ['i50sUufNbzY','BgNVwiX7K8E','L7PCS7afS3Y']; + */ + getPlaylist() { + return this.playlistVideoIds !== undefined ? this.playlistVideoIds : []; + } + + /** + * set loop video or not + * @param {boolean} loopStatus + * @example + * player.setLoop(true); + */ + setLoop(loopStatus) { + if (typeof loopStatus === 'boolean') { + this.loop = loopStatus; + } else { + console.error('setLoop first argument must be bool'); + } + } + + constructor(element, options) { + this.Player(element, options); + window.addEventListener('message', (ms) => { this.receiveMessage(ms) }); + this.message_wait = { + getvolume: [], + getmutestatus: [], + getduration: [], + getcurrenttime: [], + getplaybackrate: [], + getavailableplaybackrates: [], + gettitle: [] + }; + } +} + +/** + * After load iFrame api,function will execute + * + * But this function always execute immediately because iframe api ready mean load complete this js file + * @param {Function} func + */ +function invidious_ready(func) { + if (typeof func === 'function') { + func(); + } + else { + console.warn('invidious.ready first argument must be function'); + } +} + +invidious_embed.invidious_instance = new URL(document.currentScript.src).origin;//set default instance using load origin of js file instance + +const invidious = { + Player: invidious_embed, + PlayerState: { + ENDED: 0, + PLAYING: 1, + PAUSED: 2, + BUFFERING: 3, + CUED: 5 + }, + ready: invidious_ready +}; + +if (typeof onInvidiousIframeAPIReady === 'function') { + try { + onInvidiousIframeAPIReady(); + } catch (e) { + console.error(e); + } +}