From a4e01da27c08e43a67b2618ad1e71c1f8f86d5cd Mon Sep 17 00:00:00 2001 From: Biswakalyan Bhuyan Date: Thu, 19 Sep 2024 15:33:11 +0530 Subject: youtube fronend --- youtube/__init__.py | 166 + youtube/channel.py | 604 ++ youtube/comments.py | 231 + youtube/get_app_version/__init__.py | 1 + youtube/get_app_version/get_app_version.py | 57 + youtube/local_playlist.py | 202 + youtube/opensearch.xml | 11 + youtube/playlist.py | 128 + youtube/proto.py | 221 + youtube/proto_debug.py | 611 ++ youtube/search.py | 120 + youtube/static/channel.css | 572 ++ youtube/static/comments.css | 300 + youtube/static/dark_theme.css | 22 + youtube/static/favicon.ico | Bin 0 -> 5694 bytes youtube/static/gray_theme.css | 22 + youtube/static/home.css | 218 + youtube/static/js/av-merge.js | 987 +++ youtube/static/js/comments.js | 20 + youtube/static/js/common.js | 116 + youtube/static/js/hotkeys.js | 61 + youtube/static/js/playlistadd.js | 86 + youtube/static/js/plyr-start.js | 121 + youtube/static/js/sponsorblock.js | 40 + youtube/static/js/transcript-table.js | 151 + youtube/static/js/watch.js | 199 + youtube/static/license.css | 293 + youtube/static/light_theme.css | 22 + youtube/static/local_playlist.css | 538 ++ youtube/static/message_box.css | 12 + youtube/static/modules/plyr/blank.webm | Bin 0 -> 594 bytes youtube/static/modules/plyr/build-instructions.md | 23 + youtube/static/modules/plyr/custom_plyr.css | 39 + youtube/static/modules/plyr/plyr.css | 1 + youtube/static/modules/plyr/plyr.js | 9320 +++++++++++++++++++++ youtube/static/modules/plyr/plyr.min.js | 4 + youtube/static/modules/plyr/plyr.min.js.map | 1 + youtube/static/modules/plyr/plyr.svg | 1 + youtube/static/normalize.css | 349 + youtube/static/playlist.css | 548 ++ youtube/static/search.css | 522 ++ youtube/static/settings.css | 262 + youtube/static/subscription.css | 542 ++ youtube/static/subscription_manager.css | 366 + youtube/static/unsubscribe.css | 269 + youtube/static/watch.css | 674 ++ youtube/subscriptions.py | 1107 +++ youtube/templates/base.html | 179 + youtube/templates/channel.html | 133 + youtube/templates/comments.html | 65 + youtube/templates/comments_page.html | 47 + youtube/templates/common_elements.html | 140 + youtube/templates/embed.html | 74 + youtube/templates/error.html | 36 + youtube/templates/home.html | 13 + youtube/templates/licenses.html | 64 + youtube/templates/local_playlist.html | 49 + youtube/templates/local_playlists_list.html | 14 + youtube/templates/playlist.html | 41 + youtube/templates/search.html | 34 + youtube/templates/settings.html | 47 + youtube/templates/shared.css | 5 + youtube/templates/status.html | 6 + youtube/templates/subscription_manager.html | 78 + youtube/templates/subscriptions.html | 83 + youtube/templates/subscriptions.xml | 9 + youtube/templates/unsubscribe_verify.html | 21 + youtube/templates/watch.html | 263 + youtube/util.py | 807 ++ youtube/version.py | 3 + youtube/watch.py | 884 ++ youtube/yt_data_extract/__init__.py | 13 + youtube/yt_data_extract/common.py | 610 ++ youtube/yt_data_extract/everything_else.py | 372 + youtube/yt_data_extract/watch_extraction.py | 948 +++ 75 files changed, 25198 insertions(+) create mode 100644 youtube/__init__.py create mode 100644 youtube/channel.py create mode 100644 youtube/comments.py create mode 100644 youtube/get_app_version/__init__.py create mode 100644 youtube/get_app_version/get_app_version.py create mode 100644 youtube/local_playlist.py create mode 100644 youtube/opensearch.xml create mode 100644 youtube/playlist.py create mode 100644 youtube/proto.py create mode 100644 youtube/proto_debug.py create mode 100644 youtube/search.py create mode 100644 youtube/static/channel.css create mode 100644 youtube/static/comments.css create mode 100644 youtube/static/dark_theme.css create mode 100644 youtube/static/favicon.ico create mode 100644 youtube/static/gray_theme.css create mode 100644 youtube/static/home.css create mode 100644 youtube/static/js/av-merge.js create mode 100644 youtube/static/js/comments.js create mode 100644 youtube/static/js/common.js create mode 100644 youtube/static/js/hotkeys.js create mode 100644 youtube/static/js/playlistadd.js create mode 100644 youtube/static/js/plyr-start.js create mode 100644 youtube/static/js/sponsorblock.js create mode 100644 youtube/static/js/transcript-table.js create mode 100644 youtube/static/js/watch.js create mode 100644 youtube/static/license.css create mode 100644 youtube/static/light_theme.css create mode 100644 youtube/static/local_playlist.css create mode 100644 youtube/static/message_box.css create mode 100644 youtube/static/modules/plyr/blank.webm create mode 100644 youtube/static/modules/plyr/build-instructions.md create mode 100644 youtube/static/modules/plyr/custom_plyr.css create mode 100644 youtube/static/modules/plyr/plyr.css create mode 100644 youtube/static/modules/plyr/plyr.js create mode 100644 youtube/static/modules/plyr/plyr.min.js create mode 100644 youtube/static/modules/plyr/plyr.min.js.map create mode 100644 youtube/static/modules/plyr/plyr.svg create mode 100644 youtube/static/normalize.css create mode 100644 youtube/static/playlist.css create mode 100644 youtube/static/search.css create mode 100644 youtube/static/settings.css create mode 100644 youtube/static/subscription.css create mode 100644 youtube/static/subscription_manager.css create mode 100644 youtube/static/unsubscribe.css create mode 100644 youtube/static/watch.css create mode 100644 youtube/subscriptions.py create mode 100644 youtube/templates/base.html create mode 100644 youtube/templates/channel.html create mode 100644 youtube/templates/comments.html create mode 100644 youtube/templates/comments_page.html create mode 100644 youtube/templates/common_elements.html create mode 100644 youtube/templates/embed.html create mode 100644 youtube/templates/error.html create mode 100644 youtube/templates/home.html create mode 100644 youtube/templates/licenses.html create mode 100644 youtube/templates/local_playlist.html create mode 100644 youtube/templates/local_playlists_list.html create mode 100644 youtube/templates/playlist.html create mode 100644 youtube/templates/search.html create mode 100644 youtube/templates/settings.html create mode 100644 youtube/templates/shared.css create mode 100644 youtube/templates/status.html create mode 100644 youtube/templates/subscription_manager.html create mode 100644 youtube/templates/subscriptions.html create mode 100644 youtube/templates/subscriptions.xml create mode 100644 youtube/templates/unsubscribe_verify.html create mode 100644 youtube/templates/watch.html create mode 100644 youtube/util.py create mode 100644 youtube/version.py create mode 100644 youtube/watch.py create mode 100644 youtube/yt_data_extract/__init__.py create mode 100644 youtube/yt_data_extract/common.py create mode 100644 youtube/yt_data_extract/everything_else.py create mode 100644 youtube/yt_data_extract/watch_extraction.py (limited to 'youtube') diff --git a/youtube/__init__.py b/youtube/__init__.py new file mode 100644 index 0000000..64aed56 --- /dev/null +++ b/youtube/__init__.py @@ -0,0 +1,166 @@ +from youtube import util +from .get_app_version import app_version +import flask +from flask import request +import jinja2 +import settings +import traceback +import re +from sys import exc_info +yt_app = flask.Flask(__name__) +yt_app.config['TEMPLATES_AUTO_RELOAD'] = True +yt_app.url_map.strict_slashes = False +# yt_app.jinja_env.trim_blocks = True +# yt_app.jinja_env.lstrip_blocks = True + + +yt_app.add_url_rule('/settings', 'settings_page', settings.settings_page, methods=['POST', 'GET']) + + +@yt_app.route('/') +def homepage(): + return flask.render_template('home.html', title="YT Local") + + +@yt_app.route('/licenses') +def licensepage(): + return flask.render_template( + 'licenses.html', + title="Licenses - YT Local" + ) + + +theme_names = { + 0: 'light_theme', + 1: 'gray_theme', + 2: 'dark_theme', +} + + +@yt_app.context_processor +def inject_theme_preference(): + return { + 'theme_path': '/youtube.com/static/' + theme_names[settings.theme] + '.css', + 'settings': settings, + # Detect version + 'current_version': app_version()['version'], + 'current_branch': app_version()['branch'], + 'current_commit': app_version()['commit'], + } + + +@yt_app.template_filter('commatize') +def commatize(num): + if num is None: + return '' + if isinstance(num, str): + try: + num = int(num) + except ValueError: + return num + return '{:,}'.format(num) + + +def timestamp_replacement(match): + time_seconds = 0 + for part in match.group(0).split(':'): + time_seconds = 60*time_seconds + int(part) + return ( + """ + %s + + """ % ( + str(time_seconds), + match.group(0), + str(time_seconds), + str(time_seconds) + ) + ) + + +TIMESTAMP_RE = re.compile(r'\b(\d?\d:)?\d?\d:\d\d\b') + + +@yt_app.template_filter('timestamps') +def timestamps(text): + return TIMESTAMP_RE.sub(timestamp_replacement, text) + + +@yt_app.errorhandler(500) +def error_page(e): + slim = request.args.get('slim', False) # whether it was an ajax request + if (exc_info()[0] == util.FetchError + and exc_info()[1].code == '429' + and settings.route_tor + ): + error_message = ('Error: YouTube blocked the request because the Tor' + ' exit node is overutilized. Try getting a new exit node by' + ' using the New Identity button in the Tor Browser.') + if exc_info()[1].error_message: + error_message += '\n\n' + exc_info()[1].error_message + if exc_info()[1].ip: + error_message += '\n\nExit node IP address: ' + exc_info()[1].ip + return flask.render_template('error.html', error_message=error_message, slim=slim), 502 + elif exc_info()[0] == util.FetchError and exc_info()[1].error_message: + return (flask.render_template( + 'error.html', + error_message=exc_info()[1].error_message, + slim=slim + ), 502) + elif (exc_info()[0] == util.FetchError + and exc_info()[1].code == '404' + ): + error_message = ('Error: The page you are looking for isn\'t here. ¯\_(ツ)_/¯') + return flask.render_template('error.html', + error_code=exc_info()[1].code, + error_message=error_message, + slim=slim), 404 + return flask.render_template('error.html', traceback=traceback.format_exc(), + error_code=exc_info()[1].code, + slim=slim), 500 + # return flask.render_template('error.html', traceback=traceback.format_exc(), slim=slim), 500 + + +font_choices = { + 0: 'initial', + 1: '"liberation serif", "times new roman", calibri, carlito, serif', + 2: 'arial, "liberation sans", sans-serif', + 3: 'verdana, sans-serif', + 4: 'tahoma, sans-serif', +} + + +@yt_app.route('/shared.css') +def get_css(): + return flask.Response( + flask.render_template( + 'shared.css', + font_family=font_choices[settings.font] + ), + mimetype='text/css', + ) + + +# This is okay because the flask urlize function puts the href as the first +# property +YOUTUBE_LINK_RE = re.compile(r' 1 doesn't work when sorting by oldest + offset = 30*(int(page) - 1) + page_token = proto.string(61, proto.unpadded_b64encode( + proto.string(1, proto.unpadded_b64encode(proto.uint(1,offset))) + )) + + tab = proto.string(2, tab) + sort = proto.uint(3, int(sort)) + + shelf_view = proto.uint(4, 0) + view = proto.uint(6, int(view)) + continuation_info = proto.string(3, + proto.percent_b64encode(tab + sort + shelf_view + view + page_token) + ) + + channel_id = proto.string(2, channel_id) + pointless_nest = proto.string(80226972, channel_id + continuation_info) + + return base64.urlsafe_b64encode(pointless_nest).decode('ascii') + + +def channel_ctoken_v2(channel_id, page, sort, tab, view=1): + # see https://github.com/iv-org/invidious/issues/1319#issuecomment-671732646 + # page > 1 doesn't work when sorting by oldest + offset = 30*(int(page) - 1) + schema_number = { + 3: 6307666885028338688, + 2: 17254859483345278706, + 1: 16570086088270825023, + }[int(sort)] + page_token = proto.string(61, proto.unpadded_b64encode(proto.string(1, + proto.uint(1, schema_number) + proto.string(2, + proto.string(1, proto.unpadded_b64encode(proto.uint(1,offset))) + ) + ))) + + tab = proto.string(2, tab) + sort = proto.uint(3, int(sort)) + #page = proto.string(15, str(page)) + + shelf_view = proto.uint(4, 0) + view = proto.uint(6, int(view)) + continuation_info = proto.string( + 3, + proto.percent_b64encode(tab + sort + shelf_view + view + page_token) + ) + + channel_id = proto.string(2, channel_id) + pointless_nest = proto.string(80226972, channel_id + continuation_info) + + return base64.urlsafe_b64encode(pointless_nest).decode('ascii') + + +def channel_ctoken_v1(channel_id, page, sort, tab, view=1): + tab = proto.string(2, tab) + sort = proto.uint(3, int(sort)) + page = proto.string(15, str(page)) + # example with shelves in videos tab: https://www.youtube.com/channel/UCNL1ZadSjHpjm4q9j2sVtOA/videos + shelf_view = proto.uint(4, 0) + view = proto.uint(6, int(view)) + continuation_info = proto.string(3, proto.percent_b64encode(tab + view + sort + shelf_view + page + proto.uint(23, 0)) ) + + channel_id = proto.string(2, channel_id) + pointless_nest = proto.string(80226972, channel_id + continuation_info) + + return base64.urlsafe_b64encode(pointless_nest).decode('ascii') + + +def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1, + ctoken=None, print_status=True): + message = 'Got channel tab' if print_status else None + + if not ctoken: + if tab in ('videos', 'shorts', 'streams'): + ctoken = channel_ctoken_v5(channel_id, page, sort, tab, view) + else: + ctoken = channel_ctoken_v3(channel_id, page, sort, tab, view) + ctoken = ctoken.replace('=', '%3D') + + # Not sure what the purpose of the key is or whether it will change + # For now it seems to be constant for the API endpoint, not dependent + # on the browsing session or channel + key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key + + data = { + 'context': { + 'client': { + 'hl': 'en', + 'gl': 'US', + 'clientName': 'WEB', + 'clientVersion': '2.20240327.00.00', + }, + }, + 'continuation': ctoken, + } + + content_type_header = (('Content-Type', 'application/json'),) + content = util.fetch_url( + url, headers_desktop + content_type_header, + data=json.dumps(data), debug_name='channel_tab', report_text=message) + + return content + + +# cache entries expire after 30 minutes +number_of_videos_cache = cachetools.TTLCache(128, 30*60) +@cachetools.cached(number_of_videos_cache) +def get_number_of_videos_channel(channel_id): + if channel_id is None: + return 1000 + + # Uploads playlist + playlist_id = 'UU' + channel_id[2:] + url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1' + + try: + response = util.fetch_url(url, headers_mobile, + debug_name='number_of_videos', report_text='Got number of videos') + except urllib.error.HTTPError as e: + traceback.print_exc() + print("Couldn't retrieve number of videos") + return 1000 + + response = response.decode('utf-8') + + # match = re.search(r'"numVideosText":\s*{\s*"runs":\s*\[{"text":\s*"([\d,]*) videos"', response) + match = re.search(r'"numVideosText".*?([,\d]+)', response) + if match: + return int(match.group(1).replace(',','')) + else: + return 0 +def set_cached_number_of_videos(channel_id, num_videos): + @cachetools.cached(number_of_videos_cache) + def dummy_func_using_same_cache(channel_id): + return num_videos + dummy_func_using_same_cache(channel_id) + + +channel_id_re = re.compile(r'videos\.xml\?channel_id=([a-zA-Z0-9_-]{24})"') +@cachetools.func.lru_cache(maxsize=128) +def get_channel_id(base_url): + # method that gives the smallest possible response at ~4 kb + # needs to be as fast as possible + base_url = base_url.replace('https://www', 'https://m') # avoid redirect + response = util.fetch_url(base_url + '/about?pbj=1', headers_mobile, + debug_name='get_channel_id', report_text='Got channel id').decode('utf-8') + match = channel_id_re.search(response) + if match: + return match.group(1) + return None + + +metadata_cache = cachetools.LRUCache(128) +@cachetools.cached(metadata_cache) +def get_metadata(channel_id): + base_url = 'https://www.youtube.com/channel/' + channel_id + polymer_json = util.fetch_url(base_url + '/about?pbj=1', + headers_desktop, + debug_name='gen_channel_about', + report_text='Retrieved channel metadata') + info = yt_data_extract.extract_channel_info(json.loads(polymer_json), + 'about', + continuation=False) + return extract_metadata_for_caching(info) +def set_cached_metadata(channel_id, metadata): + @cachetools.cached(metadata_cache) + def dummy_func_using_same_cache(channel_id): + return metadata + dummy_func_using_same_cache(channel_id) +def extract_metadata_for_caching(channel_info): + metadata = {} + for key in ('approx_subscriber_count', 'short_description', 'channel_name', + 'avatar'): + metadata[key] = channel_info[key] + return metadata + + +def get_number_of_videos_general(base_url): + return get_number_of_videos_channel(get_channel_id(base_url)) + + +def get_channel_search_json(channel_id, query, page): + offset = proto.unpadded_b64encode(proto.uint(3, (page-1)*30)) + params = proto.string(2, 'search') + proto.string(15, offset) + params = proto.percent_b64encode(params) + ctoken = proto.string(2, channel_id) + proto.string(3, params) + proto.string(11, query) + ctoken = base64.urlsafe_b64encode(proto.nested(80226972, ctoken)).decode('ascii') + + key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key + + data = { + 'context': { + 'client': { + 'hl': 'en', + 'gl': 'US', + 'clientName': 'WEB', + 'clientVersion': '2.20240327.00.00', + }, + }, + 'continuation': ctoken, + } + + content_type_header = (('Content-Type', 'application/json'),) + polymer_json = util.fetch_url( + url, headers_desktop + content_type_header, + data=json.dumps(data), debug_name='channel_search') + + return polymer_json + + +def post_process_channel_info(info): + info['avatar'] = util.prefix_url(info['avatar']) + info['channel_url'] = util.prefix_url(info['channel_url']) + for item in info['items']: + item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id']) + util.prefix_urls(item) + util.add_extra_html_info(item) + if info['current_tab'] == 'about': + for i, (text, url) in enumerate(info['links']): + if isinstance(url, str) and util.YOUTUBE_URL_RE.fullmatch(url): + info['links'][i] = (text, util.prefix_url(url)) + + +def get_channel_first_page(base_url=None, tab='videos', channel_id=None): + if channel_id: + base_url = 'https://www.youtube.com/channel/' + channel_id + return util.fetch_url(base_url + '/' + tab + '?pbj=1&view=0', + headers_desktop, debug_name='gen_channel_' + tab) + + +playlist_sort_codes = {'2': "da", '3': "dd", '4': "lad"} + +# youtube.com/[channel_id]/[tab] +# youtube.com/user/[username]/[tab] +# youtube.com/c/[custom]/[tab] +# youtube.com/[custom]/[tab] +def get_channel_page_general_url(base_url, tab, request, channel_id=None): + + page_number = int(request.args.get('page', 1)) + # sort 1: views + # sort 2: oldest + # sort 3: newest + # sort 4: newest - no shorts (Just a kludge on our end, not internal to yt) + default_sort = '3' if settings.include_shorts_in_channel else '4' + sort = request.args.get('sort', default_sort) + view = request.args.get('view', '1') + query = request.args.get('query', '') + ctoken = request.args.get('ctoken', '') + include_shorts = (sort != '4') + default_params = (page_number == 1 and sort in ('3', '4') and view == '1') + continuation = bool(ctoken) # whether or not we're using a continuation + page_size = 30 + try_channel_api = True + polymer_json = None + + # Use the special UU playlist which contains all the channel's uploads + if tab == 'videos' and sort in ('3', '4'): + if not channel_id: + channel_id = get_channel_id(base_url) + if page_number == 1 and include_shorts: + tasks = ( + gevent.spawn(playlist.playlist_first_page, + 'UU' + channel_id[2:], + report_text='Retrieved channel videos'), + gevent.spawn(get_metadata, channel_id), + ) + gevent.joinall(tasks) + util.check_gevent_exceptions(*tasks) + + # Ignore the metadata for now, it is cached and will be + # recalled later + pl_json = tasks[0].value + pl_info = yt_data_extract.extract_playlist_info(pl_json) + number_of_videos = pl_info['metadata']['video_count'] + if number_of_videos is None: + number_of_videos = 1000 + else: + set_cached_number_of_videos(channel_id, number_of_videos) + else: + tasks = ( + gevent.spawn(playlist.get_videos, 'UU' + channel_id[2:], + page_number, include_shorts=include_shorts), + gevent.spawn(get_metadata, channel_id), + gevent.spawn(get_number_of_videos_channel, channel_id), + ) + gevent.joinall(tasks) + util.check_gevent_exceptions(*tasks) + + pl_json = tasks[0].value + pl_info = yt_data_extract.extract_playlist_info(pl_json) + number_of_videos = tasks[2].value + + info = pl_info + info['channel_id'] = channel_id + info['current_tab'] = 'videos' + if info['items']: # Success + page_size = 100 + try_channel_api = False + else: # Try the first-page method next + try_channel_api = True + + # Use the regular channel API + if tab in ('shorts', 'streams') or (tab=='videos' and try_channel_api): + if channel_id: + num_videos_call = (get_number_of_videos_channel, channel_id) + else: + num_videos_call = (get_number_of_videos_general, base_url) + + # Use ctoken method, which YouTube changes all the time + if channel_id and not default_params: + if sort == 4: + _sort = 3 + else: + _sort = sort + page_call = (get_channel_tab, channel_id, page_number, _sort, + tab, view, ctoken) + # Use the first-page method, which won't break + else: + page_call = (get_channel_first_page, base_url, tab) + + tasks = ( + gevent.spawn(*num_videos_call), + gevent.spawn(*page_call), + ) + gevent.joinall(tasks) + util.check_gevent_exceptions(*tasks) + number_of_videos, polymer_json = tasks[0].value, tasks[1].value + + elif tab == 'about': + # polymer_json = util.fetch_url(base_url + '/about?pbj=1', headers_desktop, debug_name='gen_channel_about') + channel_id = get_channel_id(base_url) + ctoken = channel_about_ctoken(channel_id) + polymer_json = util.call_youtube_api('web', 'browse', { + 'continuation': ctoken, + }) + continuation=True + elif tab == 'playlists' and page_number == 1: + polymer_json = util.fetch_url(base_url+ '/playlists?pbj=1&view=1&sort=' + playlist_sort_codes[sort], headers_desktop, debug_name='gen_channel_playlists') + elif tab == 'playlists': + polymer_json = get_channel_tab(channel_id, page_number, sort, + 'playlists', view) + continuation = True + elif tab == 'search' and channel_id: + polymer_json = get_channel_search_json(channel_id, query, page_number) + elif tab == 'search': + url = base_url + '/search?pbj=1&query=' + urllib.parse.quote(query, safe='') + polymer_json = util.fetch_url(url, headers_desktop, debug_name='gen_channel_search') + elif tab == 'videos': + pass + else: + flask.abort(404, 'Unknown channel tab: ' + tab) + + if polymer_json is not None: + info = yt_data_extract.extract_channel_info( + json.loads(polymer_json), tab, continuation=continuation + ) + + if info['error'] is not None: + return flask.render_template('error.html', error_message=info['error']) + + if channel_id: + info['channel_url'] = 'https://www.youtube.com/channel/' + channel_id + info['channel_id'] = channel_id + else: + channel_id = info['channel_id'] + + # Will have microformat present, cache metadata while we have it + if channel_id and default_params and tab not in ('videos', 'about'): + metadata = extract_metadata_for_caching(info) + set_cached_metadata(channel_id, metadata) + # Otherwise, populate with our (hopefully cached) metadata + elif channel_id and info.get('channel_name') is None: + metadata = get_metadata(channel_id) + for key, value in metadata.items(): + yt_data_extract.conservative_update(info, key, value) + # need to add this metadata to the videos/playlists + additional_info = { + 'author': info['channel_name'], + 'author_id': info['channel_id'], + 'author_url': info['channel_url'], + } + for item in info['items']: + item.update(additional_info) + + if tab in ('videos', 'shorts', 'streams'): + info['number_of_videos'] = number_of_videos + info['number_of_pages'] = math.ceil(number_of_videos/page_size) + info['header_playlist_names'] = local_playlist.get_playlist_names() + if tab in ('videos', 'shorts', 'streams', 'playlists'): + info['current_sort'] = sort + elif tab == 'search': + info['search_box_value'] = query + info['header_playlist_names'] = local_playlist.get_playlist_names() + if tab in ('search', 'playlists'): + info['page_number'] = page_number + info['subscribed'] = subscriptions.is_subscribed(info['channel_id']) + + post_process_channel_info(info) + + return flask.render_template('channel.html', + parameters_dictionary = request.args, + **info + ) + + +@yt_app.route('/channel//') +@yt_app.route('/channel//') +def get_channel_page(channel_id, tab='videos'): + return get_channel_page_general_url('https://www.youtube.com/channel/' + channel_id, tab, request, channel_id) + + +@yt_app.route('/user//') +@yt_app.route('/user//') +def get_user_page(username, tab='videos'): + return get_channel_page_general_url('https://www.youtube.com/user/' + username, tab, request) + + +@yt_app.route('/c//') +@yt_app.route('/c//') +def get_custom_c_page(custom, tab='videos'): + return get_channel_page_general_url('https://www.youtube.com/c/' + custom, tab, request) + + +@yt_app.route('/') +@yt_app.route('//') +def get_toplevel_custom_page(custom, tab='videos'): + return get_channel_page_general_url('https://www.youtube.com/' + custom, tab, request) diff --git a/youtube/comments.py b/youtube/comments.py new file mode 100644 index 0000000..92a89e1 --- /dev/null +++ b/youtube/comments.py @@ -0,0 +1,231 @@ +from youtube import proto, util, yt_data_extract +from youtube.util import ( + concat_or_none, + strip_non_ascii +) +from youtube import yt_app +import settings + +import json +import base64 + +import flask +from flask import request + +# Here's what I know about the secret key (starting with ASJN_i) +# *The secret key definitely contains the following information (or perhaps the information is stored at youtube's servers): +# -Video id +# -Offset +# -Sort +# *If the video id or sort in the ctoken contradicts the ASJN, the response is an error. The offset encoded outside the ASJN is ignored entirely. +# *The ASJN is base64 encoded data, indicated by the fact that the character after "ASJN_i" is one of ("0", "1", "2", "3") +# *The encoded data is not valid protobuf +# *The encoded data (after the 5 or so bytes that are always the same) is indistinguishable from random data according to a battery of randomness tests +# *The ASJN in the ctoken provided by a response changes in regular intervals of about a second or two. +# *Old ASJN's continue to work, and start at the same comment even if new comments have been posted since +# *The ASJN has no relation with any of the data in the response it came from + + +def make_comment_ctoken(video_id, sort=0, offset=0, lc='', secret_key=''): + video_id = proto.as_bytes(video_id) + secret_key = proto.as_bytes(secret_key) + + + page_info = proto.string(4, video_id) + proto.uint(6, sort) + offset_information = proto.nested(4, page_info) + proto.uint(5, offset) + if secret_key: + offset_information = proto.string(1, secret_key) + offset_information + + page_params = proto.string(2, video_id) + if lc: + page_params += proto.string(6, proto.percent_b64encode(proto.string(15, lc))) + + result = proto.nested(2, page_params) + proto.uint(3, 6) + proto.nested(6, offset_information) + return base64.urlsafe_b64encode(result).decode('ascii') + + +def request_comments(ctoken, replies=False): + url = 'https://m.youtube.com/youtubei/v1/next' + url += '?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + data = json.dumps({ + 'context': { + 'client': { + 'hl': 'en', + 'gl': 'US', + 'clientName': 'MWEB', + 'clientVersion': '2.20240328.08.00', + }, + }, + 'continuation': ctoken.replace('=', '%3D'), + }) + + content = util.fetch_url( + url, headers=util.mobile_xhr_headers + util.json_header, data=data, + report_text='Retrieved comments', debug_name='request_comments') + content = content.decode('utf-8') + + polymer_json = json.loads(content) + return polymer_json + + +def single_comment_ctoken(video_id, comment_id): + page_params = proto.string(2, video_id) + proto.string( + 6, proto.percent_b64encode(proto.string(15, comment_id))) + + result = proto.nested(2, page_params) + proto.uint(3, 6) + return base64.urlsafe_b64encode(result).decode('ascii') + + +def post_process_comments_info(comments_info): + for comment in comments_info['comments']: + comment['author'] = strip_non_ascii(comment['author']) + comment['author_url'] = concat_or_none( + '/', comment['author_url']) + comment['author_avatar'] = concat_or_none( + settings.img_prefix, comment['author_avatar']) + + comment['permalink'] = concat_or_none( + util.URL_ORIGIN, '/watch?v=', + comments_info['video_id'], + '&lc=', comment['id'] + ) + + reply_count = comment['reply_count'] + comment['replies_url'] = None + if comment['reply_ctoken']: + # change max_replies field to 250 in ctoken + ctoken = comment['reply_ctoken'] + ctoken, err = proto.set_protobuf_value( + ctoken, + 'base64p', 6, 3, 9, value=200) + if err: + print('Error setting ctoken value:') + print(err) + comment['replies_url'] = None + comment['replies_url'] = concat_or_none( + util.URL_ORIGIN, + '/comments?replies=1&ctoken=' + ctoken) + + if reply_count == 0: + comment['view_replies_text'] = 'Reply' + elif reply_count == 1: + comment['view_replies_text'] = '1 reply' + else: + comment['view_replies_text'] = str(reply_count) + ' replies' + + if comment['approx_like_count'] == '1': + comment['likes_text'] = '1 like' + else: + comment['likes_text'] = (str(comment['approx_like_count']) + + ' likes') + + comments_info['include_avatars'] = settings.enable_comment_avatars + if comments_info['ctoken']: + ctoken = comments_info['ctoken'] + if comments_info['is_replies']: + replies_param = '&replies=1' + # change max_replies field to 250 in ctoken + new_ctoken, err = proto.set_protobuf_value( + ctoken, + 'base64p', 6, 3, 9, value=200) + if err: + print('Error setting ctoken value:') + print(err) + else: + ctoken = new_ctoken + else: + replies_param = '' + comments_info['more_comments_url'] = concat_or_none( + util.URL_ORIGIN, '/comments?ctoken=', ctoken, replies_param) + + if comments_info['offset'] is None: + comments_info['page_number'] = None + else: + comments_info['page_number'] = int(comments_info['offset']/20) + 1 + + if not comments_info['is_replies']: + comments_info['sort_text'] = 'top' if comments_info['sort'] == 0 else 'newest' + + comments_info['video_url'] = concat_or_none( + util.URL_ORIGIN, '/watch?v=', comments_info['video_id']) + comments_info['video_thumbnail'] = concat_or_none( + settings.img_prefix, 'https://i.ytimg.com/vi/', + comments_info['video_id'], '/hqdefault.jpg' + ) + + +def video_comments(video_id, sort=0, offset=0, lc='', secret_key=''): + try: + if settings.comments_mode: + comments_info = {'error': None} + other_sort_url = ( + util.URL_ORIGIN + '/comments?ctoken=' + + make_comment_ctoken(video_id, sort=1 - sort, lc=lc) + ) + other_sort_text = 'Sort by ' + ('newest' if sort == 0 else 'top') + + this_sort_url = (util.URL_ORIGIN + + '/comments?ctoken=' + + make_comment_ctoken(video_id, sort=sort, lc=lc)) + + comments_info['comment_links'] = [ + (other_sort_text, other_sort_url), + ('Direct link', this_sort_url) + ] + + ctoken = make_comment_ctoken(video_id, sort, offset, lc) + comments_info.update(yt_data_extract.extract_comments_info( + request_comments(ctoken), ctoken=ctoken + )) + post_process_comments_info(comments_info) + + return comments_info + else: + return {} + except util.FetchError as e: + if e.code == '429' and settings.route_tor: + comments_info['error'] = 'Error: YouTube blocked the request because the Tor exit node is overutilized.' + if e.error_message: + comments_info['error'] += '\n\n' + e.error_message + comments_info['error'] += '\n\nExit node IP address: %s' % e.ip + else: + comments_info['error'] = 'YouTube blocked the request. IP address: %s' % e.ip + + except Exception as e: + comments_info['error'] = 'YouTube blocked the request. IP address: %s' % e.ip + + if comments_info.get('error'): + print('Error retrieving comments for ' + str(video_id) + ':\n' + + comments_info['error']) + + return comments_info + + +@yt_app.route('/comments') +def get_comments_page(): + ctoken = request.args.get('ctoken', '') + replies = request.args.get('replies', '0') == '1' + + comments_info = yt_data_extract.extract_comments_info( + request_comments(ctoken, replies), ctoken=ctoken + ) + post_process_comments_info(comments_info) + + if not replies: + if comments_info['sort'] is None or comments_info['video_id'] is None: + other_sort_url = None + else: + other_sort_url = ( + util.URL_ORIGIN + + '/comments?ctoken=' + + make_comment_ctoken(comments_info['video_id'], + sort=1-comments_info['sort']) + ) + other_sort_text = 'Sort by ' + ('newest' if comments_info['sort'] == 0 else 'top') + comments_info['comment_links'] = [(other_sort_text, other_sort_url)] + + return flask.render_template( + 'comments_page.html', + comments_info=comments_info, + slim=request.args.get('slim', False) + ) diff --git a/youtube/get_app_version/__init__.py b/youtube/get_app_version/__init__.py new file mode 100644 index 0000000..2d5290f --- /dev/null +++ b/youtube/get_app_version/__init__.py @@ -0,0 +1 @@ +from .get_app_version import * diff --git a/youtube/get_app_version/get_app_version.py b/youtube/get_app_version/get_app_version.py new file mode 100644 index 0000000..9852359 --- /dev/null +++ b/youtube/get_app_version/get_app_version.py @@ -0,0 +1,57 @@ +from __future__ import unicode_literals +from subprocess import ( + call, + STDOUT +) +from ..version import __version__ +import os +import subprocess + + +def app_version(): + def minimal_env_cmd(cmd): + # make minimal environment + env = {} + for k in ['SYSTEMROOT', 'PATH']: + v = os.environ.get(k) + if v is not None: + env[k] = v + + env['LANGUAGE'] = 'C' + env['LANG'] = 'C' + env['LC_ALL'] = 'C' + out = subprocess.Popen( + cmd, stdout=subprocess.PIPE, env=env).communicate()[0] + return out + + subst_list = { + "version": __version__, + "branch": None, + "commit": None + } + + if os.system("command -v git > /dev/null 2>&1") != 0: + subst_list + else: + if call(["git", "branch"], stderr=STDOUT, + stdout=open(os.devnull, 'w')) != 0: + subst_list + else: + # version + describe = minimal_env_cmd(["git", "describe", "--always"]) + git_revision = describe.strip().decode('ascii') + # branch + branch = minimal_env_cmd(["git", "branch"]) + git_branch = branch.strip().decode('ascii').replace('* ', '') + + subst_list = { + "version": __version__, + "branch": git_branch, + "commit": git_revision + } + + return subst_list + + +if __name__ == "__main__": + app_version() diff --git a/youtube/local_playlist.py b/youtube/local_playlist.py new file mode 100644 index 0000000..aa3ac27 --- /dev/null +++ b/youtube/local_playlist.py @@ -0,0 +1,202 @@ +from youtube import util, yt_data_extract +from youtube import yt_app +import settings + +import os +import json +import html +import gevent +import urllib +import math + +import flask +from flask import request + +playlists_directory = os.path.join(settings.data_dir, "playlists") +thumbnails_directory = os.path.join(settings.data_dir, "playlist_thumbnails") + + +def video_ids_in_playlist(name): + try: + with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file: + videos = file.read() + return set(json.loads(video)['id'] for video in videos.splitlines()) + except FileNotFoundError: + return set() + + +def add_to_playlist(name, video_info_list): + if not os.path.exists(playlists_directory): + os.makedirs(playlists_directory) + ids = video_ids_in_playlist(name) + missing_thumbnails = [] + with open(os.path.join(playlists_directory, name + ".txt"), "a", encoding='utf-8') as file: + for info in video_info_list: + id = json.loads(info)['id'] + if id not in ids: + file.write(info + "\n") + missing_thumbnails.append(id) + gevent.spawn(util.download_thumbnails, os.path.join(thumbnails_directory, name), missing_thumbnails) + + +def add_extra_info_to_videos(videos, playlist_name): + '''Adds extra information necessary for rendering the video item HTML + Downloads missing thumbnails''' + try: + thumbnails = set(os.listdir(os.path.join(thumbnails_directory, + playlist_name))) + except FileNotFoundError: + thumbnails = set() + missing_thumbnails = [] + + for video in videos: + video['type'] = 'video' + util.add_extra_html_info(video) + if video['id'] + '.jpg' in thumbnails: + video['thumbnail'] = ( + '/https://youtube.com/data/playlist_thumbnails/' + + playlist_name + + '/' + video['id'] + '.jpg') + else: + video['thumbnail'] = util.get_thumbnail_url(video['id']) + missing_thumbnails.append(video['id']) + + gevent.spawn(util.download_thumbnails, + os.path.join(thumbnails_directory, playlist_name), + missing_thumbnails) + + +def read_playlist(name): + '''Returns a list of videos for the given playlist name''' + playlist_path = os.path.join(playlists_directory, name + '.txt') + with open(playlist_path, 'r', encoding='utf-8') as f: + data = f.read() + + videos = [] + videos_json = data.splitlines() + for video_json in videos_json: + try: + info = json.loads(video_json) + videos.append(info) + except json.decoder.JSONDecodeError: + if not video_json.strip() == '': + print('Corrupt playlist video entry: ' + video_json) + return videos + + +def get_local_playlist_videos(name, offset=0, amount=50): + videos = read_playlist(name) + add_extra_info_to_videos(videos, name) + return videos[offset:offset+amount], len(videos) + + +def get_playlist_names(): + try: + items = os.listdir(playlists_directory) + except FileNotFoundError: + return + for item in items: + name, ext = os.path.splitext(item) + if ext == '.txt': + yield name + + +def remove_from_playlist(name, video_info_list): + ids = [json.loads(video)['id'] for video in video_info_list] + with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file: + videos = file.read() + videos_in = videos.splitlines() + videos_out = [] + for video in videos_in: + if json.loads(video)['id'] not in ids: + videos_out.append(video) + with open(os.path.join(playlists_directory, name + ".txt"), 'w', encoding='utf-8') as file: + file.write("\n".join(videos_out) + "\n") + + try: + thumbnails = set(os.listdir(os.path.join(thumbnails_directory, name))) + except FileNotFoundError: + pass + else: + to_delete = thumbnails & set(id + ".jpg" for id in ids) + for file in to_delete: + os.remove(os.path.join(thumbnails_directory, name, file)) + + return len(videos_out) + + +@yt_app.route('/playlists', methods=['GET']) +@yt_app.route('/playlists/', methods=['GET']) +def get_local_playlist_page(playlist_name=None): + if playlist_name is None: + playlists = [(name, util.URL_ORIGIN + '/playlists/' + name) for name in get_playlist_names()] + return flask.render_template('local_playlists_list.html', playlists=playlists) + else: + page = int(request.args.get('page', 1)) + offset = 50*(page - 1) + videos, num_videos = get_local_playlist_videos(playlist_name, offset=offset, amount=50) + return flask.render_template( + 'local_playlist.html', + header_playlist_names=get_playlist_names(), + playlist_name=playlist_name, + videos=videos, + num_pages=math.ceil(num_videos/50), + parameters_dictionary=request.args, + ) + + +@yt_app.route('/playlists/', methods=['POST']) +def path_edit_playlist(playlist_name): + '''Called when making changes to the playlist from that playlist's page''' + if request.values['action'] == 'remove': + videos_to_remove = request.values.getlist('video_info_list') + number_of_videos_remaining = remove_from_playlist(playlist_name, videos_to_remove) + redirect_page_number = min(int(request.values.get('page', 1)), math.ceil(number_of_videos_remaining/50)) + return flask.redirect(util.URL_ORIGIN + request.path + '?page=' + str(redirect_page_number)) + elif request.values['action'] == 'remove_playlist': + try: + os.remove(os.path.join(playlists_directory, playlist_name + ".txt")) + except OSError: + pass + return flask.redirect(util.URL_ORIGIN + '/playlists') + elif request.values['action'] == 'export': + videos = read_playlist(playlist_name) + fmt = request.values['export_format'] + if fmt in ('ids', 'urls'): + prefix = '' + if fmt == 'urls': + prefix = 'https://www.youtube.com/watch?v=' + id_list = '\n'.join(prefix + v['id'] for v in videos) + id_list += '\n' + resp = flask.Response(id_list, mimetype='text/plain') + cd = 'attachment; filename="%s.txt"' % playlist_name + resp.headers['Content-Disposition'] = cd + return resp + elif fmt == 'json': + json_data = json.dumps({'videos': videos}, indent=2, + sort_keys=True) + resp = flask.Response(json_data, mimetype='text/json') + cd = 'attachment; filename="%s.json"' % playlist_name + resp.headers['Content-Disposition'] = cd + return resp + else: + flask.abort(400) + else: + flask.abort(400) + + +@yt_app.route('/edit_playlist', methods=['POST']) +def edit_playlist(): + '''Called when adding videos to a playlist from elsewhere''' + if request.values['action'] == 'add': + add_to_playlist(request.values['playlist_name'], request.values.getlist('video_info_list')) + return '', 204 + else: + flask.abort(400) + + +@yt_app.route('/data/playlist_thumbnails//') +def serve_thumbnail(playlist_name, thumbnail): + # .. is necessary because flask always uses the application directory at ./youtube, not the working directory + return flask.send_from_directory( + os.path.join('..', thumbnails_directory, playlist_name), thumbnail) diff --git a/youtube/opensearch.xml b/youtube/opensearch.xml new file mode 100644 index 0000000..09d1cb7 --- /dev/null +++ b/youtube/opensearch.xml @@ -0,0 +1,11 @@ + +YT Local +no CIA shit in the background +UTF-8 +data:image/x-icon;base64,AAABAAEAEBAAAAEACAAlAgAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAexJREFUOI2lkzFPmlEUhp/73fshtCUCRtvQkJoKMrDQJvoHnBzUhc3EH0DUQf+As6tujo4M6mTiIDp0kGiMTRojTRNSW6o12iD4YYXv3g7Qr4O0ScM7npz7vOe+J0fk83lDF7K6eQygwkdHhI+P0bYNxmBXq5RmZui5vGQgn0f7fKi7O4oLC1gPD48BP9JpnpRKJFZXcQMB3m1u4vr9NHp76d/bo39/n4/z84ROThBa4/r91OJxMKb9BSn5mskAIOt1eq6uEFpjVyrEcjk+T0+TXlzkbTZLuFDAur9/nIFRipuREQCe7+zgBgK8mZvj/fIylVTKa/6UzXKbSnnuHkA0GnwbH/cA0a0takND3IyOEiwWAXBiMYTWjzLwtvB9bAyAwMUF8ZUVPiwtYTWbHqA6PIxoNv8OMLbN3eBga9TZWYQxaKX+AJJJhOv+AyAlT0slAG6TSX5n8+zszJugkzxA4PzcK9YSCQCk42DXaq1aGwqgfT5ebG9jpMQyUjKwu8vrtbWWqxC83NjAd31NsO2uleJnX58HCJ6eEjk8BGNQAA+RCOXJScpTU2AMwnUxlkXk4ACA+2iUSKGArNeRjkMsl6M8MYHQGtHpmIxSvFpfRzoORinQGqvZBCEwQoAxfMlkaIRCnQH/o66v8Re19MavaDNLfgAAAABJRU5ErkJggg== + + + + +$main_url/youtube.com/results + diff --git a/youtube/playlist.py b/youtube/playlist.py new file mode 100644 index 0000000..83d530c --- /dev/null +++ b/youtube/playlist.py @@ -0,0 +1,128 @@ +from youtube import util, yt_data_extract, proto, local_playlist +from youtube import yt_app +import settings + +import base64 +import urllib +import json +import string +import gevent +import math +from flask import request +import flask + + +def playlist_ctoken(playlist_id, offset, include_shorts=True): + + offset = proto.uint(1, offset) + offset = b'PT:' + proto.unpadded_b64encode(offset) + offset = proto.string(15, offset) + if not include_shorts: + offset += proto.string(104, proto.uint(2, 1)) + + continuation_info = proto.string(3, proto.percent_b64encode(offset)) + + playlist_id = proto.string(2, 'VL' + playlist_id) + pointless_nest = proto.string(80226972, playlist_id + continuation_info) + + return base64.urlsafe_b64encode(pointless_nest).decode('ascii') + + +def playlist_first_page(playlist_id, report_text="Retrieved playlist", + use_mobile=False): + if use_mobile: + url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1' + content = util.fetch_url( + url, util.mobile_xhr_headers, + report_text=report_text, debug_name='playlist_first_page' + ) + content = json.loads(content.decode('utf-8')) + else: + url = 'https://www.youtube.com/playlist?list=' + playlist_id + '&pbj=1' + content = util.fetch_url( + url, util.desktop_xhr_headers, + report_text=report_text, debug_name='playlist_first_page' + ) + content = json.loads(content.decode('utf-8')) + + return content + + +def get_videos(playlist_id, page, include_shorts=True, use_mobile=False, + report_text='Retrieved playlist'): + # mobile requests return 20 videos per page + if use_mobile: + page_size = 20 + headers = util.mobile_xhr_headers + # desktop requests return 100 videos per page + else: + page_size = 100 + headers = util.desktop_xhr_headers + + url = "https://m.youtube.com/playlist?ctoken=" + url += playlist_ctoken(playlist_id, (int(page)-1)*page_size, + include_shorts=include_shorts) + url += "&pbj=1" + content = util.fetch_url( + url, headers, report_text=report_text, + debug_name='playlist_videos' + ) + + info = json.loads(content.decode('utf-8')) + return info + + +@yt_app.route('/playlist') +def get_playlist_page(): + if 'list' not in request.args: + abort(400) + + playlist_id = request.args.get('list') + page = request.args.get('page', '1') + + if page == '1': + first_page_json = playlist_first_page(playlist_id) + this_page_json = first_page_json + else: + tasks = ( + gevent.spawn( + playlist_first_page, playlist_id, + report_text="Retrieved playlist info", use_mobile=True + ), + gevent.spawn(get_videos, playlist_id, page) + ) + gevent.joinall(tasks) + util.check_gevent_exceptions(*tasks) + first_page_json, this_page_json = tasks[0].value, tasks[1].value + + info = yt_data_extract.extract_playlist_info(this_page_json) + if info['error']: + return flask.render_template('error.html', error_message=info['error']) + + if page != '1': + info['metadata'] = yt_data_extract.extract_playlist_metadata(first_page_json) + + util.prefix_urls(info['metadata']) + for item in info.get('items', ()): + util.prefix_urls(item) + util.add_extra_html_info(item) + if 'id' in item: + item['thumbnail'] = f"{settings.img_prefix}https://i.ytimg.com/vi/{item['id']}/hqdefault.jpg" + + item['url'] += '&list=' + playlist_id + if item['index']: + item['url'] += '&index=' + str(item['index']) + + video_count = yt_data_extract.deep_get(info, 'metadata', 'video_count') + if video_count is None: + video_count = 40 + + return flask.render_template( + 'playlist.html', + header_playlist_names=local_playlist.get_playlist_names(), + video_list=info.get('items', []), + num_pages=math.ceil(video_count/100), + parameters_dictionary=request.args, + + **info['metadata'] + ).encode('utf-8') diff --git a/youtube/proto.py b/youtube/proto.py new file mode 100644 index 0000000..924e983 --- /dev/null +++ b/youtube/proto.py @@ -0,0 +1,221 @@ +from math import ceil +import base64 +import io +import traceback + + +def byte(n): + return bytes((n,)) + + +def varint_encode(offset): + '''In this encoding system, for each 8-bit byte, the first bit is 1 if there are more bytes, and 0 is this is the last one. + The next 7 bits are data. These 7-bit sections represent the data in Little endian order. For example, suppose the data is + aaaaaaabbbbbbbccccccc (each of these sections is 7 bits). It will be encoded as: + 1ccccccc 1bbbbbbb 0aaaaaaa + + This encoding is used in youtube parameters to encode offsets and to encode the length for length-prefixed data. + See https://developers.google.com/protocol-buffers/docs/encoding#varints for more info.''' + needed_bytes = ceil(offset.bit_length()/7) or 1 # (0).bit_length() returns 0, but we need 1 in that case. + encoded_bytes = bytearray(needed_bytes) + for i in range(0, needed_bytes - 1): + encoded_bytes[i] = (offset & 127) | 128 # 7 least significant bits + offset = offset >> 7 + encoded_bytes[-1] = offset & 127 # leave first bit as zero for last byte + + return bytes(encoded_bytes) + + +def varint_decode(encoded): + decoded = 0 + for i, byte in enumerate(encoded): + decoded |= (byte & 127) << 7*i + + if not (byte & 128): + break + return decoded + + +def string(field_number, data): + data = as_bytes(data) + return _proto_field(2, field_number, varint_encode(len(data)) + data) + + +nested = string + + +def uint(field_number, value): + return _proto_field(0, field_number, varint_encode(value)) + + +def _proto_field(wire_type, field_number, data): + ''' See https://developers.google.com/protocol-buffers/docs/encoding#structure ''' + return varint_encode((field_number << 3) | wire_type) + data + + +def percent_b64encode(data): + return base64.urlsafe_b64encode(data).replace(b'=', b'%3D') + + +def unpadded_b64encode(data): + return base64.urlsafe_b64encode(data).replace(b'=', b'') + + +def as_bytes(value): + if isinstance(value, str): + return value.encode('utf-8') + return value + + +def read_varint(data): + result = 0 + i = 0 + while True: + try: + byte = data.read(1)[0] + except IndexError: + if i == 0: + raise EOFError() + raise Exception('Unterminated varint starting at ' + str(data.tell() - i)) + result |= (byte & 127) << 7*i + if not byte & 128: + break + + i += 1 + return result + + +def read_group(data, end_sequence): + start = data.tell() + index = data.original.find(end_sequence, start) + if index == -1: + raise Exception('Unterminated group') + data.seek(index + len(end_sequence)) + return data.original[start:index] + +def read_protobuf(data): + data_original = data + data = io.BytesIO(data) + data.original = data_original + while True: + try: + tag = read_varint(data) + except EOFError: + break + wire_type = tag & 7 + field_number = tag >> 3 + + if wire_type == 0: + value = read_varint(data) + elif wire_type == 1: + value = data.read(8) + elif wire_type == 2: + length = read_varint(data) + value = data.read(length) + elif wire_type == 3: + end_bytes = encode_varint((field_number << 3) | 4) + value = read_group(data, end_bytes) + elif wire_type == 5: + value = data.read(4) + else: + raise Exception("Unknown wire type: " + str(wire_type) + ", Tag: " + bytes_to_hex(succinct_encode(tag)) + ", at position " + str(data.tell())) + yield (wire_type, field_number, value) + + +def parse(data, include_wire_type=False): + '''Returns a dict mapping field numbers to values + + data is the protobuf structure, which must not be b64-encoded''' + if include_wire_type: + return {field_number: [wire_type, value] + for wire_type, field_number, value in read_protobuf(data)} + return {field_number: value + for _, field_number, value in read_protobuf(data)} + + +base64_enc_funcs = { + 'base64': base64.urlsafe_b64encode, + 'base64s': unpadded_b64encode, + 'base64p': percent_b64encode, +} + + +def _make_protobuf(data): + ''' + Input: Recursive list of protobuf objects or base-64 encodings + Output: Protobuf bytestring + Each protobuf object takes the form [wire_type, field_number, field_data] + If a string protobuf has a list/tuple of length 2, this has the form + (base64 type, data) + The base64 types are + - base64 means a base64 encode with equals sign paddings + - base64s means a base64 encode without padding + - base64p means a url base64 encode with equals signs replaced with %3D + ''' + # must be dict mapping field_number to [wire_type, value] + if isinstance(data, dict): + new_data = [] + for field_num, (wire_type, value) in sorted(data.items()): + new_data.append((wire_type, field_num, value)) + data = new_data + if isinstance(data, str): + return data.encode('utf-8') + elif len(data) == 2 and data[0] in list(base64_enc_funcs.keys()): + return base64_enc_funcs[data[0]](_make_protobuf(data[1])) + elif isinstance(data, list): + result = b'' + for field in data: + if field[0] == 0: + result += uint(field[1], field[2]) + elif field[0] == 2: + result += string(field[1], _make_protobuf(field[2])) + else: + raise NotImplementedError('Wire type ' + str(field[0]) + + ' not implemented') + return result + return data + + +def make_protobuf(data): + return _make_protobuf(data).decode('ascii') + + +def _set_protobuf_value(data, *path, value): + if not path: + return value + op = path[0] + if op in base64_enc_funcs: + inner_data = b64_to_bytes(data) + return base64_enc_funcs[op]( + _set_protobuf_value(inner_data, *path[1:], value=value) + ) + pb_dict = parse(data, include_wire_type=True) + pb_dict[op][1] = _set_protobuf_value( + pb_dict[op][1], *path[1:], value=value + ) + return _make_protobuf(pb_dict) + + +def set_protobuf_value(data, *path, value): + '''Set a field's value in a raw protobuf structure + + path is a list of field numbers and/or base64 encoding directives + + The directives are + base64: normal base64 encoding with equal signs padding + base64s ("stripped"): no padding + base64p: %3D instead of = for padding + + return new_protobuf, err''' + try: + new_protobuf = _set_protobuf_value(data, *path, value=value) + return new_protobuf.decode('ascii'), None + except Exception: + return None, traceback.format_exc() + + +def b64_to_bytes(data): + if isinstance(data, bytes): + data = data.decode('ascii') + data = data.replace("%3D", "=") + return base64.urlsafe_b64decode(data + "="*((4 - len(data) % 4) % 4)) diff --git a/youtube/proto_debug.py b/youtube/proto_debug.py new file mode 100644 index 0000000..d793fe1 --- /dev/null +++ b/youtube/proto_debug.py @@ -0,0 +1,611 @@ +# TODO: clean this file up more and heavily refactor + +''' Helper functions for reverse engineering protobuf. + +Basic guide: + +Run interactively with python3 -i proto_debug.py + +The function dec will decode a base64 string +(regardless of whether it includes = or %3D at the end) to a bytestring + +The function pb (parse_protobuf) will return a list of tuples. +Each tuple is (wire_type, field_number, field_data) + +The function enc encodes as base64 (inverse of dec) +The function uenc is like enc but replaces = with %3D + +See https://developers.google.com/protocol-buffers/docs/encoding#structure + +Example usage: +>>> pb(dec('4qmFsgJcEhhVQ1lPX2phYl9lc3VGUlY0YjE3QUp0QXcaQEVnWjJhV1JsYjNNWUF5QUFNQUU0QWVvREdFTm5Ua1JSVlVWVFEzZHBYM2gwTTBaeFRuRkZiRFZqUWclM0QlM0Q%3D')) +[(2, 80226972, b'\x12\x18UCYO_jab_esuFRV4b17AJtAw\x1a@EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D')] + +>>> pb(b'\x12\x18UCYO_jab_esuFRV4b17AJtAw\x1a@EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D') +[(2, 2, b'UCYO_jab_esuFRV4b17AJtAw'), (2, 3, b'EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D')] + +>>> pb(dec(b'EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D')) +[(2, 2, b'videos'), (0, 3, 3), (0, 4, 0), (0, 6, 1), (0, 7, 1), (2, 61, b'CgNDQUESCwi_xt3FqNqEl5cB')] + +>>> pb(dec(b'CgNDQUESCwi_xt3FqNqEl5cB')) +[(2, 1, b'CAA'), (2, 2, b'\x08\xbf\xc6\xdd\xc5\xa8\xda\x84\x97\x97\x01')] + +>>> pb(b'\x08\xbf\xc6\xdd\xc5\xa8\xda\x84\x97\x97\x01') +[(0, 1, 10893665244101960511)] + +>>> pb(dec(b'CAA')) +[(0, 1, 0)] + +The function recursive_pb will try to do dec/pb recursively automatically. +It's a dumb function (so might try to dec or pb something that isn't really +base64 or protobuf) so be careful. +The function pp will pretty print the recursive structure: + +>>> pp(recursive_pb('4qmFsgJcEhhVQ1lPX2phYl9lc3VGUlY0YjE3QUp0QXcaQEVnWjJhV1JsYjNNWUF5QUFNQUU0QWVvREdFTm5Ua1JSVlVWVFEzZHBYM2gwTTBaeFRuRkZiRFZqUWclM0QlM0Q%3D')) + +('base64p', + [ + [2, 80226972, + [ + [2, 2, b'UCYO_jab_esuFRV4b17AJtAw'], + [2, 3, + ('base64p', + [ + [2, 2, b'videos'], + [0, 3, 3], + [0, 4, 0], + [0, 6, 1], + [0, 7, 1], + [2, 61, + ('base64?', + [ + [2, 1, b'CAA'], + [2, 2, + [ + [0, 1, 10893665244101960511], + ] + ], + ] + ) + ], + ] + ) + ], + ] + ], + ] +) + + +- base64 means a base64 encode with equals sign paddings +- base64s means a base64 encode without padding +- base64p means a url base64 encode with equals signs replaced with %3D +- base64? means the base64 type cannot be inferred because of the length + +make_proto is the inverse function. It will take a recursive_pb structure and +make a ctoken out of it, so in general, +x == make_proto(recursive_pb(x)) + +There are some other functions I wrote while reverse engineering stuff +that may or may not be useful. +''' + + +import urllib.request +import urllib.parse +import re +import time +import json +import os +import pprint + + +# ------ from proto.py ----------------------------------------------- +from math import ceil +import base64 +import io + + +def byte(n): + return bytes((n,)) + + +def varint_encode(offset): + '''In this encoding system, for each 8-bit byte, the first bit is 1 if there are more bytes, and 0 is this is the last one. + The next 7 bits are data. These 7-bit sections represent the data in Little endian order. For example, suppose the data is + aaaaaaabbbbbbbccccccc (each of these sections is 7 bits). It will be encoded as: + 1ccccccc 1bbbbbbb 0aaaaaaa + + This encoding is used in youtube parameters to encode offsets and to encode the length for length-prefixed data. + See https://developers.google.com/protocol-buffers/docs/encoding#varints for more info.''' + needed_bytes = ceil(offset.bit_length()/7) or 1 # (0).bit_length() returns 0, but we need 1 in that case. + encoded_bytes = bytearray(needed_bytes) + for i in range(0, needed_bytes - 1): + encoded_bytes[i] = (offset & 127) | 128 # 7 least significant bits + offset = offset >> 7 + encoded_bytes[-1] = offset & 127 # leave first bit as zero for last byte + + return bytes(encoded_bytes) + + +def varint_decode(encoded): + decoded = 0 + for i, byte in enumerate(encoded): + decoded |= (byte & 127) << 7*i + + if not (byte & 128): + break + return decoded + + +def string(field_number, data): + data = as_bytes(data) + return _proto_field(2, field_number, varint_encode(len(data)) + data) + + +nested = string + + +def uint(field_number, value): + return _proto_field(0, field_number, varint_encode(value)) + + +def _proto_field(wire_type, field_number, data): + ''' See https://developers.google.com/protocol-buffers/docs/encoding#structure ''' + return varint_encode((field_number << 3) | wire_type) + data + + +def percent_b64encode(data): + return base64.urlsafe_b64encode(data).replace(b'=', b'%3D') + + +def unpadded_b64encode(data): + return base64.urlsafe_b64encode(data).replace(b'=', b'') + + +def as_bytes(value): + if isinstance(value, str): + return value.encode('utf-8') + return value + + +def read_varint(data): + result = 0 + i = 0 + while True: + try: + byte = data.read(1)[0] + except IndexError: + if i == 0: + raise EOFError() + raise Exception('Unterminated varint starting at ' + str(data.tell() - i)) + result |= (byte & 127) << 7*i + if not byte & 128: + break + + i += 1 + return result + + +def read_group(data, end_sequence): + start = data.tell() + index = data.original.find(end_sequence, start) + if index == -1: + raise Exception('Unterminated group') + data.seek(index + len(end_sequence)) + return data.original[start:index] + + +def parse(data, include_wire_type=False): + '''Returns a dict mapping field numbers to values + + data is the protobuf structure, which must not be b64-encoded''' + if include_wire_type: + return {field_number: [wire_type, value] + for wire_type, field_number, value in read_protobuf(data)} + return {field_number: value + for _, field_number, value in read_protobuf(data)} + + +base64_enc_funcs = { + 'base64': base64.urlsafe_b64encode, + 'base64s': unpadded_b64encode, + 'base64p': percent_b64encode, + 'base64?': base64.urlsafe_b64encode, +} + + +def _make_protobuf(data): + # must be dict mapping field_number to [wire_type, value] + if isinstance(data, dict): + new_data = [] + for field_num, (wire_type, value) in sorted(data.items()): + new_data.append((wire_type, field_num, value)) + data = new_data + if isinstance(data, str): + return data.encode('utf-8') + elif len(data) == 2 and data[0] in list(base64_enc_funcs.keys()): + return base64_enc_funcs[data[0]](_make_protobuf(data[1])) + elif isinstance(data, list): + result = b'' + for field in data: + if field[0] == 0: + result += uint(field[1], field[2]) + elif field[0] == 2: + result += string(field[1], _make_protobuf(field[2])) + else: + raise NotImplementedError('Wire type ' + str(field[0]) + + ' not implemented') + return result + return data + + +def make_protobuf(data): + return _make_protobuf(data).decode('ascii') + + +make_proto = make_protobuf + + +def _set_protobuf_value(data, *path, value): + if not path: + return value + op = path[0] + if op in base64_enc_funcs: + inner_data = b64_to_bytes(data) + return base64_enc_funcs[op]( + _set_protobuf_value(inner_data, *path[1:], value=value) + ) + pb_dict = parse(data, include_wire_type=True) + pb_dict[op][1] = _set_protobuf_value( + pb_dict[op][1], *path[1:], value=value + ) + return _make_protobuf(pb_dict) + + +def set_protobuf_value(data, *path, value): + '''Set a field's value in a raw protobuf structure + + path is a list of field numbers and/or base64 encoding directives + + The directives are + base64: normal base64 encoding with equal signs padding + base64s ("stripped"): no padding + base64p: %3D instead of = for padding + + return new_protobuf, err''' + try: + new_protobuf = _set_protobuf_value(data, *path, value=value) + return new_protobuf.decode('ascii'), None + except Exception: + return None, traceback.format_exc() + + +def b64_to_bytes(data): + if isinstance(data, bytes): + data = data.decode('ascii') + data = data.replace("%3D", "=") + return base64.urlsafe_b64decode(data + "="*((4 - len(data) % 4) % 4)) +# -------------------------------------------------------------------- + + +dec = b64_to_bytes + + +def get_b64_type(data): + '''return base64, base64s, base64p, or base64?''' + if isinstance(data, str): + data = data.encode('ascii') + if data.endswith(b'='): + return 'base64' + if data.endswith(b'%3D'): + return 'base64p' + # Length of data means it wouldn't have an equals sign, + # so we can't tell which type it is. + if len(data) % 4 == 0: + return 'base64?' + + return 'base64s' + + +def enc(t): + return base64.urlsafe_b64encode(t).decode('ascii') + + +def uenc(t): + return enc(t).replace("=", "%3D") + + +def b64_to_ascii(t): + return base64.urlsafe_b64decode(t).decode('ascii', errors='replace') + + +def b64_to_bin(t): + decoded = base64.urlsafe_b64decode(t) + # print(len(decoded)*8) + return " ".join(["{:08b}".format(x) for x in decoded]) + + +def bytes_to_bin(t): + return " ".join(["{:08b}".format(x) for x in t]) + + +def bin_to_bytes(t): + return int(t, 2).to_bytes((len(t) + 7) // 8, 'big') + + +def bytes_to_hex(t): + return ' '.join(hex(n)[2:].zfill(2) for n in t) + + +tohex = bytes_to_hex +fromhex = bytes.fromhex + + +def aligned_ascii(data): + return ' '.join(' ' + chr(n) if n in range(32, 128) else ' _' for n in data) + + +def parse_protobuf(data, mutable=False, spec=()): + data_original = data + data = io.BytesIO(data) + data.original = data_original + while True: + try: + tag = read_varint(data) + except EOFError: + break + wire_type = tag & 7 + field_number = tag >> 3 + + if wire_type == 0: + value = read_varint(data) + elif wire_type == 1: + value = data.read(8) + elif wire_type == 2: + length = read_varint(data) + value = data.read(length) + elif wire_type == 3: + end_bytes = varint_encode((field_number << 3) | 4) + value = read_group(data, end_bytes) + elif wire_type == 5: + value = data.read(4) + else: + raise Exception("Unknown wire type: " + str(wire_type) + ", Tag: " + bytes_to_hex(varint_encode(tag)) + ", at position " + str(data.tell())) + if mutable: + yield [wire_type, field_number, value] + else: + yield (wire_type, field_number, value) + + +read_protobuf = parse_protobuf + + +def pb(data, mutable=False): + return list(parse_protobuf(data, mutable=mutable)) + + +def bytes_to_base4(data): + result = '' + for b in data: + result += str(b >> 6) + str((b >> 4) & 0b11) + str((b >> 2) & 0b11) + str(b & 0b11) + return result + + +import re +import struct +import binascii + + +# Base32 encoding/decoding must be done in Python +_b32alphabet = b'abcdefghijklmnopqrstuvwxyz012345' +_b32tab2 = None +_b32rev = None + +bytes_types = (bytes, bytearray) # Types acceptable as binary data + + +def _bytes_from_decode_data(s): + if isinstance(s, str): + try: + return s.encode('ascii') + except UnicodeEncodeError: + raise ValueError('string argument should contain only ASCII characters') + if isinstance(s, bytes_types): + return s + try: + return memoryview(s).tobytes() + except TypeError: + raise TypeError("argument should be a bytes-like object or ASCII " + "string, not %r" % s.__class__.__name__) from None + + +def b32decode(s, casefold=False, map01=None): + """Decode the Base32 encoded bytes-like object or ASCII string s. + + Optional casefold is a flag specifying whether a lowercase alphabet is + acceptable as input. For security purposes, the default is False. + + RFC 3548 allows for optional mapping of the digit 0 (zero) to the + letter O (oh), and for optional mapping of the digit 1 (one) to + either the letter I (eye) or letter L (el). The optional argument + map01 when not None, specifies which letter the digit 1 should be + mapped to (when map01 is not None, the digit 0 is always mapped to + the letter O). For security purposes the default is None, so that + 0 and 1 are not allowed in the input. + + The result is returned as a bytes object. A binascii.Error is raised if + the input is incorrectly padded or if there are non-alphabet + characters present in the input. + """ + global _b32rev + # Delay the initialization of the table to not waste memory + # if the function is never called + if _b32rev is None: + _b32rev = {v: k for k, v in enumerate(_b32alphabet)} + s = _bytes_from_decode_data(s) + if len(s) % 8: + raise binascii.Error('Incorrect padding') + # Handle section 2.4 zero and one mapping. The flag map01 will be either + # False, or the character to map the digit 1 (one) to. It should be + # either L (el) or I (eye). + if map01 is not None: + map01 = _bytes_from_decode_data(map01) + assert len(map01) == 1, repr(map01) + s = s.translate(bytes.maketrans(b'01', b'O' + map01)) + if casefold: + s = s.upper() + # Strip off pad characters from the right. We need to count the pad + # characters because this will tell us how many null bytes to remove from + # the end of the decoded string. + l = len(s) + s = s.rstrip(b'=') + padchars = l - len(s) + # Now decode the full quanta + decoded = bytearray() + b32rev = _b32rev + for i in range(0, len(s), 8): + quanta = s[i: i + 8] + acc = 0 + try: + for c in quanta: + acc = (acc << 5) + b32rev[c] + except KeyError: + raise binascii.Error('Non-base32 digit found') from None + decoded += acc.to_bytes(5, 'big') + # Process the last, partial quanta + if padchars: + acc <<= 5 * padchars + last = acc.to_bytes(5, 'big') + if padchars == 1: + decoded[-5:] = last[:-1] + elif padchars == 3: + decoded[-5:] = last[:-2] + elif padchars == 4: + decoded[-5:] = last[:-3] + elif padchars == 6: + decoded[-5:] = last[:-4] + else: + raise binascii.Error('Incorrect padding') + return bytes(decoded) + + +def dec32(data): + if isinstance(data, bytes): + data = data.decode('ascii') + return b32decode(data + "="*((8 - len(data)%8)%8)) + + +_patterns = [ + (b'UC', 24), # channel + (b'PL', 34), # playlist + (b'LL', 24), # liked videos playlist + (b'UU', 24), # user uploads playlist + (b'RD', 15), # radio mix + (b'RD', 43), # radio mix + (b'', 11), # video + (b'Ug', 26), # comment + (b'Ug', 49), # comment reply (of form parent_id.reply_id) + (b'9', 22), # comment reply id +] +def is_youtube_object_id(data): + try: + if isinstance(data, str): + data = data.encode('ascii') + except Exception: + return False + + for start_sequence, length in _patterns: + if len(data) == length and data.startswith(start_sequence): + return True + + return False + + +def recursive_pb(data): + try: + # check if this fits the basic requirements for base64 + if isinstance(data, str) or all(i > 32 for i in data): + if len(data) > 11 and not is_youtube_object_id(data): + raw_data = b64_to_bytes(data) + b64_type = get_b64_type(data) + + rpb = recursive_pb(raw_data) + if rpb == raw_data: + # could not interpret as protobuf, probably not b64 + return data + return (b64_type, rpb) + else: + return data + except Exception as e: + return data + + try: + result = pb(data, mutable=True) + except Exception as e: + return data + + for tuple in result: + if tuple[0] == 2: + tuple[2] = recursive_pb(tuple[2]) + + return result + + + +def indent_lines(lines, indent): + return re.sub(r'^', ' '*indent, lines, flags=re.MULTILINE) + + +def _pp(obj, indent): # not my best work + if isinstance(obj, tuple): + if len(obj) == 3: # (wire_type, field_number, data) + return obj.__repr__() + else: # (base64, [...]) + return ('(' + obj[0].__repr__() + ',\n' + + indent_lines(_pp(obj[1], indent), indent) + '\n' + + ')') + elif isinstance(obj, list): + # [wire_type, field_number, data] + if (len(obj) == 3 + and not any(isinstance(x, (list, tuple)) for x in obj) + ): + return obj.__repr__() + + # [wire_type, field_number, [...]] + elif (len(obj) == 3 + and not any(isinstance(x, (list, tuple)) for x in obj[0:2]) + ): + return ('[' + obj[0].__repr__() + ', ' + obj[1].__repr__() + ',\n' + + indent_lines(_pp(obj[2], indent), indent) + '\n' + + ']') + else: + s = '[\n' + for x in obj: + s += indent_lines(_pp(x, indent), indent) + ',\n' + s += ']' + return s + else: + return obj.__repr__() + + +def pp(obj, indent=1): + '''Pretty prints the recursive pb structure''' + print(_pp(obj, indent)) + + +desktop_user_agent = 'Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0' +desktop_headers = ( + ('Accept', '*/*'), + ('Accept-Language', 'en-US,en;q=0.5'), + ('X-YouTube-Client-Name', '1'), + ('X-YouTube-Client-Version', '2.20180830'), +) + (('User-Agent', desktop_user_agent),) + +mobile_user_agent = 'Mozilla/5.0 (Linux; Android 7.0; Redmi Note 4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36' +mobile_headers = ( + ('Accept', '*/*'), + ('Accept-Language', 'en-US,en;q=0.5'), + ('X-YouTube-Client-Name', '2'), + ('X-YouTube-Client-Version', '2.20180830'), +) + (('User-Agent', mobile_user_agent),) diff --git a/youtube/search.py b/youtube/search.py new file mode 100644 index 0000000..d586c62 --- /dev/null +++ b/youtube/search.py @@ -0,0 +1,120 @@ +from youtube import util, yt_data_extract, proto, local_playlist +from youtube import yt_app +import settings + +import json +import urllib +import base64 +import mimetypes +from flask import request +import flask +import os + +# Sort: 1 + # Upload date: 2 + # View count: 3 + # Rating: 1 + # Relevance: 0 +# Offset: 9 +# Filters: 2 + # Upload date: 1 + # Type: 2 + # Duration: 3 + + +features = { + '4k': 14, + 'hd': 4, + 'hdr': 25, + 'subtitles': 5, + 'creative_commons': 6, + '3d': 7, + 'live': 8, + 'purchased': 9, + '360': 15, + 'location': 23, +} + +def page_number_to_sp_parameter(page, autocorrect, sort, filters): + offset = (int(page) - 1)*20 # 20 results per page + autocorrect = proto.nested(8, proto.uint(1, 1 - int(autocorrect) )) + filters_enc = proto.nested(2, proto.uint(1, filters['time']) + proto.uint(2, filters['type']) + proto.uint(3, filters['duration'])) + result = proto.uint(1, sort) + filters_enc + autocorrect + proto.uint(9, offset) + proto.string(61, b'') + return base64.urlsafe_b64encode(result).decode('ascii') + +def get_search_json(query, page, autocorrect, sort, filters): + url = "https://www.youtube.com/results?search_query=" + urllib.parse.quote_plus(query) + headers = { + 'Host': 'www.youtube.com', + 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.5', + 'X-YouTube-Client-Name': '1', + 'X-YouTube-Client-Version': '2.20180418', + } + url += "&pbj=1&sp=" + page_number_to_sp_parameter(page, autocorrect, sort, filters).replace("=", "%3D") + content = util.fetch_url(url, headers=headers, report_text="Got search results", debug_name='search_results') + info = json.loads(content) + return info + + +@yt_app.route('/results') +@yt_app.route('/search') +def get_search_page(): + query = request.args.get('search_query') or request.args.get('query') + if query is None: + return flask.render_template('home.html', title='Search') + elif query.startswith('https://www.youtube.com') or query.startswith('https://www.youtu.be'): + return flask.redirect(f'/{query}') + + page = request.args.get("page", "1") + autocorrect = int(request.args.get("autocorrect", "1")) + sort = int(request.args.get("sort", "0")) + filters = {} + filters['time'] = int(request.args.get("time", "0")) + filters['type'] = int(request.args.get("type", "0")) + filters['duration'] = int(request.args.get("duration", "0")) + polymer_json = get_search_json(query, page, autocorrect, sort, filters) + + search_info = yt_data_extract.extract_search_info(polymer_json) + if search_info['error']: + return flask.render_template('error.html', error_message=search_info['error']) + + for extract_item_info in search_info['items']: + util.prefix_urls(extract_item_info) + util.add_extra_html_info(extract_item_info) + + corrections = search_info['corrections'] + if corrections['type'] == 'did_you_mean': + corrected_query_string = request.args.to_dict(flat=False) + corrected_query_string['search_query'] = [corrections['corrected_query']] + corrections['corrected_query_url'] = util.URL_ORIGIN + '/results?' + urllib.parse.urlencode(corrected_query_string, doseq=True) + elif corrections['type'] == 'showing_results_for': + no_autocorrect_query_string = request.args.to_dict(flat=False) + no_autocorrect_query_string['autocorrect'] = ['0'] + no_autocorrect_query_url = util.URL_ORIGIN + '/results?' + urllib.parse.urlencode(no_autocorrect_query_string, doseq=True) + corrections['original_query_url'] = no_autocorrect_query_url + + return flask.render_template( + 'search.html', + header_playlist_names=local_playlist.get_playlist_names(), + query=query, + estimated_results=search_info['estimated_results'], + estimated_pages=search_info['estimated_pages'], + corrections=search_info['corrections'], + results=search_info['items'], + parameters_dictionary=request.args, + ) + + +@yt_app.route('/opensearch.xml') +def get_search_engine_xml(): + with open(os.path.join(settings.program_directory, 'youtube/opensearch.xml'), 'rb') as f: + if settings.app_public: + main_url = '%s' % settings.app_url + else: + main_url = '%s:%s' % (settings.app_url, settings.port_number) + content = f.read().replace( + b'$main_url', str(main_url).encode() + ) + return flask.Response(content, mimetype='application/xml') diff --git a/youtube/static/channel.css b/youtube/static/channel.css new file mode 100644 index 0000000..e842beb --- /dev/null +++ b/youtube/static/channel.css @@ -0,0 +1,572 @@ +body { + display: grid; + grid-gap: 20px; + grid-template-areas: + "header" + "main" + "footer"; + /* Fix height */ + height: 100vh; + grid-template-rows: auto 1fr auto; + /* fix top and bottom */ + margin-left: 1rem; + margin-right: 1rem; +} + +img { + width: 100%; + height: auto; +} + +a:link { + color: var(--link); +} + +a:visited { + color: var(--link-visited); +} + +input[type="text"], +input[type="search"] { + background: var(--background); + border: 1px solid var(--button-border); + padding: 0.4rem 0.4rem; + font-size: 15px; + color: var(--search-text); + outline: none; + box-shadow: none; +} + +input[type='search'] { + border-bottom: 1px solid var(--button-border); + border-top: 0px; + border-left: 0px; + border-right: 0px; + border-radius: 0px; +} + +header { + display: grid; + grid-gap: 1px; + grid-template-areas: + "home" + "form" + "playlist"; + grid-area: header; +} + +.home { + grid-area: home; + margin-left: auto; + margin-right: auto; + margin-bottom: 1rem; + margin-top: 1rem; +} + +.form { + display: grid; + grid-gap: 4px; + grid-template-areas: + "search-box" + "search-button" + "dropdown"; + grid-area: form; +} + +.search-box { + grid-area: search-box; +} +.search-button { + grid-area: search-button; + + cursor: pointer; + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.search-button:hover { + background-color: var(--buttom-hover); +} + +.dropdown { + display: grid; + grid-gap: 1px; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; + z-index: 1; +} +.dropdown-label { + grid-area: dropdown-label; + + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.dropdown-label:hover { + background-color: var(--buttom-hover); +} + +/* playlist */ +.playlist { + display: grid; + grid-gap: 4px; + grid-template-areas: + "play-box" + "play-hidden" + "play-add" + "play-clean"; + grid-area: playlist; +} +.play-box { + grid-area: play-box; +} + +.play-hidden { + grid-area: play-hidden; +} + +.play-add { + grid-area: play-add; + cursor: pointer; + + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.play-add:hover { + background-color: var(--buttom-hover); +} + +.play-clean { + display: grid; + grid-area: play-clean; +} + +.play-clean > button { + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.play-clean > button:hover { + background-color: var(--buttom-hover); +} +/* /playlist */ + +/* ------------- Menu Mobile sin JS ---------------- */ +/* input hidden */ +.opt-box { + display: none; +} +.dropdown-content { + display: none; + grid-area: dropdown-content; +} +label[for=options-toggle-cbox] { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#options-toggle-cbox:checked ~ .dropdown-content { + display: block; + white-space: nowrap; + background: var(--secondary-background); + padding: 0.5rem 1rem; +} +/*- ----------- End Menu Mobile sin JS ------------- */ + +.main { + grid-area: main; + display: grid; + grid-row-gap: 1rem; +} + +/* fix hr when is children of grid */ +hr { + width: 100%; +} + +.author-container { + display: grid; + grid-template-columns: 1fr; + margin: auto; + grid-template-areas: + "author" + "summary" + "subscribe"; +} +.author { + grid-area: author; + display: grid; + grid-template-columns: 100px 1fr; + grid-column-gap: 1rem; + align-items: center; + justify-self: center; +} +.summary { grid-area: summary; } +.summary p { + text-align: center; +} +.subscribe { + grid-area: subscribe; + justify-self: center; +} +.subscribe .btn-subscribe { + background-color: var(--buttom); + color: var(--buttom-text); + text-shadow: none; + cursor: pointer; + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + border: 1px solid; + border-color: var(--button-border); + border-radius: 0.2rem; +} + +/* Video list item */ +.video-container { + display: grid; + grid-row-gap: 0.5rem; +} + +.item-box { + display: grid; + grid-template-columns: 1.9fr 0.1fr; + grid-template-rows: 1fr; + grid-gap: 1px; + grid-template-areas: + "item-video item-checkbox"; +} + +.item-video { + grid-area: item-video; + + display: grid; + grid-template-columns: auto; + grid-template-rows: repeat(4, auto); + grid-row-gap: 0.4rem; + grid-template-areas: + "thumbnail-box" + "info-box"; + align-items: center; + + font-size: 0.7rem; +} + +.item-video a { + text-decoration: none; + cursor: pointer; +} + +.item-video.channel-item { + border-radius: 50%; + width: 150px; + height: 150px; +} + +.thumbnail-box { + grid-area: thumbnail-box; + position: relative; +} + +.thumbnail { + padding: 28.125%; + position: relative; + box-sizing: border-box; +} + +.thumbnail-img { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + object-fit: cover; + background-color: var(--thumb-background); +} + +.length { + position: absolute; + background-color: rgba(35, 35, 35, 0.75); + color: #fff; + border-radius: 2px; + padding: 2px; + font-size: 16px; + right: 0.25em; + bottom: -0.75em; +} + +.playlist-item .thumbnail-info { + position: absolute; + right: 0px; + bottom: 0px; + height: 100%; + width: 50%; + text-align: center; + white-space: pre-line; + opacity: .8; + color: var(--text); + font-size: 0.8125rem; + background: var(--secondary-background); + padding: 0; +} + +.playlist-item .thumbnail-info span { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + text-transform: none; +} + +.info-box { + grid-area: info-box; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto auto auto auto; + grid-gap: 1px; + grid-template-areas: + "." + "." + "." + "." + "."; +} + +.title { + font-size: 0.8rem; + margin: 0px; + font-weight: normal; + overflow: hidden; + text-overflow: ellipsis; +} + +.info-box address { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.thumbnail-info { + background-color: var(--time-background); + color: #fff; + padding: 2px 5px; + text-transform: uppercase; + font-weight: 700; + font-size: 12px; + position: absolute; + right: 0; + bottom: .2rem; +} + +.item-checkbox { + grid-area: item-checkbox; + justify-self: start; + align-self: center; + min-width: 30px; + margin: 0px; +} + +.stats { + display: flex; + justify-content: space-between; +} + +.horizontal-stats { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.horizontal-stats > li { + display: inline; +} + +.horizontal-stats > li:first-child::after { + content: " | "; +} + +/* pagination */ +.main .pagination-container { + display: grid; + justify-content: center; +} + +.main .pagination-container .pagination-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; +} + +.main .pagination-container .pagination-list .page-link { + border-style: none; + font-weight: bold; + text-align: center; + background: var(--secondary-focus); + text-decoration: none; + align-self: center; + padding: .5rem; + margin: 0.2rem; + width: 1rem; +} + +.main .pagination-container .pagination-list .page-link.is-current { + background: var(--secondary-background); +} + +/* /video list item */ + +.footer { + grid-area: footer; + display: grid; + grid-template-columns: auto; + align-items: center; + justify-content: center; + margin: auto; + text-align: center; +} + +.footer > p { + text-align: center; +} + +@media (min-width: 480px) { + .item-video { + font-size: 0.85rem; + } + .info-box { + grid-gap: 2px; + } + .title { + font-size: 1rem; + } +} + +@media (min-width: 600px) { + .video-container { + display: grid; + grid-row-gap: 0.5rem; + grid-template-columns: 1fr 1fr; + } +} + +@media (min-width: 992px) { + body { + display: grid; + grid-template-columns: 0.3fr 2fr 1fr 0.3fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "header header header header" + "main main main main" + "footer footer footer footer"; + } + .form { + display: grid; + grid-gap: 1px; + grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr; + grid-template-areas: ". search-box search-button dropdown"; + grid-area: form; + position: relative; + } + .dropdown { + display: grid; + grid-gap: 1px; + grid-template-columns: 100px auto; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; + position: absolute; + z-index: 1; + } + + #options-toggle-cbox:checked ~ .dropdown-content { + width: calc(100% + 100px); + max-height: 80vh; + overflow-y: scroll; + } + + .author-container { + max-width: 50vw; + } + + /* playlist */ + .playlist { + display: grid; + grid-gap: 1px; + grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr; + grid-template-areas: ". play-box play-add play-clean"; + grid-area: playlist; + } + .play-clean { + grid-template-columns: 100px auto; + } + .play-clean > button { + padding-left: 0px; + padding-right: 0px; + padding-bottom: 6px; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + color: var(--buttom-text); + border-radius: 5px; + cursor: pointer; + } + + .video-container { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-row-gap: 1rem; + grid-column-gap: 1rem; + } + + .footer { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-column-gap: 2rem; + align-items: center; + justify-content: center; + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; + } +} diff --git a/youtube/static/comments.css b/youtube/static/comments.css new file mode 100644 index 0000000..aa279ef --- /dev/null +++ b/youtube/static/comments.css @@ -0,0 +1,300 @@ +body { + display: grid; + grid-gap: 20px; + grid-template-areas: + "header" + "main" + "footer"; + /* Fix height */ + height: 100vh; + grid-template-rows: auto 1fr auto; + /* fix top and bottom */ + margin-left: 1rem; + margin-right: 1rem; +} + +h1, h2, h3, h4, h5, h6, div, button { + margin: 0; + padding: 0; +} + +img { + width: 100%; + height: auto; +} + +a:link { + color: var(--link); +} + +a:visited { + color: var(--link-visited); +} + +input[type="text"], +input[type="search"] { + background: var(--background); + border: 1px solid var(--button-border); + border-radius: 5px; + padding: 0.4rem 0.4rem; + font-size: 15px; + color: var(--search-text); + outline: none; + box-shadow: none; +} + +input[type='search'] { + border-bottom: 1px solid var(--button-border); + border-top: 0px; + border-left: 0px; + border-right: 0px; + border-radius: 0px; +} + +header { + display: grid; + grid-gap: 1px; + grid-template-areas: + "home" + "form"; + grid-area: header; +} + +.home { + grid-area: home; + margin-left: auto; + margin-right: auto; + margin-bottom: 1rem; + margin-top: 1rem; +} + +.form { + display: grid; + grid-gap: 4px; + grid-template-areas: + "search-box" + "search-button" + "dropdown"; + grid-area: form; +} + +.search-box { + grid-area: search-box; +} +.search-button { + grid-area: search-button; + + cursor: pointer; + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.search-button:hover { + background-color: var(--buttom-hover); +} + +.dropdown { + display: grid; + grid-gap: 1px; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; + padding-right: 4rem; +} +.dropdown-label { + grid-area: dropdown-label; + + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.dropdown-label:hover { + background-color: var(--buttom-hover); +} + +/* ------------- Menu Mobile sin JS ---------------- */ +/* input hidden */ +.opt-box { + display: none; +} +.dropdown-content { + display: none; + grid-area: dropdown-content; +} +label[for=options-toggle-cbox] { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#options-toggle-cbox:checked ~ .dropdown-content { + display: inline-grid; + white-space: nowrap; +} +/*- ----------- End Menu Mobile sin JS ------------- */ + +.main { + grid-area: main; + margin: 0 auto; + max-width: 80ch; +} + +/* comments */ +.comments-area { + display: grid; + grid-row-gap: 0.5rem; +} + +.comments-area textarea { + resize: vertical; +} + +.video-metadata { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 0.5rem; + grid-template-rows: auto auto 1fr auto; + grid-template-areas: + "video-metadata-thumbnail-box ." + "video-metadata-thumbnail-box ." + "video-metadata-thumbnail-box ."; +} + +.video-metadata > h2 { + font-size: 0.875rem; +} + +.video-metadata-thumbnail-box { + grid-area: video-metadata-thumbnail-box; +} + +.comment-form { + display: grid; + grid-row-gap: 0.5rem; +} + +.post-comment-button { + justify-self: end; +} + +.comments { + display: grid; + grid-row-gap: 0.5rem; +} + +.comment { + display: grid; + grid-template-columns: repeat(3, auto) 3fr; + grid-template-rows: repeat(4, auto); + grid-column-gap: 0.4rem; + grid-template-areas: + "author-avatar author-name permalink ." + "author-avatar comment-text comment-text comment-text" + ". comment-likes comment-likes comment-likes" + ". button-row button-row button-row"; + background: var(--secondary-background); +} + +.author-avatar { grid-area: author-avatar; } +.author-name { grid-area: author-name; } +.permalink { grid-area: permalink; } +.comment-text { grid-area: comment-text; } +.comment-likes { grid-area: comment-likes; } +.button-row { grid-area: button-row; } + +.more-comments { + justify-self: center; + margin-top: 10px; + margin-bottom: 10px; + background: var(--secondary-background); + padding: 5px; + + /* disable text selection */ + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.footer { + grid-area: footer; + display: grid; + grid-template-columns: auto; + align-items: center; + justify-content: center; + margin: auto; + text-align: center; +} + +.footer > p { + text-align: center; +} + +@media (min-width: 780px) { + body { + display: grid; + grid-template-columns: 0.3fr 2fr 1fr 0.3fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "header header header header" + "main main main main" + "footer footer footer footer"; + } + .form { + display: grid; + grid-gap: 1px; + grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr; + grid-template-areas: ". search-box search-button dropdown"; + grid-area: form; + position: relative; + } + .dropdown { + display: grid; + grid-gap: 1px; + grid-template-columns: minmax(50px, 120px); + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; + z-index: 1; + position: absolute; + } + #options-toggle-cbox:checked ~ .dropdown-content { + padding: 0rem 3rem 1rem 1rem; + width: 100%; + max-height: 45vh; + overflow-y: scroll; + } + + .footer { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-column-gap: 2rem; + align-items: center; + justify-content: center; + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; + } +} diff --git a/youtube/static/dark_theme.css b/youtube/static/dark_theme.css new file mode 100644 index 0000000..38cdd63 --- /dev/null +++ b/youtube/static/dark_theme.css @@ -0,0 +1,22 @@ +:root { + --background: #121113; + --text: #FFFFFF; + --secondary-hover: #222222; + --secondary-focus: #121113; + --secondary-inverse: #FFFFFF; + --primary-background: #242424; + --secondary-background: #222222; + --thumb-background: #222222; + --link: #00B0FF; + --link-visited: #40C4FF; + --border-bg: #222222; + --border-bg-settings: #000000; + --border-bg-license: #000000; + --buttom: #121113; + --buttom-text: #FFFFFF; + --button-border: #222222; + --buttom-hover: #222222; + --search-text: #FFFFFF; + --time-background: #121113; + --time-text: #FFFFFF; +} diff --git a/youtube/static/favicon.ico b/youtube/static/favicon.ico new file mode 100644 index 0000000..9d6417c Binary files /dev/null and b/youtube/static/favicon.ico differ diff --git a/youtube/static/gray_theme.css b/youtube/static/gray_theme.css new file mode 100644 index 0000000..2ae3efe --- /dev/null +++ b/youtube/static/gray_theme.css @@ -0,0 +1,22 @@ +:root { + --background: #2D3743; + --text: #FFFFFF; + --secondary-hover: #73828C; + --secondary-focus: rgba(115, 130, 140, 0.125); + --secondary-inverse: #FFFFFF; + --primary-background: #2D3743; + --secondary-background: #102027; + --thumb-background: #35404D; + --link: #22AAFF; + --link-visited: #7755FF; + --border-bg: #FFFFFF; + --border-bg-settings: #FFFFFF; + --border-bg-license: #FFFFFF; + --buttom: #2D3743; + --buttom-text: #FFFFFF; + --button-border: #102027; + --buttom-hover: #102027; + --search-text: #FFFFFF; + --time-background: #212121; + --time-text: #FFFFFF; +} diff --git a/youtube/static/home.css b/youtube/static/home.css new file mode 100644 index 0000000..8923c57 --- /dev/null +++ b/youtube/static/home.css @@ -0,0 +1,218 @@ +body { + display: grid; + grid-gap: 20px; + grid-template-areas: + "header" + "main" + "footer"; + /* Fix height */ + height: 100vh; + grid-template-rows: auto 1fr auto; + /* fix top and bottom */ + margin-left: 1rem; + margin-right: 1rem; +} + +a:link { + color: var(--link); +} + +a:visited { + color: var(--link-visited); +} + +input[type="text"], +input[type="search"] { + background: var(--background); + border: 1px solid var(--button-border); + border-radius: 5px; + padding: 0.4rem 0.4rem; + font-size: 15px; + color: var(--search-text); + outline: none; + box-shadow: none; +} + +input[type='search'] { + border-bottom: 1px solid var(--button-border); + border-top: 0px; + border-left: 0px; + border-right: 0px; + border-radius: 0px; +} + +header { + display: grid; + grid-gap: 1px; + grid-template-areas: + "home" + "form"; + grid-area: header; +} + +.home { + grid-area: home; + margin-left: auto; + margin-right: auto; + margin-bottom: 1rem; + margin-top: 1rem; +} + +.form { + display: grid; + grid-gap: 4px; + grid-template-areas: + "search-box" + "search-button" + "dropdown"; + grid-area: form; +} + +.search-box { + grid-area: search-box; +} +.search-button { + grid-area: search-button; + + cursor: pointer; + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.search-button:hover { + background-color: var(--buttom-hover); +} + +.dropdown { + display: grid; + grid-gap: 1px; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; +} +.dropdown-label { + grid-area: dropdown-label; + + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.dropdown-label:hover { + background-color: var(--buttom-hover); +} + +/* ------------- Menu Mobile sin JS ---------------- */ +/* input hidden */ +.opt-box { + display: none; +} +.dropdown-content { + display: none; + grid-area: dropdown-content; +} +label[for=options-toggle-cbox] { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#options-toggle-cbox:checked ~ .dropdown-content { + display: block; + white-space: nowrap; + background: var(--secondary-background); + padding: 0.5rem 1rem; +} +/*- ----------- End Menu Mobile sin JS ------------- */ + +.main { + grid-area: main; + margin: 0 auto; + max-width: 80ch; +} + +.code-error { + background: var(--secondary-background); + padding: 1rem; +} + +.footer { + grid-area: footer; + display: grid; + grid-template-columns: auto; + align-items: center; + justify-content: center; + margin: auto; + text-align: center; +} + +.footer > p { + text-align: center; +} + +@media (min-width: 780px) { + body { + display: grid; + grid-template-columns: 0.3fr 2fr 1fr 0.3fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "header header header header" + "main main main main" + "footer footer footer footer"; + } + .form { + display: grid; + grid-gap: 1px; + grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr; + grid-template-areas: ". search-box search-button dropdown"; + grid-area: form; + position: relative; + } + .dropdown { + display: grid; + grid-gap: 1px; + grid-template-columns: 100px auto; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; + position: absolute; + background: var(--background); + padding-right: 4rem; + z-index: 1; + } + #options-toggle-cbox:checked ~ .dropdown-content { + width: calc(100% + 100px); + max-height: 80vh; + overflow-y: scroll; + } + + .footer { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-column-gap: 2rem; + align-items: center; + justify-content: center; + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; + } +} diff --git a/youtube/static/js/av-merge.js b/youtube/static/js/av-merge.js new file mode 100644 index 0000000..e00f440 --- /dev/null +++ b/youtube/static/js/av-merge.js @@ -0,0 +1,987 @@ +// Heavily modified from +// https://github.com/nickdesaulniers/netfix/issues/4#issuecomment-578856471 +// which was in turn modified from +// https://github.com/nickdesaulniers/netfix/blob/gh-pages/demo/bufferWhenNeeded.html + +// Useful reading: +// https://stackoverflow.com/questions/35177797/what-exactly-is-fragmented-mp4fmp4-how-is-it-different-from-normal-mp4 +// https://axel.isouard.fr/blog/2016/05/24/streaming-webm-video-over-html5-with-media-source + +// We start by parsing the sidx (segment index) table in order to get the +// byte ranges of the segments. The byte range of the sidx table is provided +// by the indexRange variable by YouTube + +// Useful info, as well as segments vs sequence mode (we use segments mode) +// https://joshuatz.com/posts/2020/appending-videos-in-javascript-with-mediasource-buffers/ + +// SourceBuffer data limits: +// https://developers.google.com/web/updates/2017/10/quotaexceedederror + +// TODO: Call abort to cancel in-progress appends? + + + +function AVMerge(video, srcInfo, startTime){ + this.audioSource = null; + this.videoSource = null; + this.avRatio = null; + this.videoStream = null; + this.audioStream = null; + this.seeking = false; + this.startTime = startTime; + this.video = video; + this.mediaSource = null; + this.closed = false; + this.opened = false; + this.audioEndOfStreamCalled = false; + this.videoEndOfStreamCalled = false; + if (!('MediaSource' in window)) { + reportError('MediaSource not supported.'); + return; + } + + // Find supported video and audio sources + for (let src of srcInfo['videos']) { + if (MediaSource.isTypeSupported(src['mime_codec'])) { + reportDebug('Using video source', src['mime_codec'], + src['quality_string'], 'itag', src['itag']); + this.videoSource = src; + break; + } + } + for (let src of srcInfo['audios']) { + if (MediaSource.isTypeSupported(src['mime_codec'])) { + reportDebug('Using audio source', src['mime_codec'], + src['quality_string'], 'itag', src['itag']); + this.audioSource = src; + break; + } + } + if (this.videoSource === null) + reportError('No supported video MIME type or codec found: ', + srcInfo['videos'].map(s => s.mime_codec).join(', ')); + if (this.audioSource === null) + reportError('No supported audio MIME type or codec found: ', + srcInfo['audios'].map(s => s.mime_codec).join(', ')); + if (this.videoSource === null || this.audioSource === null) + return; + + if (this.videoSource.bitrate && this.audioSource.bitrate) + this.avRatio = this.audioSource.bitrate/this.videoSource.bitrate; + else + this.avRatio = 1/10; + + this.setup(); +} +AVMerge.prototype.setup = function() { + this.mediaSource = new MediaSource(); + this.video.src = URL.createObjectURL(this.mediaSource); + this.mediaSource.onsourceopen = this.sourceOpen.bind(this); +} + +AVMerge.prototype.sourceOpen = function(_) { + // If after calling mediaSource.endOfStream, the user seeks back + // into the video, the sourceOpen event will be fired again. Do not + // overwrite the streams. + this.audioEndOfStreamCalled = false; + this.videoEndOfStreamCalled = false; + if (this.opened) + return; + this.opened = true; + this.videoStream = new Stream(this, this.videoSource, this.startTime, + this.avRatio); + this.audioStream = new Stream(this, this.audioSource, this.startTime, + this.avRatio); + + this.videoStream.setup(); + this.audioStream.setup(); + + this.timeUpdateEvt = addEvent(this.video, 'timeupdate', + this.checkBothBuffers.bind(this)); + this.seekingEvt = addEvent(this.video, 'seeking', + debounce(this.seek.bind(this), 500)); + //this.video.onseeked = function() {console.log('seeked')}; +} +AVMerge.prototype.close = function() { + if (this.closed) + return; + this.closed = true; + this.videoStream.close(); + this.audioStream.close(); + this.timeUpdateEvt.remove(); + this.seekingEvt.remove(); + if (this.mediaSource.readyState == 'open') + this.mediaSource.endOfStream(); +} +AVMerge.prototype.checkBothBuffers = function() { + this.audioStream.checkBuffer(); + this.videoStream.checkBuffer(); +} +AVMerge.prototype.seek = function(e) { + if (this.mediaSource.readyState === 'open') { + this.seeking = true; + this.audioStream.handleSeek(); + this.videoStream.handleSeek(); + this.seeking = false; + } else { + reportWarning('seek but not open? readyState:', + this.mediaSource.readyState); + } +} +AVMerge.prototype.audioEndOfStream = function() { + if (this.videoEndOfStreamCalled && !this.audioEndOfStreamCalled) { + reportDebug('Calling mediaSource.endOfStream()'); + this.mediaSource.endOfStream(); + } + this.audioEndOfStreamCalled = true; +} +AVMerge.prototype.videoEndOfStream = function() { + if (this.audioEndOfStreamCalled && !this.videoEndOfStreamCalled) { + reportDebug('Calling mediaSource.endOfStream()'); + this.mediaSource.endOfStream(); + } + this.videoEndOfStreamCalled = true; +} +AVMerge.prototype.printDebuggingInfo = function() { + reportDebug('videoSource:', this.videoSource); + reportDebug('audioSource:', this.videoSource); + reportDebug('video sidx:', this.videoStream.sidx); + reportDebug('audio sidx:', this.audioStream.sidx); + reportDebug('video updating', this.videoStream.sourceBuffer.updating); + reportDebug('audio updating', this.audioStream.sourceBuffer.updating); + reportDebug('video duration:', this.video.duration); + reportDebug('video current time:', this.video.currentTime); + reportDebug('mediaSource.readyState:', this.mediaSource.readyState); + reportDebug('videoEndOfStreamCalled', this.videoEndOfStreamCalled); + reportDebug('audioEndOfStreamCalled', this.audioEndOfStreamCalled); + for (let obj of [this.videoStream, this.audioStream]) { + reportDebug(obj.streamType, 'stream buffered times:'); + for (let i=0; i { + this.reportError('sourceBuffer error', e); + }); + this.updateendEvt = addEvent(this.sourceBuffer, 'updateend', (e) => { + if (this.appendQueue.length != 0) { + this.appendSegment(...this.appendQueue.shift()); + } + }); +} +Stream.prototype.setup = async function(){ + // Group requests together + if (this.initRange.end+1 == this.indexRange.start){ + fetchRange( + this.url, + this.initRange.start, + this.indexRange.end, + 'Initialization+index segments', + ).then( + (buffer) => { + let init_end = this.initRange.end - this.initRange.start + 1; + let index_start = this.indexRange.start - this.initRange.start; + let index_end = this.indexRange.end - this.initRange.start + 1; + this.setupInitSegment(buffer.slice(0, init_end)); + this.setupSegmentIndex(buffer.slice(index_start, index_end)); + } + ); + } else { + // initialization data + await fetchRange( + this.url, + this.initRange.start, + this.initRange.end, + 'Initialization segment', + ).then(this.setupInitSegment.bind(this)); + + // sidx (segment index) table + fetchRange( + this.url, + this.indexRange.start, + this.indexRange.end, + 'Index segment', + ).then(this.setupSegmentIndex.bind(this)); + } +} +Stream.prototype.setupInitSegment = function(initSegment) { + if (this.ext == 'webm') + this.sidx = extractWebmInitializationInfo(initSegment); + this.appendSegment(null, initSegment); +} +Stream.prototype.setupSegmentIndex = async function(indexSegment){ + if (this.ext == 'webm') { + this.sidx.entries = parseWebmCues(indexSegment, this.sidx); + if (this.fileSize) { + let lastIdx = this.sidx.entries.length - 1; + this.sidx.entries[lastIdx].end = this.fileSize - 1; + } + for (let entry of this.sidx.entries) { + entry.subSegmentDuration = entry.tickEnd - entry.tickStart + 1; + if (entry.end) + entry.referencedSize = entry.end - entry.start + 1; + } + } else { + let box = unbox(indexSegment); + this.sidx = sidx_parse(box.data, this.indexRange.end+1); + } + this.fetchSegmentIfNeeded(this.getSegmentIdx(this.startTime)); +} +Stream.prototype.close = function() { + // Prevents appendSegment adding to buffer if request finishes + // after closing + this.closed = true; + if (this.sourceBuffer.updating) + this.sourceBuffer.abort(); + this.mediaSource.removeSourceBuffer(this.sourceBuffer); + this.updateendEvt.remove(); +} +Stream.prototype.appendSegment = function(segmentIdx, chunk) { + if (this.closed) + return; + + this.reportDebug('Received segment', segmentIdx) + + // cannot append right now, schedule for updateend + if (this.sourceBuffer.updating) { + this.reportDebug('sourceBuffer updating, queueing for later'); + this.appendQueue.push([segmentIdx, chunk]); + if (this.appendQueue.length > 2){ + this.reportWarning('appendQueue length:', this.appendQueue.length); + } + return; + } + try { + this.sourceBuffer.appendBuffer(chunk); + if (segmentIdx !== null) + this.sidx.entries[segmentIdx].have = true; + this.appendRetries = 0; + } catch (e) { + if (e.name !== 'QuotaExceededError') { + throw e; + } + this.reportWarning('QuotaExceededError.'); + + // Count how many bytes are in buffer to update buffering target, + // updating .have as well for when we need to delete segments + let bytesInBuffer = 0; + for (let i = 0; i < this.sidx.entries.length; i++) { + if (this.segmentInBuffer(i)) + bytesInBuffer += this.sidx.entries[i].referencedSize; + else if (this.sidx.entries[i].have) { + this.sidx.entries[i].have = false; + this.sidx.entries[i].requested = false; + } + } + bytesInBuffer = Math.floor(4/5*bytesInBuffer); + if (bytesInBuffer < this.bufferTarget) { + this.bufferTarget = bytesInBuffer; + this.reportDebug('New buffer target:', this.bufferTarget); + } + + // Delete 10 segments (arbitrary) from buffer, making sure + // not to delete current one + let currentSegment = this.getSegmentIdx(this.video.currentTime); + let numDeleted = 0; + let i = 0; + const DELETION_TARGET = 10; + let toDelete = []; // See below for why we have to schedule it + this.reportDebug('Deleting segments from beginning of buffer.'); + while (numDeleted < DELETION_TARGET && i < currentSegment) { + if (this.sidx.entries[i].have) { + toDelete.push(i) + numDeleted++; + } + i++; + } + if (numDeleted < DELETION_TARGET) + this.reportDebug('Deleting segments from end of buffer.'); + + i = this.sidx.entries.length - 1; + while (numDeleted < DELETION_TARGET && i > currentSegment) { + if (this.sidx.entries[i].have) { + toDelete.push(i) + numDeleted++; + } + i--; + } + + // When calling .remove, the sourceBuffer will go into updating=true + // state, and remove cannot be called until it is done. So we have + // to delete on the updateend event for subsequent ones. + let removeFinishedEvent; + let deletedStuff = (toDelete.length !== 0) + let deleteSegment = () => { + if (toDelete.length === 0) { + removeFinishedEvent.remove(); + // If QuotaExceeded happened for current segment, retry the + // append + // Rescheduling will take care of updating=true problem. + // Also check that we found segments to delete, to avoid + // infinite looping if we can't delete anything + if (segmentIdx === currentSegment && deletedStuff) { + this.reportDebug('Retrying appendSegment for', segmentIdx); + this.appendSegment(segmentIdx, chunk); + } else { + this.reportDebug('Not retrying segment', segmentIdx); + this.sidx.entries[segmentIdx].requested = false; + } + return; + } + let idx = toDelete.shift(); + let entry = this.sidx.entries[idx]; + let start = entry.tickStart/this.sidx.timeScale; + let end = (entry.tickEnd+1)/this.sidx.timeScale; + this.reportDebug('Deleting segment', idx); + this.sourceBuffer.remove(start, end); + entry.have = false; + entry.requested = false; + } + removeFinishedEvent = addEvent(this.sourceBuffer, 'updateend', + deleteSegment); + if (!this.sourceBuffer.updating) + deleteSegment(); + } +} +Stream.prototype.getSegmentIdx = function(videoTime) { + // get an estimate + let currentTick = videoTime * this.sidx.timeScale; + let firstSegmentDuration = this.sidx.entries[0].subSegmentDuration; + let index = 1 + Math.floor(currentTick / firstSegmentDuration); + index = clamp(index, 0, this.sidx.entries.length - 1); + + let increment = 1; + if (currentTick < this.sidx.entries[index].tickStart){ + increment = -1; + } + + // go up or down to find correct index + while (index >= 0 && index < this.sidx.entries.length) { + let entry = this.sidx.entries[index]; + if (entry.tickStart <= currentTick && (entry.tickEnd+1) > currentTick){ + return index; + } + index = index + increment; + } + this.reportError('Could not find segment index for time', videoTime); + return 0; +} +Stream.prototype.checkBuffer = async function() { + if (this.avMerge.seeking) { + return; + } + // Find the first unbuffered segment, i + let currentSegmentIdx = this.getSegmentIdx(this.video.currentTime); + let bufferedBytesAhead = 0; + let i; + for (i = currentSegmentIdx; i < this.sidx.entries.length; i++) { + let entry = this.sidx.entries[i]; + // check if we had it before, but it was deleted by the browser + if (entry.have && !this.segmentInBuffer(i)) { + this.reportDebug('segment', i, 'deleted by browser'); + entry.have = false; + entry.requested = false; + } + if (!entry.have) { + break; + } + bufferedBytesAhead += entry.referencedSize; + if (bufferedBytesAhead > this.bufferTarget) { + return; + } + } + + if (i < this.sidx.entries.length && !this.sidx.entries[i].requested) { + this.fetchSegment(i); + // We have all the segments until the end + // Signal the end of stream + } else if (i == this.sidx.entries.length) { + if (this.streamType == 'audio') + this.avMerge.audioEndOfStream(); + else + this.avMerge.videoEndOfStream(); + } +} +Stream.prototype.segmentInBuffer = function(segmentIdx) { + let entry = this.sidx.entries[segmentIdx]; + // allow for 0.01 second error + let timeStart = entry.tickStart/this.sidx.timeScale + 0.01; + + /* Some of YouTube's mp4 fragments are malformed, with half-frame + playback gaps. In this video at 240p (timeScale = 90000 ticks/second) + https://www.youtube.com/watch?v=ZhOQCwJvwlo + segment 4 (starting at 0) is claimed in the sidx table to have + a duration of 388500 ticks, but closer examination of the file using + Bento4 mp4dump shows that the segment has 129 frames at 3000 ticks + per frame, which gives an actual duration of 38700 (1500 less than + claimed). The file is 30 fps, so this error is exactly half a frame. + + Note that the base_media_decode_time exactly matches the tickStart, + so the media decoder is being given a time gap of half a frame. + + The practical result of this is that sourceBuffer.buffered reports + a timeRange.end that is less than expected for that segment, resulting in + a false determination that the browser has deleted a segment. + + Segment 5 has the opposite issue, where it has a 1500 tick surplus of video + data compared to the sidx length. Segments 6 and 7 also have this + deficit-surplus pattern. + + This might have something to do with the fact that the video also + has 60 fps formats. In order to allow for adaptive streaming and seamless + quality switching, YouTube likely encodes their formats to line up nicely. + Either there is a bug in their encoder, or this is intentional. Allow for + up to 1 frame-time of error to work around this issue. */ + let endError; + if (this.streamType == 'video') + endError = 1/(this.avMerge.videoSource.fps || 30); + else + endError = 0.01 + let timeEnd = (entry.tickEnd+1)/this.sidx.timeScale - endError; + + let timeRanges = this.sourceBuffer.buffered; + for (let i=0; i < timeRanges.length; i++) { + if (timeRanges.start(i) <= timeStart && timeEnd <= timeRanges.end(i)) { + return true; + } + } + return false; +} +Stream.prototype.fetchSegment = function(segmentIdx) { + entry = this.sidx.entries[segmentIdx]; + entry.requested = true; + this.reportDebug( + 'Fetching segment', segmentIdx, ', bytes', + entry.start, entry.end, ', seconds', + entry.tickStart/this.sidx.timeScale, + (entry.tickEnd+1)/this.sidx.timeScale + ) + fetchRange( + this.url, + entry.start, + entry.end, + String(this.streamType) + ' segment ' + String(segmentIdx), + ).then(this.appendSegment.bind(this, segmentIdx)); +} +Stream.prototype.fetchSegmentIfNeeded = function(segmentIdx) { + if (segmentIdx < 0 || segmentIdx >= this.sidx.entries.length){ + return; + } + entry = this.sidx.entries[segmentIdx]; + // check if we had it before, but it was deleted by the browser + if (entry.have && !this.segmentInBuffer(segmentIdx)) { + this.reportDebug('segment', segmentIdx, 'deleted by browser'); + entry.have = false; + entry.requested = false; + } + if (entry.requested) { + return; + } + + this.fetchSegment(segmentIdx); +} +Stream.prototype.handleSeek = function() { + let segmentIdx = this.getSegmentIdx(this.video.currentTime); + this.fetchSegmentIfNeeded(segmentIdx); +} +Stream.prototype.reportDebug = function(...args) { + reportDebug(String(this.streamType) + ':', ...args); +} +Stream.prototype.reportWarning = function(...args) { + reportWarning(String(this.streamType) + ':', ...args); +} +Stream.prototype.reportError = function(...args) { + reportError(String(this.streamType) + ':', ...args); +} + + +// Utility functions + +// https://gomakethings.com/promise-based-xhr/ +// https://stackoverflow.com/a/30008115 +// http://lofi.limo/blog/retry-xmlhttprequest-carefully +function fetchRange(url, start, end, debugInfo) { + return new Promise((resolve, reject) => { + let retryCount = 0; + let xhr = new XMLHttpRequest(); + function onFailure(err, message, maxRetries=5){ + message = debugInfo + ': ' + message + ' - Err: ' + String(err); + retryCount++; + if (retryCount > maxRetries || xhr.status == 403){ + reportError('fetchRange error while fetching ' + message); + reject(message); + return; + } else { + reportWarning('Failed to fetch ' + message + + '. Attempting retry ' + + String(retryCount) +'/' + String(maxRetries)); + } + + // Retry in 1 second, doubled for each next retry + setTimeout(function(){ + xhr.open('get',url); + xhr.send(); + }, 1000*Math.pow(2,(retryCount-1))); + } + xhr.open('get', url); + xhr.timeout = 15000; + xhr.responseType = 'arraybuffer'; + xhr.setRequestHeader('Range', 'bytes=' + start + '-' + end); + xhr.onload = function (e) { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response); + } else { + onFailure(e, + 'Status ' + + String(xhr.status) + ' ' + String(xhr.statusText) + ); + } + }; + xhr.onerror = function (event) { + onFailure(e, 'Network error'); + }; + xhr.ontimeout = function (event){ + xhr.timeout += 5000; + onFailure(null, 'Timeout (15s)', maxRetries=5); + }; + xhr.send(); + }); +} + +function debounce(func, wait, immediate) { + let timeout; + return function() { + let context = this; + let args = arguments; + let later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + let callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; +} + +function clamp(number, min, max) { + return Math.max(min, Math.min(number, max)); +} + +// allow to remove an event listener without having a function reference +function RegisteredEvent(obj, eventName, func) { + this.obj = obj; + this.eventName = eventName; + this.func = func; + obj.addEventListener(eventName, func); +} +RegisteredEvent.prototype.remove = function() { + this.obj.removeEventListener(this.eventName, this.func); +} +function addEvent(obj, eventName, func) { + return new RegisteredEvent(obj, eventName, func); +} + +function reportWarning(...args){ + console.warn(...args); +} +function reportError(...args){ + console.error(...args); +} +function reportDebug(...args){ + console.debug(...args); +} + +function byteArrayToIntegerLittleEndian(unsignedByteArray){ + let result = 0; + for (byte of unsignedByteArray){ + result = result*256; + result += byte + } + return result; +} +function byteArrayToFloat(byteArray) { + let view = new DataView(byteArray.buffer); + if (byteArray.length == 4) + return view.getFloat32(byteArray.byteOffset); + else + return view.getFloat64(byteArray.byteOffset); +} +function ByteParser(data){ + this.curIndex = 0; + this.data = new Uint8Array(data); +} +ByteParser.prototype.readInteger = function(nBytes){ + let result = byteArrayToIntegerLittleEndian( + this.data.slice(this.curIndex, this.curIndex + nBytes) + ); + this.curIndex += nBytes; + return result; +} +ByteParser.prototype.readBufferBytes = function(nBytes){ + let result = this.data.slice(this.curIndex, this.curIndex + nBytes); + this.curIndex += nBytes; + return result; +} + +// BEGIN iso-bmff-parser-stream/lib/box/sidx.js (modified) +// https://github.com/necccc/iso-bmff-parser-stream/blob/master/lib/box/sidx.js +/* The MIT License (MIT) + +Copyright (c) 2014 Szabolcs Szabolcsi-Toth + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.*/ +function sidx_parse (data, offset) { + let bp = new ByteParser(data), + version = bp.readInteger(1), + flags = bp.readInteger(3), + referenceId = bp.readInteger(4), + timeScale = bp.readInteger(4), + earliestPresentationTime = bp.readInteger(version === 0 ? 4 : 8), + firstOffset = bp.readInteger(4), + __reserved = bp.readInteger(2), + entryCount = bp.readInteger(2), + entries = []; + + let totalBytesOffset = firstOffset + offset; + let totalTicks = 0; + for (let i = entryCount; i > 0; i=i-1 ) { + let referencedSize = bp.readInteger(4), + subSegmentDuration = bp.readInteger(4), + unused = bp.readBufferBytes(4) + entries.push({ + referencedSize: referencedSize, + subSegmentDuration: subSegmentDuration, + unused: unused, + start: totalBytesOffset, + end: totalBytesOffset + referencedSize - 1, // inclusive + tickStart: totalTicks, + tickEnd: totalTicks + subSegmentDuration - 1, + requested: false, + have: false, + }); + totalBytesOffset = totalBytesOffset + referencedSize; + totalTicks = totalTicks + subSegmentDuration; + } + + return { + version: version, + flags: flags, + referenceId: referenceId, + timeScale: timeScale, + earliestPresentationTime: earliestPresentationTime, + firstOffset: firstOffset, + entries: entries + }; +} +// END sidx.js + +// BEGIN iso-bmff-parser-stream/lib/unbox.js (same license), modified +function unbox(buf) { + let bp = new ByteParser(buf), + bufferLength = buf.length, + length, + typeData, + boxData + + length = bp.readInteger(4); // length of entire box, + typeData = bp.readInteger(4); + + if (bufferLength - length < 0) { + reportWarning('Warning: sidx table is cut off'); + return { + currentLength: bufferLength, + length: length, + type: typeData, + data: bp.readBufferBytes(bufferLength) + }; + } + + boxData = bp.readBufferBytes(length - 8); + + return { + length: length, + type: typeData, + data: boxData + }; +} +// END unbox.js + + +function extractWebmInitializationInfo(initializationSegment) { + let result = { + timeScale: null, + cuesOffset: null, + duration: null, + }; + (new EbmlDecoder()).readTags(initializationSegment, (tagType, tag) => { + if (tag.name == 'TimecodeScale') + result.timeScale = byteArrayToIntegerLittleEndian(tag.data); + else if (tag.name == 'Duration') + // Integer represented as a float (why??); units of TimecodeScale + result.duration = byteArrayToFloat(tag.data); + // https://lists.matroska.org/pipermail/matroska-devel/2013-July/004549.html + // "CueClusterPosition in turn is relative to the segment's data start + // position" (the data start is the position after the bytes + // used to represent the tag ID and entry size) + else if (tagType == 'start' && tag.name == 'Segment') + result.cuesOffset = tag.dataStart; + }); + if (result.timeScale === null) { + result.timeScale = 1000000; + } + + // webm timecodeScale is the number of nanoseconds in a tick + // Convert it to number of ticks per second to match mp4 convention + result.timeScale = 10**9/result.timeScale; + return result; +} +function parseWebmCues(indexSegment, initInfo) { + let entries = []; + let currentEntry = {}; + let cuesOffset = initInfo.cuesOffset; + (new EbmlDecoder()).readTags(indexSegment, (tagType, tag) => { + if (tag.name == 'CueTime') { + const tickStart = byteArrayToIntegerLittleEndian(tag.data); + currentEntry.tickStart = tickStart; + if (entries.length !== 0) + entries[entries.length - 1].tickEnd = tickStart - 1; + } else if (tag.name == 'CueClusterPosition') { + const byteStart = byteArrayToIntegerLittleEndian(tag.data); + currentEntry.start = cuesOffset + byteStart; + if (entries.length !== 0) + entries[entries.length - 1].end = cuesOffset + byteStart - 1; + } else if (tagType == 'end' && tag.name == 'CuePoint') { + entries.push(currentEntry); + currentEntry = {}; + } + }); + if (initInfo.duration) + entries[entries.length - 1].tickEnd = initInfo.duration - 1; + return entries; +} + +// BEGIN node-ebml (modified) for parsing WEBM cues table +// https://github.com/node-ebml/node-ebml + +/* Copyright (c) 2013-2018 Mark Schmale and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.*/ + +const schema = new Map([ + [0x18538067, ['Segment', 'm']], + [0x1c53bb6b, ['Cues', 'm']], + [0xbb, ['CuePoint', 'm']], + [0xb3, ['CueTime', 'u']], + [0xb7, ['CueTrackPositions', 'm']], + [0xf7, ['CueTrack', 'u']], + [0xf1, ['CueClusterPosition', 'u']], + [0x1549a966, ['Info', 'm']], + [0x2ad7b1, ['TimecodeScale', 'u']], + [0x4489, ['Duration', 'f']], +]); + + +function EbmlDecoder() { + this.buffer = null; + this.emit = null; + this.tagStack = []; + this.cursor = 0; +} +EbmlDecoder.prototype.readTags = function(chunk, onParsedTag) { + this.buffer = new Uint8Array(chunk); + this.emit = onParsedTag; + + while (this.cursor < this.buffer.length) { + if (!this.readTag() || !this.readSize() || !this.readContent()) { + break; + } + } +} +EbmlDecoder.prototype.getSchemaInfo = function(tag) { + if (Number.isInteger(tag) && schema.has(tag)) { + let name, type; + [name, type] = schema.get(tag); + return {name, type}; + } + return { + type: null, + name: 'unknown', + }; +} +EbmlDecoder.prototype.readTag = function() { + if (this.cursor >= this.buffer.length) { + return false; + } + + const tag = readVint(this.buffer, this.cursor); + if (tag == null) { + return false; + } + + const tagObj = { + tag: tag.value, + ...this.getSchemaInfo(tag.valueWithLeading1), + start: this.cursor, + end: this.cursor + tag.length, // exclusive; also overwritten below + }; + this.tagStack.push(tagObj); + + this.cursor += tag.length; + return true; +} +EbmlDecoder.prototype.readSize = function() { + const tagObj = this.tagStack[this.tagStack.length - 1]; + + if (this.cursor >= this.buffer.length) { + return false; + } + + const size = readVint(this.buffer, this.cursor); + if (size == null) { + return false; + } + + tagObj.dataSize = size.value; + + // unknown size + if (size.value === -1) { + tagObj.end = -1; + } else { + tagObj.end += size.value + size.length; + } + + this.cursor += size.length; + tagObj.dataStart = this.cursor; + return true; +} +EbmlDecoder.prototype.readContent = function() { + const { type, dataSize, ...rest } = this.tagStack[ + this.tagStack.length - 1 + ]; + + if (type === 'm') { + this.emit('start', { type, dataSize, ...rest }); + return true; + } + + if (this.buffer.length < this.cursor + dataSize) { + return false; + } + + const data = this.buffer.subarray(this.cursor, this.cursor + dataSize); + this.cursor += dataSize; + + this.tagStack.pop(); // remove the object from the stack + + this.emit('tag', { type, dataSize, data, ...rest }); + + while (this.tagStack.length > 0) { + const topEle = this.tagStack[this.tagStack.length - 1]; + if (this.cursor < topEle.end) { + break; + } + this.emit('end', topEle); + this.tagStack.pop(); + } + return true; +} + + +// user234683 notes: The matroska variable integer format is as follows: +// The first byte is where the length of the integer in bytes is determined. +// The number of bytes for the integer is equal to the number of leading +// zeroes in that first byte PLUS 1. Then there is a single 1 bit separator, +// and the rest of the bits in the first byte and the rest of the bits in +// the subsequent bytes are the value of the number. Note the 1-bit separator +// is not part of the value, but by convention IS included in the value for the +// EBML Tag IDs in the schema table above +// The byte-length includes the first byte. So one could also say the number +// of leading zeros is the number of subsequent bytes to include. +function readVint(buffer, start = 0) { + const length = 8 - Math.floor(Math.log2(buffer[start])); + + if (start + length > buffer.length) { + return null; + } + + let value = buffer[start] & ((1 << (8 - length)) - 1); + let valueWithLeading1 = buffer[start] & ((1 << (8 - length + 1)) - 1); + for (let i = 1; i < length; i += 1) { + // user234683 notes: Bails out with -1 (unknown) if the value would + // exceed 53 bits, which is the limit since JavaScript stores all + // numbers as floating points. See + // https://github.com/node-ebml/node-ebml/issues/49 + if (i === 7) { + if (value >= 2 ** 8 && buffer[start + 7] > 0) { + return { length, value: -1, valueWithLeading1: -1 }; + } + } + value *= 2 ** 8; + value += buffer[start + i]; + valueWithLeading1 *= 2 ** 8; + valueWithLeading1 += buffer[start + i]; + } + + return { length, value, valueWithLeading1 }; +} +// END node-ebml diff --git a/youtube/static/js/comments.js b/youtube/static/js/comments.js new file mode 100644 index 0000000..14ba0c0 --- /dev/null +++ b/youtube/static/js/comments.js @@ -0,0 +1,20 @@ +function onClickReplies(e) { + let details = e.target.parentElement; + // e.preventDefault(); + console.log("loading replies .."); + doXhr(details.getAttribute("data-src") + "&slim=1", (html) => { + let div = details.querySelector(".comment_page"); + div.innerHTML = html; + }); + details.removeEventListener('click', onClickReplies); +} + +window.addEventListener('DOMContentLoaded', function() { + QA("details.replies").forEach(details => { + details.addEventListener('click', onClickReplies); + details.addEventListener('auxclick', (e) => { + if (e.target.parentElement !== details) return; + if (e.button == 1) window.open(details.getAttribute("data-src")); + }); + }); +}); diff --git a/youtube/static/js/common.js b/youtube/static/js/common.js new file mode 100644 index 0000000..599d578 --- /dev/null +++ b/youtube/static/js/common.js @@ -0,0 +1,116 @@ +const Q = document.querySelector.bind(document); +const QA = document.querySelectorAll.bind(document); +const QId = document.getElementById.bind(document); +let seconds, + minutes, + hours; +function text(msg) { return document.createTextNode(msg); } +function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); } +function toTimestamp(seconds) { + seconds = Math.floor(seconds); + + minutes = Math.floor(seconds/60); + seconds = seconds % 60; + + hours = Math.floor(minutes/60); + minutes = minutes % 60; + + if (hours) { + return `0${hours}:`.slice(-3) + `0${minutes}:`.slice(-3) + `0${seconds}`.slice(-2); + } + return `0${minutes}:`.slice(-3) + `0${seconds}`.slice(-2); +} + +let cur_track_idx = 0; +function getActiveTranscriptTrackIdx() { + let textTracks = QId("js-video-player").textTracks; + if (!textTracks.length) return; + for (let i=0; i < textTracks.length; i++) { + if (textTracks[i].mode == "showing") { + cur_track_idx = i; + return cur_track_idx; + } + } + return cur_track_idx; +} +function getActiveTranscriptTrack() { return QId("js-video-player").textTracks[getActiveTranscriptTrackIdx()]; } + +function getDefaultTranscriptTrackIdx() { + let textTracks = QId("js-video-player").textTracks; + return textTracks.length - 1; +} + +function doXhr(url, callback=null) { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.onload = (e) => { + callback(e.currentTarget.response); + } + xhr.send(); + return xhr; +} + +// https://stackoverflow.com/a/30810322 +function copyTextToClipboard(text) { + let textArea = document.createElement("textarea"); + + // + // *** This styling is an extra step which is likely not required. *** + // + // Why is it here? To ensure: + // 1. the element is able to have focus and selection. + // 2. if element was to flash render it has minimal visual impact. + // 3. less flakyness with selection and copying which **might** occur if + // the textarea element is not visible. + // + // The likelihood is the element won't even render, not even a + // flash, so some of these are just precautions. However in + // Internet Explorer the element is visible whilst the popup + // box asking the user for permission for the web page to + // copy to the clipboard. + // + + // Place in top-left corner of screen regardless of scroll position. + textArea.style.position = 'fixed'; + textArea.style.top = 0; + textArea.style.left = 0; + + // Ensure it has a small width and height. Setting to 1px / 1em + // doesn't work as this gives a negative w/h on some browsers. + textArea.style.width = '2em'; + textArea.style.height = '2em'; + + // We don't need padding, reducing the size if it does flash render. + textArea.style.padding = 0; + + // Clean up any borders. + textArea.style.border = 'none'; + textArea.style.outline = 'none'; + textArea.style.boxShadow = 'none'; + + // Avoid flash of white box if rendered for any reason. + textArea.style.background = 'transparent'; + + + textArea.value = text; + + let parent_el = video.parentElement; + parent_el.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + let successful = document.execCommand('copy'); + let msg = successful ? 'successful' : 'unsuccessful'; + console.log('Copying text command was ' + msg); + } catch (err) { + console.log('Oops, unable to copy'); + } + + parent_el.removeChild(textArea); +} + + +window.addEventListener('DOMContentLoaded', function() { + cur_track_idx = getDefaultTranscriptTrackIdx(); +}); diff --git a/youtube/static/js/hotkeys.js b/youtube/static/js/hotkeys.js new file mode 100644 index 0000000..b71972e --- /dev/null +++ b/youtube/static/js/hotkeys.js @@ -0,0 +1,61 @@ +function onKeyDown(e) { + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return false; + + // console.log(e); + let v = QId("js-video-player"); + if (!e.isTrusted) return; // plyr CustomEvent + let c = e.key.toLowerCase(); + if (e.ctrlKey) return; + else if (c == "k") { + v.paused ? v.play() : v.pause(); + } + else if (c == "arrowleft") { + e.preventDefault(); + v.currentTime = v.currentTime - 5; + } + else if (c == "arrowright") { + e.preventDefault(); + v.currentTime = v.currentTime + 5; + } + else if (c == "j") { + e.preventDefault(); + v.currentTime = v.currentTime - 10; + } + else if (c == "l") { + e.preventDefault(); + v.currentTime = v.currentTime + 10; + } + else if (c == "f") { + e.preventDefault(); + if (data.settings.use_video_player == 2) { + player.fullscreen.toggle() + } + else { + if (document.fullscreen) { + document.exitFullscreen() + } + else { + v.requestFullscreen() + } + } + } + else if (c == "m") { + if (v.muted == false) {v.muted = true;} + else {v.muted = false;} + } + else if (c == "c") { + e.preventDefault(); + let tt = getActiveTranscriptTrack(); + if (tt == null) return; + if (tt.mode == "showing") tt.mode = "disabled"; + else tt.mode = "showing"; + } + else if (c == "t") { + let ts = Math.floor(QId("js-video-player").currentTime); + copyTextToClipboard(`https://youtu.be/${data.video_id}?t=${ts}`); + } +} + +window.addEventListener('DOMContentLoaded', function() { + document.addEventListener('keydown', onKeyDown); +}); diff --git a/youtube/static/js/playlistadd.js b/youtube/static/js/playlistadd.js new file mode 100644 index 0000000..4b76ce1 --- /dev/null +++ b/youtube/static/js/playlistadd.js @@ -0,0 +1,86 @@ +(function main() { + /* Takes control of the form if javascript is enabled, so that adding stuff to a playlist will not cause things to stop loading, and will display a status message. If javascript is disabled, the form will still work using regular HTML methods, but causes things on the page (such as the video) to stop loading. */ + const playlistAddForm = document.getElementById('playlist-edit'); + + function setStyle(element, property, value){ + element.style[property] = value; + } + function removeMessage(messageBox){ + messageBox.parentNode.removeChild(messageBox); + } + + function displayMessage(text, error=false){ + let currentMessageBox = document.getElementById('message-box'); + if(currentMessageBox !== null){ + currentMessageBox.parentNode.removeChild(currentMessageBox); + } + let messageBox = document.createElement('div'); + if(error){ + messageBox.setAttribute('role', 'alert'); + } else { + messageBox.setAttribute('role', 'status'); + } + messageBox.setAttribute('id', 'message-box'); + let textNode = document.createTextNode(text); + messageBox.appendChild(textNode); + document.querySelector('main').appendChild(messageBox); + let currentstyle = window.getComputedStyle(messageBox); + let removalDelay; + if(error){ + removalDelay = 5000; + } else { + removalDelay = 1500; + } + window.setTimeout(setStyle, 20, messageBox, 'opacity', 1); + window.setTimeout(setStyle, removalDelay, messageBox, 'opacity', 0); + window.setTimeout(removeMessage, removalDelay+300, messageBox); + } + // https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Sending_forms_through_JavaScript + function sendData(event){ + let clicked_button = document.activeElement; + if(clicked_button === null || clicked_button.getAttribute('type') !== 'submit' || clicked_button.parentElement != event.target){ + console.log('ERROR: clicked_button not valid'); + return; + } + if(clicked_button.getAttribute('value') !== 'add'){ + return; // video(s) are being removed from playlist, just let it refresh the page + } + event.preventDefault(); + let XHR = new XMLHttpRequest(); + let FD = new FormData(playlistAddForm); + + if(FD.getAll('video_info_list').length === 0){ + displayMessage('Error: No videos selected', true); + return; + } + + if(FD.get('playlist_name') === ""){ + displayMessage('Error: No playlist selected', true); + return; + } + + // https://stackoverflow.com/questions/48322876/formdata-doesnt-include-value-of-buttons + FD.append('action', 'add'); + + XHR.addEventListener('load', function(event){ + if(event.target.status == 204){ + displayMessage('Added videos to playlist "' + FD.get('playlist_name') + '"'); + } else { + displayMessage('Error adding videos to playlist: ' + event.target.status.toString(), true); + } + }); + + XHR.addEventListener('error', function(event){ + if(event.target.status == 0){ + displayMessage('XHR failed: Check that XHR requests are allowed', true); + } else { + displayMessage('XHR failed: Unknown error', true); + } + }); + + XHR.open('POST', playlistAddForm.getAttribute('action')); + XHR.send(FD); + } + + playlistAddForm.addEventListener('submit', sendData); +}()); diff --git a/youtube/static/js/plyr-start.js b/youtube/static/js/plyr-start.js new file mode 100644 index 0000000..56068f0 --- /dev/null +++ b/youtube/static/js/plyr-start.js @@ -0,0 +1,121 @@ +(function main() { + 'use strict'; + + // Captions + let captionsActive = false; + if (data.settings.subtitles_mode === 2 || (data.settings.subtitles_mode === 1 && data.has_manual_captions)) { + captionsActive = true; + } + + // AutoPlay + let autoplayActive = data.settings.autoplay_videos || false; + + let qualityOptions = []; + let qualityDefault; + + for (let src of data.uni_sources) { + qualityOptions.push(src.quality_string); + } + + for (let src of data.pair_sources) { + qualityOptions.push(src.quality_string); + } + + if (data.using_pair_sources) { + qualityDefault = data.pair_sources[data.pair_idx].quality_string; + } else if (data.uni_sources.length !== 0) { + qualityDefault = data.uni_sources[data.uni_idx].quality_string; + } else { + qualityDefault = 'None'; + } + + // Fix plyr refusing to work with qualities that are strings + Object.defineProperty(Plyr.prototype, 'quality', { + set: function (input) { + const config = this.config.quality; + const options = this.options.quality; + let quality = input; + let updateStorage = true; + + if (!options.length) { + return; + } + + if (!options.includes(quality)) { + return; + } + + // Update config + config.selected = quality; + + // Set quality + this.media.quality = quality; + + // Save to storage + if (updateStorage) { + this.storage.set({ quality }); + } + }, + }); + + const player = new Plyr(document.getElementById('js-video-player'), { + // Learning about autoplay permission https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy/autoplay#syntax + autoplay: autoplayActive, + disableContextMenu: false, + captions: { + active: captionsActive, + language: data.settings.subtitles_language, + }, + controls: [ + 'play-large', + 'play', + 'progress', + 'current-time', + 'duration', + 'mute', + 'volume', + 'captions', + 'settings', + 'pip', + 'airplay', + 'fullscreen', + ], + iconUrl: '/youtube.com/static/modules/plyr/plyr.svg', + blankVideo: '/youtube.com/static/modules/plyr/blank.webm', + debug: false, + storage: { enabled: false }, + quality: { + default: qualityDefault, + options: qualityOptions, + forced: true, + onChange: function (quality) { + if (quality == 'None') { + return; + } + if (quality.includes('(integrated)')) { + for (let i = 0; i < data.uni_sources.length; i++) { + if (data.uni_sources[i].quality_string == quality) { + changeQuality({ type: 'uni', index: i }); + return; + } + } + } else { + for (let i = 0; i < data.pair_sources.length; i++) { + if (data.pair_sources[i].quality_string == quality) { + changeQuality({ type: 'pair', index: i }); + return; + } + } + } + }, + }, + previewThumbnails: { + enabled: storyboard_url !== null, + src: [storyboard_url], + }, + settings: ['captions', 'quality', 'speed', 'loop'], + tooltips: { + controls: true, + }, + }); +})(); diff --git a/youtube/static/js/sponsorblock.js b/youtube/static/js/sponsorblock.js new file mode 100644 index 0000000..a929fc5 --- /dev/null +++ b/youtube/static/js/sponsorblock.js @@ -0,0 +1,40 @@ +"use strict"; + +// from: https://git.gir.st/subscriptionfeed.git/blob/59a590d:/app/youtube/templates/watch.html.j2#l28 + +let sha256=function a(b){function c(a,b){return a>>>b|a<<32-b}for(var d,e,f=Math.pow,g=f(2,32),h="length",i="",j=[],k=8*b[h],l=a.h=a.h||[],m=a.k=a.k||[],n=m[h],o={},p=2;64>n;p++)if(!o[p]){for(d=0;313>d;d+=p)o[d]=p;l[n]=f(p,.5)*g|0,m[n++]=f(p,1/3)*g|0}for(b+="\x80";b[h]%64-56;)b+="\x00";for(d=0;d>8)return;j[d>>2]|=e<<(3-d)%4*8}for(j[j[h]]=k/g|0,j[j[h]]=k,e=0;ed;d++){var s=q[d-15],t=q[d-2],u=l[0],v=l[4],w=l[7]+(c(v,6)^c(v,11)^c(v,25))+(v&l[5]^~v&l[6])+m[d]+(q[d]=16>d?q[d]:q[d-16]+(c(s,7)^c(s,18)^s>>>3)+q[d-7]+(c(t,17)^c(t,19)^t>>>10)|0),x=(c(u,2)^c(u,13)^c(u,22))+(u&l[1]^u&l[2]^l[1]&l[2]);l=[w+x|0].concat(l),l[4]=l[4]+w|0}for(d=0;8>d;d++)l[d]=l[d]+r[d]|0}for(d=0;8>d;d++)for(e=3;e+1;e--){var y=l[d]>>8*e&255;i+=(16>y?0:"")+y.toString(16)}return i}; /*https://geraintluff.github.io/sha256/sha256.min.js (public domain)*/ + +window.addEventListener("load", load_sponsorblock); +document.addEventListener('DOMContentLoaded', ()=>{ + const check = document.querySelector("#skip_sponsors"); + check.addEventListener("change", () => {if (check.checked) load_sponsorblock()}); +}); +function load_sponsorblock(){ + const info_elem = Q('#skip_n'); + if (info_elem.innerText.length) return; // already fetched + const hash = sha256(data.video_id).substr(0,4); + const video_obj = QId("js-video-player"); + let url = `/https://sponsor.ajay.app/api/skipSegments/${hash}`; + fetch(url) + .then(response => response.json()) + .then(r => { + for (const video of r) { + if (video.videoID != data.video_id) continue; + info_elem.innerText = `(${video.segments.length} segments)`; + const cat_n = video.segments.map(e=>e.category).sort() + .reduce((acc,e) => (acc[e]=(acc[e]||0)+1, acc), {}); + info_elem.title = Object.entries(cat_n).map(e=>e.join(': ')).join(', '); + for (const segment of video.segments) { + const [start, stop] = segment.segment; + if (segment.category != "sponsor") continue; + video_obj.addEventListener("timeupdate", function() { + if (Q("#skip_sponsors").checked && + this.currentTime >= start && + this.currentTime < stop-1) { + this.currentTime = stop; + } + }); + } + } + }); +} diff --git a/youtube/static/js/transcript-table.js b/youtube/static/js/transcript-table.js new file mode 100644 index 0000000..5cee97e --- /dev/null +++ b/youtube/static/js/transcript-table.js @@ -0,0 +1,151 @@ +let details_tt, select_tt, table_tt; + +function renderCues() { + const selectedTrack = QId("js-video-player").textTracks[select_tt.selectedIndex]; + const cuesList = [...selectedTrack.cues]; + const is_automatic = cuesList[0].text.startsWith(" \n"); + + // Firefox ignores cues starting with a blank line containing a space + // Automatic captions contain such a blank line in the first cue + let ff_bug = false; + if (!cuesList[0].text.length) { ff_bug = true; is_automatic = true }; + let rows; + + function forEachCue(callback) { + for (let i=0; i < cuesList.length; i++) { + let txt, startTime = selectedTrack.cues[i].startTime; + if (is_automatic) { + // Automatic captions repeat content. The new segment is displayed + // on the bottom row; the old one is displayed on the top row. + // So grab the bottom row only. Skip every other cue because the bottom + // row is empty. + if (i % 2) continue; + if (ff_bug && !selectedTrack.cues[i].text.length) { + txt = selectedTrack.cues[i+1].text; + } else { + txt = selectedTrack.cues[i].text.split('\n')[1].replace(/<[\d:.]*?>(.*?)<\/c>/g, "$1"); + } + } else { + txt = selectedTrack.cues[i].text; + } + callback(startTime, txt); + } + } + + function createTimestampLink(startTime, txt, title=null) { + a = document.createElement("a"); + a.appendChild(text(txt)); + a.href = "javascript:;"; // TODO: replace this with ?t parameter + if (title) a.title = title; + a.addEventListener("click", (e) => { + QId("js-video-player").currentTime = startTime; + }) + return a; + } + + clearNode(table_tt); + console.log("render cues..", selectedTrack.cues.length); + if (Q("input#transcript-use-table").checked) { + forEachCue((startTime, txt) => { + let tr, td, a; + tr = document.createElement("tr"); + + td = document.createElement("td") + td.appendChild(createTimestampLink(startTime, toTimestamp(startTime))); + tr.appendChild(td); + + td = document.createElement("td") + td.appendChild(text(txt)); + tr.appendChild(td); + + table_tt.appendChild(tr); + }); + rows = table_tt.rows; + } + else { + forEachCue((startTime, txt) => { + span = document.createElement("span"); + let idx = txt.indexOf(" ", 1); + let [firstWord, rest] = [txt.slice(0, idx), txt.slice(idx)]; + + span.appendChild(createTimestampLink(startTime, firstWord, toTimestamp(startTime))); + if (rest) span.appendChild(text(rest + " ")); + table_tt.appendChild(span); + }); + rows = table_tt.childNodes; + } + + let lastActiveRow = null; + let row; + function colorCurRow(e) { + // console.log("cuechange:", e); + let activeCueIdx = cuesList.findIndex((c) => c == selectedTrack.activeCues[0]); + let activeRowIdx = is_automatic ? Math.floor(activeCueIdx / 2) : activeCueIdx; + + if (lastActiveRow) lastActiveRow.style.backgroundColor = ""; + if (activeRowIdx < 0) return; + row = rows[activeRowIdx]; + row.style.backgroundColor = "#0cc12e42"; + lastActiveRow = row; + } + selectedTrack.addEventListener("cuechange", colorCurRow); +} + +function loadCues() { + const textTracks = QId("js-video-player").textTracks; + const selectedTrack = textTracks[select_tt.selectedIndex]; + + // See https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/mode + // This code will (I think) make sure that the selected track's cues + // are loaded even if the track subtitles aren't on (showing). Setting it + // to hidden will load them. + let selected_track_target_mode = "hidden"; + + for (let track of textTracks) { + // Want to avoid unshowing selected track if it's showing + if (track.mode === "showing") selected_track_target_mode = "showing"; + + if (track !== selectedTrack) track.mode = "disabled"; + } + if (selectedTrack.mode == "disabled") { + selectedTrack.mode = selected_track_target_mode; + } + + let intervalID = setInterval(() => { + if (selectedTrack.cues && selectedTrack.cues.length) { + clearInterval(intervalID); + renderCues(); + } + }, 100); +} + +window.addEventListener('DOMContentLoaded', function() { + const textTracks = QId("js-video-player").textTracks; + if (!textTracks.length) return; + + details_tt = Q("details#transcript-details"); + details_tt.addEventListener("toggle", () => { + if (details_tt.open) loadCues(); + }); + + select_tt = Q("select#select-tt"); + select_tt.selectedIndex = getDefaultTranscriptTrackIdx(); + select_tt.addEventListener("change", loadCues); + + table_tt = Q("table#transcript-table"); + table_tt.appendChild(text("loading...")); + + textTracks.addEventListener("change", (e) => { + // console.log(e); + let idx = getActiveTranscriptTrackIdx(); // sadly not provided by 'e' + if (textTracks[idx].mode == "showing") { + select_tt.selectedIndex = idx; + loadCues(); + } + else if (details_tt.open && textTracks[idx].mode == "disabled") { + textTracks[idx].mode = "hidden"; // so we still receive 'oncuechange' + } + }) + + Q("input#transcript-use-table").addEventListener("change", renderCues); +}); diff --git a/youtube/static/js/watch.js b/youtube/static/js/watch.js new file mode 100644 index 0000000..95d9fa7 --- /dev/null +++ b/youtube/static/js/watch.js @@ -0,0 +1,199 @@ +const video = document.getElementById('js-video-player'); + +function changeQuality(selection) { + let currentVideoTime = video.currentTime; + let videoPaused = video.paused; + let videoSpeed = video.playbackRate; + let srcInfo; + if (avMerge) + avMerge.close(); + if (selection.type == 'uni'){ + srcInfo = data['uni_sources'][selection.index]; + video.src = srcInfo.url; + } else { + srcInfo = data['pair_sources'][selection.index]; + avMerge = new AVMerge(video, srcInfo, currentVideoTime); + } + video.currentTime = currentVideoTime; + if (!videoPaused){ + video.play(); + } + video.playbackRate = videoSpeed; +} + +// Initialize av-merge +let avMerge; +if (data.using_pair_sources) { + let srcPair = data['pair_sources'][data['pair_idx']]; + // Do it dynamically rather than as the default in jinja + // in case javascript is disabled + avMerge = new AVMerge(video, srcPair, 0); +} + +// Quality selector +const qs = document.getElementById('quality-select'); +if (qs) { + qs.addEventListener('change', function(e) { + changeQuality(JSON.parse(this.value)) + }); +} + +// Set up video start time from &t parameter +if (data.time_start != 0 && video) {video.currentTime = data.time_start}; + +// External video speed control +let speedInput = document.getElementById('speed-control'); +speedInput.addEventListener('keyup', (event) => { + if (event.key === 'Enter') { + let speed = parseFloat(speedInput.value); + if(!isNaN(speed)){ + video.playbackRate = speed; + } + } +}); + + +// Playlist lazy image loading +if (data.playlist && data.playlist['id'] !== null) { + // lazy load playlist images + // copied almost verbatim from + // https://css-tricks.com/tips-for-rolling-your-own-lazy-loading/ + // IntersectionObserver isn't supported in pre-quantum + // firefox versions, but the alternative of making it + // manually is a performance drain, so oh well + let observer = new IntersectionObserver(lazyLoad, { + + // where in relation to the edge of the viewport, we are observing + rootMargin: "100px", + + // how much of the element needs to have intersected + // in order to fire our loading function + threshold: 1.0 + + }); + + function lazyLoad(elements) { + elements.forEach(item => { + if (item.intersectionRatio > 0) { + + // set the src attribute to trigger a load + item.target.src = item.target.dataset.src; + + // stop observing this element. Our work here is done! + observer.unobserve(item.target); + }; + }); + }; + + // Tell our observer to observe all img elements with a "lazy" class + let lazyImages = document.querySelectorAll('img.lazy'); + lazyImages.forEach(img => { + observer.observe(img); + }); +} + + +// Autoplay +if (data.settings.related_videos_mode !== 0 || data.playlist !== null) { + let playability_error = !!data.playability_error; + let isPlaylist = false; + if (data.playlist !== null && data.playlist['current_index'] !== null) + isPlaylist = true; + + // read cookies on whether to autoplay + // https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie + let cookieValue; + let playlist_id; + if (isPlaylist) { + // from https://stackoverflow.com/a/6969486 + function escapeRegExp(string) { + // $& means the whole matched string + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + playlist_id = data.playlist['id']; + playlist_id = escapeRegExp(playlist_id); + + cookieValue = document.cookie.replace(new RegExp( + '(?:(?:^|.*;\\s*)autoplay_' + + playlist_id + '\\s*\\=\\s*([^;]*).*$)|^.*$' + ), '$1'); + } else { + cookieValue = document.cookie.replace(new RegExp( + '(?:(?:^|.*;\\s*)autoplay\\s*\\=\\s*([^;]*).*$)|^.*$' + ),'$1'); + } + + let autoplayEnabled = 0; + if(cookieValue.length === 0){ + autoplayEnabled = 0; + } else { + autoplayEnabled = Number(cookieValue); + } + + // check the checkbox if autoplay is on + let checkbox = document.querySelector('.autoplay-toggle'); + if(autoplayEnabled){ + checkbox.checked = true; + } + + // listen for checkbox to turn autoplay on and off + let cookie = 'autoplay' + if (isPlaylist) + cookie += '_' + playlist_id; + + checkbox.addEventListener( 'change', function() { + if(this.checked) { + autoplayEnabled = 1; + document.cookie = cookie + '=1; SameSite=Strict'; + } else { + autoplayEnabled = 0; + document.cookie = cookie + '=0; SameSite=Strict'; + } + }); + + if(!playability_error){ + // play the video if autoplay is on + if(autoplayEnabled){ + video.play(); + } + } + + // determine next video url + let nextVideoUrl; + if (isPlaylist) { + let currentIndex = data.playlist['current_index']; + if (data.playlist['current_index']+1 == data.playlist['items'].length) + nextVideoUrl = null; + else + nextVideoUrl = data.playlist['items'][data.playlist['current_index']+1]['url']; + + // scroll playlist to proper position + // item height + gap == 100 + let pl = document.querySelector('.playlist-videos'); + pl.scrollTop = 100*currentIndex; + } else { + if (data.related.length === 0) + nextVideoUrl = null; + else + nextVideoUrl = data.related[0]['url']; + } + let nextVideoDelay = 1000; + + // go to next video when video ends + // https://stackoverflow.com/a/2880950 + if (nextVideoUrl) { + if(playability_error){ + videoEnded(); + } else { + video.addEventListener('ended', videoEnded, false); + } + function nextVideo(){ + if(autoplayEnabled){ + window.location.href = nextVideoUrl; + } + } + function videoEnded(e) { + window.setTimeout(nextVideo, nextVideoDelay); + } + } +} diff --git a/youtube/static/license.css b/youtube/static/license.css new file mode 100644 index 0000000..a86780c --- /dev/null +++ b/youtube/static/license.css @@ -0,0 +1,293 @@ +body { + display: grid; + grid-gap: 20px; + grid-template-areas: + "header" + "main" + "footer"; + /* Fix height */ + height: 100vh; + grid-template-rows: auto 1fr auto; + /* fix top and bottom */ + margin-left: 1rem; + margin-right: 1rem; +} + +a:link { + color: var(--link); +} + +a:visited { + color: var(--link-visited); +} + +input[type="text"], +input[type="search"] { + background: var(--background); + border: 1px solid var(--button-border); + border-radius: 5px; + padding: 0.4rem 0.4rem; + font-size: 15px; + color: var(--search-text); + outline: none; + box-shadow: none; +} + +input[type='search'] { + border-bottom: 1px solid var(--button-border); + border-top: 0px; + border-left: 0px; + border-right: 0px; + border-radius: 0px; +} + +header { + display: grid; + grid-gap: 1px; + grid-template-areas: + "home" + "form"; + grid-area: header; +} + +.home { + grid-area: home; + margin-left: auto; + margin-right: auto; + margin-bottom: 1rem; + margin-top: 1rem; +} + +.form { + display: grid; + grid-gap: 4px; + grid-template-areas: + "search-box" + "search-button" + "dropdown"; + grid-area: form; +} + +.search-box { + grid-area: search-box; +} +.search-button { + grid-area: search-button; + + cursor: pointer; + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.search-button:hover { + background-color: var(--buttom-hover); +} + +.dropdown { + display: grid; + grid-gap: 1px; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; +} +.dropdown-label { + grid-area: dropdown-label; + + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.dropdown-label:hover { + background-color: var(--buttom-hover); +} + +/* ------------- Menu Mobile sin JS ---------------- */ +/* input hidden */ +.opt-box { + display: none; +} +.dropdown-content { + display: none; + grid-area: dropdown-content; +} +label[for=options-toggle-cbox] { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#options-toggle-cbox:checked ~ .dropdown-content { + display: block; + white-space: nowrap; + background: var(--secondary-background); + padding: 0.5rem 1rem; +} +/*- ----------- End Menu Mobile sin JS ------------- */ + +.main { + grid-area: main; + margin: 0 auto; + max-width: 80ch; +} + +.code-error { + background: var(--secondary-background); + padding: 1rem; +} + +.footer { + grid-area: footer; + display: grid; + grid-template-columns: auto; + align-items: center; + justify-content: center; + margin: auto; + text-align: center; +} + +.footer > p { + text-align: center; +} + +/* ---- ---- Table ---- ---- */ +.table { + width: 100%; + border-collapse: collapse; +} + +.table caption { + margin: 1rem 0; + width: 100%; +} + +.table td,.table th { + padding: 10px 10px; + border: 1px solid var(--border-bg-license); + text-align: center; +} + +.table th { + background-color: var(--secondary-background); + color: var(--text); +} + +.table tbody tr:nth-child(even) { + background-color: var(--secondary-focus); +} + +.table tbody tr:nth-child(2n+1) { + background-color: var(--primary-background); +} + +.table thead tr th:nth-last-child(1) { + padding: 0; +} + +.table tbody tr td:nth-last-child(1) > button { + color: var(--text); + width: 40px; + height: 30px; + background-color: var(--secondary-background); + border: 1px solid var(--secondary-background); + cursor: pointer; +} + +.table tbody tr td:nth-last-child(1) > a { + color: var(--text); + cursor: pointer; + padding: 2px 10px; +} + +/* ---- ---- End table ---- ---- */ + +/* ---- Table responsive ---- */ +@media (max-width: 580px) { + .table thead { + display: none; + } + + .table tr{ + margin-bottom:15px; + } + + .table, + .table tbody, + .table tr, + .table td { + display: block; + width: auto; + text-align: justify; + } + + .table td::before { + content: attr(data-label) ": "; + font-weight: bold; + } +} +/* End table responsive */ + +@media (min-width: 780px) { + body { + display: grid; + grid-template-columns: 0.3fr 2fr 1fr 0.3fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "header header header header" + "main main main main" + "footer footer footer footer"; + } + .form { + display: grid; + grid-gap: 1px; + grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr; + grid-template-areas: ". search-box search-button dropdown"; + grid-area: form; + position: relative; + } + .dropdown { + display: grid; + grid-gap: 1px; + grid-template-columns: minmax(50px, 120px); + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; + z-index: 1; + position: absolute; + } + #options-toggle-cbox:checked ~ .dropdown-content { + padding: 0rem 3rem 1rem 1rem; + width: 100%; + max-height: 45vh; + overflow-y: scroll; + } + .footer { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-column-gap: 2rem; + align-items: center; + justify-content: center; + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; + } +} diff --git a/youtube/static/light_theme.css b/youtube/static/light_theme.css new file mode 100644 index 0000000..9dab076 --- /dev/null +++ b/youtube/static/light_theme.css @@ -0,0 +1,22 @@ +:root { + --background: #FFFFFF; + --text: #212121; + --secondary-hover: #212121; + --secondary-focus: #FAFAFA; + --secondary-inverse: #FFFFFF; + --primary-background: #F5F5F5; + --secondary-background: #EEEEEE; + --thumb-background: #F5F5F5; + --link: #212121; + --link-visited: #808080; + --border-bg: #212121; + --border-bg-settings: #91918C; + --border-bg-license: #91918C; + --buttom: #FFFFFF; + --buttom-text: #212121; + --button-border: #91918C; + --buttom-hover: #BBBBBB; + --search-text: #212121; + --time-background: #212121; + --time-text: #FFFFFF; +} diff --git a/youtube/static/local_playlist.css b/youtube/static/local_playlist.css new file mode 100644 index 0000000..dedd2dd --- /dev/null +++ b/youtube/static/local_playlist.css @@ -0,0 +1,538 @@ +body { + display: grid; + grid-gap: 20px; + grid-template-areas: + "header" + "main" + "footer"; + /* Fix height */ + height: 100vh; + grid-template-rows: auto 1fr auto; + /* fix top and bottom */ + margin-left: 1rem; + margin-right: 1rem; +} + +img { + width: 100%; + height: auto; +} + +a:link { + color: var(--link); +} + +a:visited { + color: var(--link-visited); +} + +input[type="text"], +input[type="search"] { + background: var(--background); + border: 1px solid var(--button-border); + padding: 0.4rem 0.4rem; + font-size: 15px; + color: var(--search-text); + outline: none; + box-shadow: none; +} + +input[type='search'] { + border-bottom: 1px solid var(--button-border); + border-top: 0px; + border-left: 0px; + border-right: 0px; + border-radius: 0px; +} + +header { + display: grid; + grid-gap: 1px; + grid-template-areas: + "home" + "form" + "playlist"; + grid-area: header; +} + +.home { + grid-area: home; + margin-left: auto; + margin-right: auto; + margin-bottom: 1rem; + margin-top: 1rem; +} + +.form { + display: grid; + grid-gap: 4px; + grid-template-areas: + "search-box" + "search-button" + "dropdown"; + grid-area: form; +} + +.search-box { + grid-area: search-box; +} +.search-button { + grid-area: search-button; + + cursor: pointer; + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.search-button:hover { + background-color: var(--buttom-hover); +} + +.dropdown { + display: grid; + grid-gap: 1px; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; +} +.dropdown-label { + grid-area: dropdown-label; + + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.dropdown-label:hover { + background-color: var(--buttom-hover); +} + +/* playlist */ +.playlist { + display: grid; + grid-gap: 4px; + grid-template-areas: + "play-box" + "play-hidden" + "play-add" + "play-clean"; + grid-area: playlist; +} +.play-box { + grid-area: play-box; +} + +.play-hidden { + grid-area: play-hidden; +} + +.play-add { + grid-area: play-add; + cursor: pointer; + + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.play-add:hover { + background-color: var(--buttom-hover); +} + +.play-clean { + display: grid; + grid-area: play-clean; +} + +.play-clean > button { + padding-bottom: 6px; + padding-left: .75em; + padding-right: .75em; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + border: 1px solid var(--button-border); + color: var(--buttom-text); + border-radius: 5px; +} +.play-clean > button:hover { + background-color: var(--buttom-hover); +} +/* /playlist */ + +/* ------------- Menu Mobile sin JS ---------------- */ +/* input hidden */ +.opt-box { + display: none; +} +.dropdown-content { + display: none; + grid-area: dropdown-content; +} +label[for=options-toggle-cbox] { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#options-toggle-cbox:checked ~ .dropdown-content { + display: block; + white-space: nowrap; + background: var(--secondary-background); + padding: 0.5rem 1rem; +} +/*- ----------- End Menu Mobile sin JS ------------- */ + +.main { + grid-area: main; + display: grid; + grid-row-gap: 1rem; +} + +/* fix hr when is children of grid */ +hr { + width: 100%; +} + +.playlist-metadata { + display: grid; + grid-template-columns: 1fr; + margin: auto; + grid-template-areas: + "play-title" + "play-action"; +} +.play-title { + grid-area: play-title; + text-align: center; +} +.play-action { + grid-area: play-action; +} + +/* Video list item */ +.video-container { + display: grid; + grid-row-gap: 0.5rem; +} + +.item-box { + display: grid; + grid-template-columns: 1.9fr 0.1fr; + grid-template-rows: 1fr; + grid-gap: 1px; + grid-template-areas: + "item-video item-checkbox"; +} + +.item-video { + grid-area: item-video; + + display: grid; + grid-template-columns: auto; + grid-template-rows: repeat(4, auto); + grid-row-gap: 0.4rem; + grid-template-areas: + "thumbnail-box" + "info-box"; + align-items: center; + + font-size: 0.7rem; +} + +.item-video a { + text-decoration: none; + cursor: pointer; +} + +.thumbnail-box { + grid-area: thumbnail-box; + position: relative; +} + +.thumbnail { + padding: 28.125%; + position: relative; + box-sizing: border-box; +} + +.thumbnail-img { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + object-fit: cover; + background-color: var(--thumb-background); +} + +.length { + position: absolute; + background-color: rgba(35, 35, 35, 0.75); + color: #fff; + border-radius: 2px; + padding: 2px; + font-size: 16px; + right: 0.25em; + bottom: -0.75em; +} + +.playlist-item .thumbnail-info { + position: absolute; + right: 0px; + bottom: 0px; + height: 100%; + width: 50%; + text-align: center; + white-space: pre-line; + opacity: .8; + color: var(--text); + font-size: 0.8125rem; + background: var(--secondary-background); + padding: 0; +} + +.playlist-item .thumbnail-info span { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + text-transform: none; +} + +.info-box { + grid-area: info-box; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto auto auto auto; + grid-gap: 1px; + grid-template-areas: + "." + "." + "." + "." + "."; +} + +.title { + font-size: 0.8rem; + margin: 0px; + font-weight: normal; + overflow: hidden; + text-overflow: ellipsis; +} + +.info-box address { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.thumbnail-info { + background-color: var(--time-background); + color: #fff; + padding: 2px 5px; + text-transform: uppercase; + font-weight: 700; + font-size: 12px; + position: absolute; + right: 0; + bottom: .2rem; +} + +.item-checkbox { + grid-area: item-checkbox; + justify-self: start; + align-self: center; + min-width: 30px; + margin: 0px; +} + +.stats { + display: flex; + justify-content: space-between; +} + +.horizontal-stats { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.horizontal-stats > li { + display: inline; +} + +.horizontal-stats > li:first-child::after { + content: " | "; +} + +/* pagination */ +.main .pagination-container { + display: grid; + justify-content: center; +} + +.main .pagination-container .pagination-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; +} + +.main .pagination-container .pagination-list .page-link { + border-style: none; + font-weight: bold; + text-align: center; + background: var(--secondary-focus); + text-decoration: none; + align-self: center; + padding: .5rem; + margin: 0.2rem; + width: 1rem; +} + +.main .pagination-container .pagination-list .page-link.is-current { + background: var(--secondary-background); +} + +/* /video list item */ + +.footer { + grid-area: footer; + display: grid; + grid-template-columns: auto; + align-items: center; + justify-content: center; + margin: auto; + text-align: center; +} + +.footer > p { + text-align: center; +} + +@media (min-width: 480px) { + .item-video { + font-size: 0.85rem; + } + .info-box { + grid-gap: 2px; + } + .title { + font-size: 1rem; + } +} + +@media (min-width: 600px) { + .video-container { + display: grid; + grid-row-gap: 0.5rem; + grid-template-columns: 1fr 1fr; + } +} + +@media (min-width: 992px) { + body { + display: grid; + grid-template-columns: 0.3fr 2fr 1fr 0.3fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "header header header header" + "main main main main" + "footer footer footer footer"; + } + .form { + display: grid; + grid-gap: 1px; + grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr; + grid-template-areas: ". search-box search-button dropdown"; + grid-area: form; + position: relative; + } + .dropdown { + display: grid; + grid-gap: 1px; + grid-template-columns: 100px auto; + grid-template-areas: + "dropdown-label" + "dropdown-content"; + grid-area: dropdown; + position: absolute; + z-index: 1; + } + #options-toggle-cbox:checked ~ .dropdown-content { + width: calc(100% + 100px); + max-height: 80vh; + overflow-y: scroll; + } + .playlist-metadata { + max-width: 50vw; + } + + /* playlist */ + .playlist { + display: grid; + grid-gap: 1px; + grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr; + grid-template-areas: ". play-box play-add play-clean"; + grid-area: playlist; + } + .play-clean { + grid-template-columns: 100px auto; + } + .play-clean > button { + padding-left: 0px; + padding-right: 0px; + padding-bottom: 6px; + padding-top: 6px; + text-align: center; + white-space: nowrap; + background-color: var(--buttom); + color: var(--buttom-text); + border-radius: 5px; + cursor: pointer; + } + + .video-container { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-row-gap: 1rem; + grid-column-gap: 1rem; + } + + .footer { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-column-gap: 2rem; + align-items: center; + justify-content: center; + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; + } +} diff --git a/youtube/static/message_box.css b/youtube/static/message_box.css new file mode 100644 index 0000000..72f4453 --- /dev/null +++ b/youtube/static/message_box.css @@ -0,0 +1,12 @@ +#message-box { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border-style: outset; + padding: 20px; + background-color: var(--background); + opacity: 0; + transition-property: opacity; + transition-duration: 0.3s; +} diff --git a/youtube/static/modules/plyr/blank.webm b/youtube/static/modules/plyr/blank.webm new file mode 100644 index 0000000..6ec17ef Binary files /dev/null and b/youtube/static/modules/plyr/blank.webm differ diff --git a/youtube/static/modules/plyr/build-instructions.md b/youtube/static/modules/plyr/build-instructions.md new file mode 100644 index 0000000..4323281 --- /dev/null +++ b/youtube/static/modules/plyr/build-instructions.md @@ -0,0 +1,23 @@ +# Build steps for Plyr (3.6.8) + +Tested on Hyperbola GNU with Linux-libre. + +First install npm (node package manager). + +Clone the repo to a location of your choosing: +``` +git clone https://git.sr.ht/~heckyel/plyr +cd plyr +``` + +Install Plyr's dependencies: +``` +npm install +``` + +Build with npm: +``` +npm run build +``` + +plyr.js and other files will be in the `dist` directory. diff --git a/youtube/static/modules/plyr/custom_plyr.css b/youtube/static/modules/plyr/custom_plyr.css new file mode 100644 index 0000000..0fd3c52 --- /dev/null +++ b/youtube/static/modules/plyr/custom_plyr.css @@ -0,0 +1,39 @@ +/* Prevent this div from blocking right-click menu for video +e.g. Firefox playback speed options */ +.plyr__poster { + display: none; +} + +/* plyr fix */ +.plyr:-moz-full-screen video { + max-height: initial; +} + +.plyr:-webkit-full-screen video { + max-height: initial; +} + +.plyr:-ms-fullscreen video { + max-height: initial; +} + +.plyr:fullscreen video { + max-height: initial; +} + +.plyr__preview-thumb__image-container { + width: 158px; + height: 90px; +} + +.plyr__preview-thumb { + bottom: 100%; +} + +.plyr__menu__container [role="menu"], +.plyr__menu__container [role="menucaptions"] { + /* Set vertical scroll */ + /* issue https://github.com/sampotts/plyr/issues/1420 */ + max-height: 320px; + overflow-y: auto; +} diff --git a/youtube/static/modules/plyr/plyr.css b/youtube/static/modules/plyr/plyr.css new file mode 100644 index 0000000..7d98f20 --- /dev/null +++ b/youtube/static/modules/plyr/plyr.css @@ -0,0 +1 @@ +@keyframes plyr-progress{to{background-position:25px 0;background-position:var(--plyr-progress-loading-size,25px) 0}}@keyframes plyr-popup{0%{opacity:.5;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes plyr-fade-in{from{opacity:0}to{opacity:1}}.plyr{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;align-items:center;direction:ltr;display:flex;flex-direction:column;font-family:inherit;font-family:var(--plyr-font-family,inherit);font-variant-numeric:tabular-nums;font-weight:400;font-weight:var(--plyr-font-weight-regular,400);height:100%;line-height:1.7;line-height:var(--plyr-line-height,1.7);max-width:100%;min-width:200px;position:relative;text-shadow:none;transition:box-shadow .3s ease;z-index:0}.plyr audio,.plyr iframe,.plyr video{display:block;height:100%;width:100%}.plyr button{font:inherit;line-height:inherit;width:auto}.plyr:focus{outline:0}.plyr--full-ui{box-sizing:border-box}.plyr--full-ui *,.plyr--full-ui ::after,.plyr--full-ui ::before{box-sizing:inherit}.plyr--full-ui a,.plyr--full-ui button,.plyr--full-ui input,.plyr--full-ui label{touch-action:manipulation}.plyr__badge{background:#4a5464;background:var(--plyr-badge-background,#4a5464);border-radius:2px;border-radius:var(--plyr-badge-border-radius,2px);color:#fff;color:var(--plyr-badge-text-color,#fff);font-size:9px;font-size:var(--plyr-font-size-badge,9px);line-height:1;padding:3px 4px}.plyr--full-ui ::-webkit-media-text-track-container{display:none}.plyr__captions{animation:plyr-fade-in .3s ease;bottom:0;display:none;font-size:13px;font-size:var(--plyr-font-size-small,13px);left:0;padding:10px;padding:var(--plyr-control-spacing,10px);position:absolute;text-align:center;transition:transform .4s ease-in-out;width:100%}.plyr__captions span:empty{display:none}@media (min-width:480px){.plyr__captions{font-size:15px;font-size:var(--plyr-font-size-base,15px);padding:calc(10px * 2);padding:calc(var(--plyr-control-spacing,10px) * 2)}}@media (min-width:768px){.plyr__captions{font-size:18px;font-size:var(--plyr-font-size-large,18px)}}.plyr--captions-active .plyr__captions{display:block}.plyr:not(.plyr--hide-controls) .plyr__controls:not(:empty)~.plyr__captions{transform:translateY(calc(10px * -4));transform:translateY(calc(var(--plyr-control-spacing,10px) * -4))}.plyr__caption{background:rgba(0,0,0,.8);background:var(--plyr-captions-background,rgba(0,0,0,.8));border-radius:2px;-webkit-box-decoration-break:clone;box-decoration-break:clone;color:#fff;color:var(--plyr-captions-text-color,#fff);line-height:185%;padding:.2em .5em;white-space:pre-wrap}.plyr__caption div{display:inline}.plyr__control{background:0 0;border:0;border-radius:3px;border-radius:var(--plyr-control-radius,3px);color:inherit;cursor:pointer;flex-shrink:0;overflow:visible;padding:calc(10px * .7);padding:calc(var(--plyr-control-spacing,10px) * .7);position:relative;transition:all .3s ease}.plyr__control svg{display:block;fill:currentColor;height:18px;height:var(--plyr-control-icon-size,18px);pointer-events:none;width:18px;width:var(--plyr-control-icon-size,18px)}.plyr__control:focus{outline:0}.plyr__control.plyr__tab-focus{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}a.plyr__control{text-decoration:none}a.plyr__control::after,a.plyr__control::before{display:none}.plyr__control.plyr__control--pressed .icon--not-pressed,.plyr__control.plyr__control--pressed .label--not-pressed,.plyr__control:not(.plyr__control--pressed) .icon--pressed,.plyr__control:not(.plyr__control--pressed) .label--pressed{display:none}.plyr--full-ui ::-webkit-media-controls{display:none}.plyr__controls{align-items:center;display:flex;justify-content:flex-end;text-align:center}.plyr__controls .plyr__progress__container{flex:1;min-width:0}.plyr__controls .plyr__controls__item{margin-left:calc(10px / 4);margin-left:calc(var(--plyr-control-spacing,10px)/ 4)}.plyr__controls .plyr__controls__item:first-child{margin-left:0;margin-right:auto}.plyr__controls .plyr__controls__item.plyr__progress__container{padding-left:calc(10px / 4);padding-left:calc(var(--plyr-control-spacing,10px)/ 4)}.plyr__controls .plyr__controls__item.plyr__time{padding:0 calc(10px / 2);padding:0 calc(var(--plyr-control-spacing,10px)/ 2)}.plyr__controls .plyr__controls__item.plyr__progress__container:first-child,.plyr__controls .plyr__controls__item.plyr__time+.plyr__time,.plyr__controls .plyr__controls__item.plyr__time:first-child{padding-left:0}.plyr__controls:empty{display:none}.plyr [data-plyr=airplay],.plyr [data-plyr=captions],.plyr [data-plyr=fullscreen],.plyr [data-plyr=pip]{display:none}.plyr--airplay-supported [data-plyr=airplay],.plyr--captions-enabled [data-plyr=captions],.plyr--fullscreen-enabled [data-plyr=fullscreen],.plyr--pip-supported [data-plyr=pip]{display:inline-block}.plyr__menu{display:flex;position:relative}.plyr__menu .plyr__control svg{transition:transform .3s ease}.plyr__menu .plyr__control[aria-expanded=true] svg{transform:rotate(90deg)}.plyr__menu .plyr__control[aria-expanded=true] .plyr__tooltip{display:none}.plyr__menu__container{animation:plyr-popup .2s ease;background:rgba(255,255,255,.9);background:var(--plyr-menu-background,rgba(255,255,255,.9));border-radius:4px;bottom:100%;box-shadow:0 1px 2px rgba(0,0,0,.15);box-shadow:var(--plyr-menu-shadow,0 1px 2px rgba(0,0,0,.15));color:#4a5464;color:var(--plyr-menu-color,#4a5464);font-size:15px;font-size:var(--plyr-font-size-base,15px);margin-bottom:10px;position:absolute;right:-3px;text-align:left;white-space:nowrap;z-index:3}.plyr__menu__container>div{overflow:hidden;transition:height .35s cubic-bezier(.4,0,.2,1),width .35s cubic-bezier(.4,0,.2,1)}.plyr__menu__container::after{border:4px solid transparent;border:var(--plyr-menu-arrow-size,4px) solid transparent;border-top-color:rgba(255,255,255,.9);border-top-color:var(--plyr-menu-background,rgba(255,255,255,.9));content:'';height:0;position:absolute;right:calc(((18px / 2) + calc(10px * .7)) - (4px / 2));right:calc(((var(--plyr-control-icon-size,18px)/ 2) + calc(var(--plyr-control-spacing,10px) * .7)) - (var(--plyr-menu-arrow-size,4px)/ 2));top:100%;width:0}.plyr__menu__container [role=menu]{padding:calc(10px * .7);padding:calc(var(--plyr-control-spacing,10px) * .7)}.plyr__menu__container [role=menucaptions]{padding:calc(10px * .7);padding:calc(var(--plyr-control-spacing,10px) * .7);max-height:320px;overflow-y:auto}.plyr__menu__container [role=menuitem],.plyr__menu__container [role=menuitemradio]{margin-top:2px}.plyr__menu__container [role=menuitem]:first-child,.plyr__menu__container [role=menuitemradio]:first-child{margin-top:0}.plyr__menu__container .plyr__control{align-items:center;color:#4a5464;color:var(--plyr-menu-color,#4a5464);display:flex;font-size:13px;font-size:var(--plyr-font-size-menu,var(--plyr-font-size-small,13px));padding-bottom:calc(calc(10px * .7)/ 1.5);padding-bottom:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 1.5);padding-left:calc(calc(10px * .7) * 1.5);padding-left:calc(calc(var(--plyr-control-spacing,10px) * .7) * 1.5);padding-right:calc(calc(10px * .7) * 1.5);padding-right:calc(calc(var(--plyr-control-spacing,10px) * .7) * 1.5);padding-top:calc(calc(10px * .7)/ 1.5);padding-top:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 1.5);-webkit-user-select:none;-ms-user-select:none;user-select:none;width:100%}.plyr__menu__container .plyr__control>span{align-items:inherit;display:flex;width:100%}.plyr__menu__container .plyr__control::after{border:4px solid transparent;border:var(--plyr-menu-item-arrow-size,4px) solid transparent;content:'';position:absolute;top:50%;transform:translateY(-50%)}.plyr__menu__container .plyr__control--forward{padding-right:calc(calc(10px * .7) * 4);padding-right:calc(calc(var(--plyr-control-spacing,10px) * .7) * 4)}.plyr__menu__container .plyr__control--forward::after{border-left-color:#728197;border-left-color:var(--plyr-menu-arrow-color,#728197);right:calc((calc(10px * .7) * 1.5) - 4px);right:calc((calc(var(--plyr-control-spacing,10px) * .7) * 1.5) - var(--plyr-menu-item-arrow-size,4px))}.plyr__menu__container .plyr__control--forward.plyr__tab-focus::after,.plyr__menu__container .plyr__control--forward:hover::after{border-left-color:currentColor}.plyr__menu__container .plyr__control--back{font-weight:400;font-weight:var(--plyr-font-weight-regular,400);margin:calc(10px * .7);margin:calc(var(--plyr-control-spacing,10px) * .7);margin-bottom:calc(calc(10px * .7)/ 2);margin-bottom:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 2);padding-left:calc(calc(10px * .7) * 4);padding-left:calc(calc(var(--plyr-control-spacing,10px) * .7) * 4);position:relative;width:calc(100% - (calc(10px * .7) * 2));width:calc(100% - (calc(var(--plyr-control-spacing,10px) * .7) * 2))}.plyr__menu__container .plyr__control--back::after{border-right-color:#728197;border-right-color:var(--plyr-menu-arrow-color,#728197);left:calc((calc(10px * .7) * 1.5) - 4px);left:calc((calc(var(--plyr-control-spacing,10px) * .7) * 1.5) - var(--plyr-menu-item-arrow-size,4px))}.plyr__menu__container .plyr__control--back::before{background:#dcdfe5;background:var(--plyr-menu-back-border-color,#dcdfe5);box-shadow:0 1px 0 #fff;box-shadow:0 1px 0 var(--plyr-menu-back-border-shadow-color,#fff);content:'';height:1px;left:0;margin-top:calc(calc(10px * .7)/ 2);margin-top:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 2);overflow:hidden;position:absolute;right:0;top:100%}.plyr__menu__container .plyr__control--back.plyr__tab-focus::after,.plyr__menu__container .plyr__control--back:hover::after{border-right-color:currentColor}.plyr__menu__container .plyr__control[role=menuitemradio]{padding-left:calc(10px * .7);padding-left:calc(var(--plyr-control-spacing,10px) * .7)}.plyr__menu__container .plyr__control[role=menuitemradio]::after,.plyr__menu__container .plyr__control[role=menuitemradio]::before{border-radius:100%}.plyr__menu__container .plyr__control[role=menuitemradio]::before{background:rgba(0,0,0,.1);content:'';display:block;flex-shrink:0;height:16px;margin-right:10px;margin-right:var(--plyr-control-spacing,10px);transition:all .3s ease;width:16px}.plyr__menu__container .plyr__control[role=menuitemradio]::after{background:#fff;border:0;height:6px;left:12px;opacity:0;top:50%;transform:translateY(-50%) scale(0);transition:transform .3s ease,opacity .3s ease;width:6px}.plyr__menu__container .plyr__control[role=menuitemradio][aria-checked=true]::before{background:#00b3ff;background:var(--plyr-control-toggle-checked-background,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)))}.plyr__menu__container .plyr__control[role=menuitemradio][aria-checked=true]::after{opacity:1;transform:translateY(-50%) scale(1)}.plyr__menu__container .plyr__control[role=menuitemradio].plyr__tab-focus::before,.plyr__menu__container .plyr__control[role=menuitemradio]:hover::before{background:rgba(35,40,47,.1)}.plyr__menu__container .plyr__menu__value{align-items:center;display:flex;margin-left:auto;margin-right:calc((calc(10px * .7) - 2) * -1);margin-right:calc((calc(var(--plyr-control-spacing,10px) * .7) - 2) * -1);overflow:hidden;padding-left:calc(calc(10px * .7) * 3.5);padding-left:calc(calc(var(--plyr-control-spacing,10px) * .7) * 3.5);pointer-events:none}.plyr--full-ui input[type=range]{-webkit-appearance:none;background:0 0;border:0;border-radius:calc(13px * 2);border-radius:calc(var(--plyr-range-thumb-height,13px) * 2);color:#00b3ff;color:var(--plyr-range-fill-background,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));display:block;height:calc((3px * 2) + 13px);height:calc((var(--plyr-range-thumb-active-shadow-width,3px) * 2) + var(--plyr-range-thumb-height,13px));margin:0;padding:0;transition:box-shadow .3s ease;width:100%}.plyr--full-ui input[type=range]::-webkit-slider-runnable-track{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-webkit-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-webkit-user-select:none;user-select:none;background-image:linear-gradient(to right,currentColor 0,transparent 0);background-image:linear-gradient(to right,currentColor var(--value,0),transparent var(--value,0))}.plyr--full-ui input[type=range]::-webkit-slider-thumb{background:#fff;background:var(--plyr-range-thumb-background,#fff);border:0;border-radius:100%;box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2));height:13px;height:var(--plyr-range-thumb-height,13px);position:relative;-webkit-transition:all .2s ease;transition:all .2s ease;width:13px;width:var(--plyr-range-thumb-height,13px);-webkit-appearance:none;margin-top:calc(((13px - 5px)/ 2) * -1);margin-top:calc(((var(--plyr-range-thumb-height,13px) - var(--plyr-range-track-height,5px))/ 2) * -1)}.plyr--full-ui input[type=range]::-moz-range-track{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-moz-transition:box-shadow .3s ease;transition:box-shadow .3s ease;user-select:none}.plyr--full-ui input[type=range]::-moz-range-thumb{background:#fff;background:var(--plyr-range-thumb-background,#fff);border:0;border-radius:100%;box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2));height:13px;height:var(--plyr-range-thumb-height,13px);position:relative;-moz-transition:all .2s ease;transition:all .2s ease;width:13px;width:var(--plyr-range-thumb-height,13px)}.plyr--full-ui input[type=range]::-moz-range-progress{background:currentColor;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px)}.plyr--full-ui input[type=range]::-ms-track{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-ms-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-ms-user-select:none;user-select:none;color:transparent}.plyr--full-ui input[type=range]::-ms-fill-upper{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-ms-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-ms-user-select:none;user-select:none}.plyr--full-ui input[type=range]::-ms-fill-lower{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-ms-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-ms-user-select:none;user-select:none;background:currentColor}.plyr--full-ui input[type=range]::-ms-thumb{background:#fff;background:var(--plyr-range-thumb-background,#fff);border:0;border-radius:100%;box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2));height:13px;height:var(--plyr-range-thumb-height,13px);position:relative;-ms-transition:all .2s ease;transition:all .2s ease;width:13px;width:var(--plyr-range-thumb-height,13px);margin-top:0}.plyr--full-ui input[type=range]::-ms-tooltip{display:none}.plyr--full-ui input[type=range]:focus{outline:0}.plyr--full-ui input[type=range]::-moz-focus-outer{border:0}.plyr--full-ui input[type=range].plyr__tab-focus::-webkit-slider-runnable-track{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}.plyr--full-ui input[type=range].plyr__tab-focus::-moz-range-track{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}.plyr--full-ui input[type=range].plyr__tab-focus::-ms-track{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}.plyr__poster{background-color:#000;background-position:50% 50%;background-repeat:no-repeat;background-size:contain;height:100%;left:0;opacity:0;position:absolute;top:0;transition:opacity .2s ease;width:100%;z-index:1}.plyr--stopped.plyr__poster-enabled .plyr__poster{opacity:1}.plyr__time{font-size:13px;font-size:var(--plyr-font-size-time,var(--plyr-font-size-small,13px))}.plyr__time+.plyr__time::before{content:'\2044';margin-right:10px;margin-right:var(--plyr-control-spacing,10px)}@media (max-width:calc(768px - 1)){.plyr__time+.plyr__time{display:none}}.plyr__tooltip{background:rgba(255,255,255,.9);background:var(--plyr-tooltip-background,rgba(255,255,255,.9));border-radius:3px;border-radius:var(--plyr-tooltip-radius,3px);bottom:100%;box-shadow:0 1px 2px rgba(0,0,0,.15);box-shadow:var(--plyr-tooltip-shadow,0 1px 2px rgba(0,0,0,.15));color:#4a5464;color:var(--plyr-tooltip-color,#4a5464);font-size:13px;font-size:var(--plyr-font-size-small,13px);font-weight:400;font-weight:var(--plyr-font-weight-regular,400);left:50%;line-height:1.3;margin-bottom:calc(calc(10px / 2) * 2);margin-bottom:calc(calc(var(--plyr-control-spacing,10px)/ 2) * 2);opacity:0;padding:calc(10px / 2) calc(calc(10px / 2) * 1.5);padding:calc(var(--plyr-control-spacing,10px)/ 2) calc(calc(var(--plyr-control-spacing,10px)/ 2) * 1.5);pointer-events:none;position:absolute;transform:translate(-50%,10px) scale(.8);transform-origin:50% 100%;transition:transform .2s .1s ease,opacity .2s .1s ease;white-space:nowrap;z-index:2}.plyr__tooltip::before{border-left:4px solid transparent;border-left:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-right:4px solid transparent;border-right:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-top:4px solid rgba(255,255,255,.9);border-top:var(--plyr-tooltip-arrow-size,4px) solid var(--plyr-tooltip-background,rgba(255,255,255,.9));bottom:calc(4px * -1);bottom:calc(var(--plyr-tooltip-arrow-size,4px) * -1);content:'';height:0;left:50%;position:absolute;transform:translateX(-50%);width:0;z-index:2}.plyr .plyr__control.plyr__tab-focus .plyr__tooltip,.plyr .plyr__control:hover .plyr__tooltip,.plyr__tooltip--visible{opacity:1;transform:translate(-50%,0) scale(1)}.plyr .plyr__control:hover .plyr__tooltip{z-index:3}.plyr__controls>.plyr__control:first-child .plyr__tooltip,.plyr__controls>.plyr__control:first-child+.plyr__control .plyr__tooltip{left:0;transform:translate(0,10px) scale(.8);transform-origin:0 100%}.plyr__controls>.plyr__control:first-child .plyr__tooltip::before,.plyr__controls>.plyr__control:first-child+.plyr__control .plyr__tooltip::before{left:calc((18px / 2) + calc(10px * .7));left:calc((var(--plyr-control-icon-size,18px)/ 2) + calc(var(--plyr-control-spacing,10px) * .7))}.plyr__controls>.plyr__control:last-child .plyr__tooltip{left:auto;right:0;transform:translate(0,10px) scale(.8);transform-origin:100% 100%}.plyr__controls>.plyr__control:last-child .plyr__tooltip::before{left:auto;right:calc((18px / 2) + calc(10px * .7));right:calc((var(--plyr-control-icon-size,18px)/ 2) + calc(var(--plyr-control-spacing,10px) * .7));transform:translateX(50%)}.plyr__controls>.plyr__control:first-child .plyr__tooltip--visible,.plyr__controls>.plyr__control:first-child+.plyr__control .plyr__tooltip--visible,.plyr__controls>.plyr__control:first-child+.plyr__control.plyr__tab-focus .plyr__tooltip,.plyr__controls>.plyr__control:first-child+.plyr__control:hover .plyr__tooltip,.plyr__controls>.plyr__control:first-child.plyr__tab-focus .plyr__tooltip,.plyr__controls>.plyr__control:first-child:hover .plyr__tooltip,.plyr__controls>.plyr__control:last-child .plyr__tooltip--visible,.plyr__controls>.plyr__control:last-child.plyr__tab-focus .plyr__tooltip,.plyr__controls>.plyr__control:last-child:hover .plyr__tooltip{transform:translate(0,0) scale(1)}.plyr__progress{left:calc(13px * .5);left:calc(var(--plyr-range-thumb-height,13px) * .5);margin-right:13px;margin-right:var(--plyr-range-thumb-height,13px);position:relative}.plyr__progress input[type=range],.plyr__progress__buffer{margin-left:calc(13px * -.5);margin-left:calc(var(--plyr-range-thumb-height,13px) * -.5);margin-right:calc(13px * -.5);margin-right:calc(var(--plyr-range-thumb-height,13px) * -.5);width:calc(100% + 13px);width:calc(100% + var(--plyr-range-thumb-height,13px))}.plyr__progress input[type=range]{position:relative;z-index:2}.plyr__progress .plyr__tooltip{font-size:13px;font-size:var(--plyr-font-size-time,var(--plyr-font-size-small,13px));left:0}.plyr__progress__buffer{-webkit-appearance:none;background:0 0;border:0;border-radius:100px;height:5px;height:var(--plyr-range-track-height,5px);left:0;margin-top:calc((5px / 2) * -1);margin-top:calc((var(--plyr-range-track-height,5px)/ 2) * -1);padding:0;position:absolute;top:50%}.plyr__progress__buffer::-webkit-progress-bar{background:0 0}.plyr__progress__buffer::-webkit-progress-value{background:currentColor;border-radius:100px;min-width:5px;min-width:var(--plyr-range-track-height,5px);-webkit-transition:width .2s ease;transition:width .2s ease}.plyr__progress__buffer::-moz-progress-bar{background:currentColor;border-radius:100px;min-width:5px;min-width:var(--plyr-range-track-height,5px);-moz-transition:width .2s ease;transition:width .2s ease}.plyr__progress__buffer::-ms-fill{border-radius:100px;-ms-transition:width .2s ease;transition:width .2s ease}.plyr--loading .plyr__progress__buffer{animation:plyr-progress 1s linear infinite;background-image:linear-gradient(-45deg,rgba(35,40,47,.6) 25%,transparent 25%,transparent 50%,rgba(35,40,47,.6) 50%,rgba(35,40,47,.6) 75%,transparent 75%,transparent);background-image:linear-gradient(-45deg,var(--plyr-progress-loading-background,rgba(35,40,47,.6)) 25%,transparent 25%,transparent 50%,var(--plyr-progress-loading-background,rgba(35,40,47,.6)) 50%,var(--plyr-progress-loading-background,rgba(35,40,47,.6)) 75%,transparent 75%,transparent);background-repeat:repeat-x;background-size:25px 25px;background-size:var(--plyr-progress-loading-size,25px) var(--plyr-progress-loading-size,25px);color:transparent}.plyr--video.plyr--loading .plyr__progress__buffer{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25))}.plyr--audio.plyr--loading .plyr__progress__buffer{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6))}.plyr__volume{align-items:center;display:flex;max-width:110px;min-width:80px;position:relative;width:20%}.plyr__volume input[type=range]{margin-left:calc(10px / 2);margin-left:calc(var(--plyr-control-spacing,10px)/ 2);margin-right:calc(10px / 2);margin-right:calc(var(--plyr-control-spacing,10px)/ 2);position:relative;z-index:2}.plyr--is-ios .plyr__volume{min-width:0;width:auto}.plyr--audio{display:block}.plyr--audio .plyr__controls{background:#fff;background:var(--plyr-audio-controls-background,#fff);border-radius:inherit;color:#4a5464;color:var(--plyr-audio-control-color,#4a5464);padding:10px;padding:var(--plyr-control-spacing,10px)}.plyr--audio .plyr__control.plyr__tab-focus,.plyr--audio .plyr__control:hover,.plyr--audio .plyr__control[aria-expanded=true]{background:#00b3ff;background:var(--plyr-audio-control-background-hover,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));color:#fff;color:var(--plyr-audio-control-color-hover,#fff)}.plyr--full-ui.plyr--audio input[type=range]::-webkit-slider-runnable-track{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-range-track-background,var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6)))}.plyr--full-ui.plyr--audio input[type=range]::-moz-range-track{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-range-track-background,var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6)))}.plyr--full-ui.plyr--audio input[type=range]::-ms-track{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-range-track-background,var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6)))}.plyr--full-ui.plyr--audio input[type=range]:active::-webkit-slider-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(35,40,47,.1);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(35,40,47,.1))}.plyr--full-ui.plyr--audio input[type=range]:active::-moz-range-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(35,40,47,.1);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(35,40,47,.1))}.plyr--full-ui.plyr--audio input[type=range]:active::-ms-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(35,40,47,.1);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(35,40,47,.1))}.plyr--audio .plyr__progress__buffer{color:rgba(193,200,209,.6);color:var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6))}.plyr--video{background:#000;overflow:hidden}.plyr--video.plyr--menu-open{overflow:visible}.plyr__video-wrapper{background:#000;height:100%;margin:auto;overflow:hidden;position:relative;width:100%}.plyr__video-embed,.plyr__video-wrapper--fixed-ratio{height:0;padding-bottom:56.25%}.plyr__video-embed iframe,.plyr__video-wrapper--fixed-ratio video{border:0;left:0;position:absolute;top:0}.plyr--full-ui .plyr__video-embed>.plyr__video-embed__container{padding-bottom:240%;position:relative;transform:translateY(-38.28125%)}.plyr--video .plyr__controls{background:linear-gradient(rgba(0,0,0,0),rgba(0,0,0,.75));background:var(--plyr-video-controls-background,linear-gradient(rgba(0,0,0,0),rgba(0,0,0,.75)));border-bottom-left-radius:inherit;border-bottom-right-radius:inherit;bottom:0;color:#fff;color:var(--plyr-video-control-color,#fff);left:0;padding:calc(10px / 2);padding:calc(var(--plyr-control-spacing,10px)/ 2);padding-top:calc(10px * 2);padding-top:calc(var(--plyr-control-spacing,10px) * 2);position:absolute;right:0;transition:opacity .4s ease-in-out,transform .4s ease-in-out;z-index:3}@media (min-width:480px){.plyr--video .plyr__controls{padding:10px;padding:var(--plyr-control-spacing,10px);padding-top:calc(10px * 3.5);padding-top:calc(var(--plyr-control-spacing,10px) * 3.5)}}.plyr--video.plyr--hide-controls .plyr__controls{opacity:0;pointer-events:none;transform:translateY(100%)}.plyr--video .plyr__control.plyr__tab-focus,.plyr--video .plyr__control:hover,.plyr--video .plyr__control[aria-expanded=true]{background:#00b3ff;background:var(--plyr-video-control-background-hover,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));color:#fff;color:var(--plyr-video-control-color-hover,#fff)}.plyr__control--overlaid{background:#00b3ff;background:var(--plyr-video-control-background-hover,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));border:0;border-radius:100%;color:#fff;color:var(--plyr-video-control-color,#fff);display:none;left:50%;opacity:.9;padding:calc(10px * 1.5);padding:calc(var(--plyr-control-spacing,10px) * 1.5);position:absolute;top:50%;transform:translate(-50%,-50%);transition:.3s;z-index:2}.plyr__control--overlaid svg{left:2px;position:relative}.plyr__control--overlaid:focus,.plyr__control--overlaid:hover{opacity:1}.plyr--playing .plyr__control--overlaid{opacity:0;visibility:hidden}.plyr--full-ui.plyr--video .plyr__control--overlaid{display:block}.plyr--full-ui.plyr--video input[type=range]::-webkit-slider-runnable-track{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-range-track-background,var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25)))}.plyr--full-ui.plyr--video input[type=range]::-moz-range-track{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-range-track-background,var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25)))}.plyr--full-ui.plyr--video input[type=range]::-ms-track{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-range-track-background,var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25)))}.plyr--full-ui.plyr--video input[type=range]:active::-webkit-slider-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(255,255,255,.5);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(255,255,255,.5))}.plyr--full-ui.plyr--video input[type=range]:active::-moz-range-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(255,255,255,.5);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(255,255,255,.5))}.plyr--full-ui.plyr--video input[type=range]:active::-ms-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(255,255,255,.5);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(255,255,255,.5))}.plyr--video .plyr__progress__buffer{color:rgba(255,255,255,.25);color:var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25))}.plyr:-webkit-full-screen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-ms-fullscreen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:fullscreen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-webkit-full-screen video{height:100%}.plyr:-ms-fullscreen video{height:100%}.plyr:fullscreen video{height:100%}.plyr:-webkit-full-screen .plyr__video-wrapper{height:100%;position:static}.plyr:-ms-fullscreen .plyr__video-wrapper{height:100%;position:static}.plyr:fullscreen .plyr__video-wrapper{height:100%;position:static}.plyr:-webkit-full-screen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-ms-fullscreen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:fullscreen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen{display:block}.plyr:fullscreen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:fullscreen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-webkit-full-screen.plyr--hide-controls{cursor:none}.plyr:-ms-fullscreen.plyr--hide-controls{cursor:none}.plyr:fullscreen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-webkit-full-screen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}.plyr:-ms-fullscreen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}.plyr:fullscreen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr:-webkit-full-screen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-webkit-full-screen video{height:100%}.plyr:-webkit-full-screen .plyr__video-wrapper{height:100%;position:static}.plyr:-webkit-full-screen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-webkit-full-screen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-webkit-full-screen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr:-moz-full-screen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-moz-full-screen video{height:100%}.plyr:-moz-full-screen .plyr__video-wrapper{height:100%;position:static}.plyr:-moz-full-screen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-moz-full-screen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-moz-full-screen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-moz-full-screen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-moz-full-screen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr:-ms-fullscreen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-ms-fullscreen video{height:100%}.plyr:-ms-fullscreen .plyr__video-wrapper{height:100%;position:static}.plyr:-ms-fullscreen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-ms-fullscreen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-ms-fullscreen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr--fullscreen-fallback{background:#000;border-radius:0!important;height:100%;margin:0;width:100%;bottom:0;display:block;left:0;position:fixed;right:0;top:0;z-index:10000000}.plyr--fullscreen-fallback video{height:100%}.plyr--fullscreen-fallback .plyr__video-wrapper{height:100%;position:static}.plyr--fullscreen-fallback.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen{display:block}.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr--fullscreen-fallback.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr--fullscreen-fallback .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr__ads{border-radius:inherit;bottom:0;cursor:pointer;left:0;overflow:hidden;position:absolute;right:0;top:0;z-index:-1}.plyr__ads>div,.plyr__ads>div iframe{height:100%;position:absolute;width:100%}.plyr__ads::after{background:#23282f;border-radius:2px;bottom:10px;bottom:var(--plyr-control-spacing,10px);color:#fff;content:attr(data-badge-text);font-size:11px;padding:2px 6px;pointer-events:none;position:absolute;right:10px;right:var(--plyr-control-spacing,10px);z-index:3}.plyr__ads::after:empty{display:none}.plyr__cues{background:currentColor;display:block;height:5px;height:var(--plyr-range-track-height,5px);left:0;margin:-var(--plyr-range-track-height,5px)/2 0 0;opacity:.8;position:absolute;top:50%;width:3px;z-index:3}.plyr__preview-thumb{background-color:rgba(255,255,255,.9);background-color:var(--plyr-tooltip-background,rgba(255,255,255,.9));border-radius:3px;bottom:100%;box-shadow:0 1px 2px rgba(0,0,0,.15);box-shadow:var(--plyr-tooltip-shadow,0 1px 2px rgba(0,0,0,.15));margin-bottom:calc(calc(10px / 2) * 2);margin-bottom:calc(calc(var(--plyr-control-spacing,10px)/ 2) * 2);opacity:0;padding:3px;padding:var(--plyr-tooltip-radius,3px);pointer-events:none;position:absolute;transform:translate(0,10px) scale(.8);transform-origin:50% 100%;transition:transform .2s .1s ease,opacity .2s .1s ease;z-index:2}.plyr__preview-thumb--is-shown{opacity:1;transform:translate(0,0) scale(1)}.plyr__preview-thumb::before{border-left:4px solid transparent;border-left:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-right:4px solid transparent;border-right:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-top:4px solid rgba(255,255,255,.9);border-top:var(--plyr-tooltip-arrow-size,4px) solid var(--plyr-tooltip-background,rgba(255,255,255,.9));bottom:calc(4px * -1);bottom:calc(var(--plyr-tooltip-arrow-size,4px) * -1);content:'';height:0;left:50%;position:absolute;transform:translateX(-50%);width:0;z-index:2}.plyr__preview-thumb__image-container{background:#c1c8d1;border-radius:calc(3px - 1px);border-radius:calc(var(--plyr-tooltip-radius,3px) - 1px);overflow:hidden;position:relative;z-index:0}.plyr__preview-thumb__image-container img{height:100%;left:0;max-height:none;max-width:none;position:absolute;top:0;width:100%}.plyr__preview-thumb__time-container{bottom:6px;left:0;position:absolute;right:0;white-space:nowrap;z-index:3}.plyr__preview-thumb__time-container span{background-color:rgba(0,0,0,.55);border-radius:calc(3px - 1px);border-radius:calc(var(--plyr-tooltip-radius,3px) - 1px);color:#fff;font-size:13px;font-size:var(--plyr-font-size-time,var(--plyr-font-size-small,13px));padding:3px 6px}.plyr__preview-scrubbing{bottom:0;filter:blur(1px);height:100%;left:0;margin:auto;opacity:0;overflow:hidden;pointer-events:none;position:absolute;right:0;top:0;transition:opacity .3s ease;width:100%;z-index:1}.plyr__preview-scrubbing--is-shown{opacity:1}.plyr__preview-scrubbing img{height:100%;left:0;max-height:none;max-width:none;object-fit:contain;position:absolute;top:0;width:100%}.plyr--no-transition{transition:none!important}.plyr__sr-only{clip:rect(1px,1px,1px,1px);overflow:hidden;border:0!important;height:1px!important;padding:0!important;position:absolute!important;width:1px!important}.plyr [hidden]{display:none!important} \ No newline at end of file diff --git a/youtube/static/modules/plyr/plyr.js b/youtube/static/modules/plyr/plyr.js new file mode 100644 index 0000000..d5cc84e --- /dev/null +++ b/youtube/static/modules/plyr/plyr.js @@ -0,0 +1,9320 @@ +typeof navigator === "object" && (function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define('Plyr', factory) : + (global = global || self, global.Plyr = factory()); +}(this, (function () { 'use strict'; + + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + return Constructor; + } + + function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; + } + + function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + if (enumerableOnly) symbols = symbols.filter(function (sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + }); + keys.push.apply(keys, symbols); + } + + return keys; + } + + function _objectSpread2(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i] != null ? arguments[i] : {}; + + if (i % 2) { + ownKeys(Object(source), true).forEach(function (key) { + _defineProperty(target, key, source[key]); + }); + } else if (Object.getOwnPropertyDescriptors) { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + } else { + ownKeys(Object(source)).forEach(function (key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + } + + return target; + } + + function _objectWithoutPropertiesLoose(source, excluded) { + if (source == null) return {}; + var target = {}; + var sourceKeys = Object.keys(source); + var key, i; + + for (i = 0; i < sourceKeys.length; i++) { + key = sourceKeys[i]; + if (excluded.indexOf(key) >= 0) continue; + target[key] = source[key]; + } + + return target; + } + + function _objectWithoutProperties(source, excluded) { + if (source == null) return {}; + + var target = _objectWithoutPropertiesLoose(source, excluded); + + var key, i; + + if (Object.getOwnPropertySymbols) { + var sourceSymbolKeys = Object.getOwnPropertySymbols(source); + + for (i = 0; i < sourceSymbolKeys.length; i++) { + key = sourceSymbolKeys[i]; + if (excluded.indexOf(key) >= 0) continue; + if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; + target[key] = source[key]; + } + } + + return target; + } + + function _slicedToArray(arr, i) { + return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); + } + + function _toConsumableArray(arr) { + return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); + } + + function _arrayWithoutHoles(arr) { + if (Array.isArray(arr)) return _arrayLikeToArray(arr); + } + + function _arrayWithHoles(arr) { + if (Array.isArray(arr)) return arr; + } + + function _iterableToArray(iter) { + if (typeof Symbol !== "undefined" && Symbol.iterator in Object(iter)) return Array.from(iter); + } + + function _iterableToArrayLimit(arr, i) { + if (typeof Symbol === "undefined" || !(Symbol.iterator in Object(arr))) return; + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"] != null) _i["return"](); + } finally { + if (_d) throw _e; + } + } + + return _arr; + } + + function _unsupportedIterableToArray(o, minLen) { + if (!o) return; + if (typeof o === "string") return _arrayLikeToArray(o, minLen); + var n = Object.prototype.toString.call(o).slice(8, -1); + if (n === "Object" && o.constructor) n = o.constructor.name; + if (n === "Map" || n === "Set") return Array.from(o); + if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); + } + + function _arrayLikeToArray(arr, len) { + if (len == null || len > arr.length) len = arr.length; + + for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; + + return arr2; + } + + function _nonIterableSpread() { + throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); + } + + function _nonIterableRest() { + throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); + } + + function _classCallCheck$1(e, t) { + if (!(e instanceof t)) throw new TypeError("Cannot call a class as a function"); + } + + function _defineProperties$1(e, t) { + for (var n = 0; n < t.length; n++) { + var r = t[n]; + r.enumerable = r.enumerable || !1, r.configurable = !0, "value" in r && (r.writable = !0), Object.defineProperty(e, r.key, r); + } + } + + function _createClass$1(e, t, n) { + return t && _defineProperties$1(e.prototype, t), n && _defineProperties$1(e, n), e; + } + + function _defineProperty$1(e, t, n) { + return t in e ? Object.defineProperty(e, t, { + value: n, + enumerable: !0, + configurable: !0, + writable: !0 + }) : e[t] = n, e; + } + + function ownKeys$1(e, t) { + var n = Object.keys(e); + + if (Object.getOwnPropertySymbols) { + var r = Object.getOwnPropertySymbols(e); + t && (r = r.filter(function (t) { + return Object.getOwnPropertyDescriptor(e, t).enumerable; + })), n.push.apply(n, r); + } + + return n; + } + + function _objectSpread2$1(e) { + for (var t = 1; t < arguments.length; t++) { + var n = null != arguments[t] ? arguments[t] : {}; + t % 2 ? ownKeys$1(Object(n), !0).forEach(function (t) { + _defineProperty$1(e, t, n[t]); + }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(n)) : ownKeys$1(Object(n)).forEach(function (t) { + Object.defineProperty(e, t, Object.getOwnPropertyDescriptor(n, t)); + }); + } + + return e; + } + + var defaults = { + addCSS: !0, + thumbWidth: 15, + watch: !0 + }; + + function matches(e, t) { + return function () { + return Array.from(document.querySelectorAll(t)).includes(this); + }.call(e, t); + } + + function trigger(e, t) { + if (e && t) { + var n = new Event(t, { + bubbles: !0 + }); + e.dispatchEvent(n); + } + } + + var getConstructor = function getConstructor(e) { + return null != e ? e.constructor : null; + }, + instanceOf = function instanceOf(e, t) { + return !!(e && t && e instanceof t); + }, + isNullOrUndefined = function isNullOrUndefined(e) { + return null == e; + }, + isObject = function isObject(e) { + return getConstructor(e) === Object; + }, + isNumber = function isNumber(e) { + return getConstructor(e) === Number && !Number.isNaN(e); + }, + isString = function isString(e) { + return getConstructor(e) === String; + }, + isBoolean = function isBoolean(e) { + return getConstructor(e) === Boolean; + }, + isFunction = function isFunction(e) { + return getConstructor(e) === Function; + }, + isArray = function isArray(e) { + return Array.isArray(e); + }, + isNodeList = function isNodeList(e) { + return instanceOf(e, NodeList); + }, + isElement = function isElement(e) { + return instanceOf(e, Element); + }, + isEvent = function isEvent(e) { + return instanceOf(e, Event); + }, + isEmpty = function isEmpty(e) { + return isNullOrUndefined(e) || (isString(e) || isArray(e) || isNodeList(e)) && !e.length || isObject(e) && !Object.keys(e).length; + }, + is = { + nullOrUndefined: isNullOrUndefined, + object: isObject, + number: isNumber, + string: isString, + boolean: isBoolean, + function: isFunction, + array: isArray, + nodeList: isNodeList, + element: isElement, + event: isEvent, + empty: isEmpty + }; + + function getDecimalPlaces(e) { + var t = "".concat(e).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/); + return t ? Math.max(0, (t[1] ? t[1].length : 0) - (t[2] ? +t[2] : 0)) : 0; + } + + function round(e, t) { + if (1 > t) { + var n = getDecimalPlaces(t); + return parseFloat(e.toFixed(n)); + } + + return Math.round(e / t) * t; + } + + var RangeTouch = function () { + function e(t, n) { + _classCallCheck$1(this, e), is.element(t) ? this.element = t : is.string(t) && (this.element = document.querySelector(t)), is.element(this.element) && is.empty(this.element.rangeTouch) && (this.config = _objectSpread2$1({}, defaults, {}, n), this.init()); + } + + return _createClass$1(e, [{ + key: "init", + value: function value() { + e.enabled && (this.config.addCSS && (this.element.style.userSelect = "none", this.element.style.webKitUserSelect = "none", this.element.style.touchAction = "manipulation"), this.listeners(!0), this.element.rangeTouch = this); + } + }, { + key: "destroy", + value: function value() { + e.enabled && (this.config.addCSS && (this.element.style.userSelect = "", this.element.style.webKitUserSelect = "", this.element.style.touchAction = ""), this.listeners(!1), this.element.rangeTouch = null); + } + }, { + key: "listeners", + value: function value(e) { + var t = this, + n = e ? "addEventListener" : "removeEventListener"; + ["touchstart", "touchmove", "touchend"].forEach(function (e) { + t.element[n](e, function (e) { + return t.set(e); + }, !1); + }); + } + }, { + key: "get", + value: function value(t) { + if (!e.enabled || !is.event(t)) return null; + var n, + r = t.target, + i = t.changedTouches[0], + o = parseFloat(r.getAttribute("min")) || 0, + s = parseFloat(r.getAttribute("max")) || 100, + u = parseFloat(r.getAttribute("step")) || 1, + c = r.getBoundingClientRect(), + a = 100 / c.width * (this.config.thumbWidth / 2) / 100; + return 0 > (n = 100 / c.width * (i.clientX - c.left)) ? n = 0 : 100 < n && (n = 100), 50 > n ? n -= (100 - 2 * n) * a : 50 < n && (n += 2 * (n - 50) * a), o + round(n / 100 * (s - o), u); + } + }, { + key: "set", + value: function value(t) { + e.enabled && is.event(t) && !t.target.disabled && (t.preventDefault(), t.target.value = this.get(t), trigger(t.target, "touchend" === t.type ? "change" : "input")); + } + }], [{ + key: "setup", + value: function value(t) { + var n = 1 < arguments.length && void 0 !== arguments[1] ? arguments[1] : {}, + r = null; + if (is.empty(t) || is.string(t) ? r = Array.from(document.querySelectorAll(is.string(t) ? t : 'input[type="range"]')) : is.element(t) ? r = [t] : is.nodeList(t) ? r = Array.from(t) : is.array(t) && (r = t.filter(is.element)), is.empty(r)) return null; + + var i = _objectSpread2$1({}, defaults, {}, n); + + if (is.string(t) && i.watch) { + var o = new MutationObserver(function (n) { + Array.from(n).forEach(function (n) { + Array.from(n.addedNodes).forEach(function (n) { + is.element(n) && matches(n, t) && new e(n, i); + }); + }); + }); + o.observe(document.body, { + childList: !0, + subtree: !0 + }); + } + + return r.map(function (t) { + return new e(t, n); + }); + } + }, { + key: "enabled", + get: function get() { + return "ontouchstart" in document.documentElement; + } + }]), e; + }(); + + // ========================================================================== + // Type checking utils + // ========================================================================== + var getConstructor$1 = function getConstructor(input) { + return input !== null && typeof input !== 'undefined' ? input.constructor : null; + }; + + var instanceOf$1 = function instanceOf(input, constructor) { + return Boolean(input && constructor && input instanceof constructor); + }; + + var isNullOrUndefined$1 = function isNullOrUndefined(input) { + return input === null || typeof input === 'undefined'; + }; + + var isObject$1 = function isObject(input) { + return getConstructor$1(input) === Object; + }; + + var isNumber$1 = function isNumber(input) { + return getConstructor$1(input) === Number && !Number.isNaN(input); + }; + + var isString$1 = function isString(input) { + return getConstructor$1(input) === String; + }; + + var isBoolean$1 = function isBoolean(input) { + return getConstructor$1(input) === Boolean; + }; + + var isFunction$1 = function isFunction(input) { + return getConstructor$1(input) === Function; + }; + + var isArray$1 = function isArray(input) { + return Array.isArray(input); + }; + + var isWeakMap = function isWeakMap(input) { + return instanceOf$1(input, WeakMap); + }; + + var isNodeList$1 = function isNodeList(input) { + return instanceOf$1(input, NodeList); + }; + + var isElement$1 = function isElement(input) { + return instanceOf$1(input, Element); + }; + + var isTextNode = function isTextNode(input) { + return getConstructor$1(input) === Text; + }; + + var isEvent$1 = function isEvent(input) { + return instanceOf$1(input, Event); + }; + + var isKeyboardEvent = function isKeyboardEvent(input) { + return instanceOf$1(input, KeyboardEvent); + }; + + var isCue = function isCue(input) { + return instanceOf$1(input, window.TextTrackCue) || instanceOf$1(input, window.VTTCue); + }; + + var isTrack = function isTrack(input) { + return instanceOf$1(input, TextTrack) || !isNullOrUndefined$1(input) && isString$1(input.kind); + }; + + var isPromise = function isPromise(input) { + return instanceOf$1(input, Promise) && isFunction$1(input.then); + }; + + var isEmpty$1 = function isEmpty(input) { + return isNullOrUndefined$1(input) || (isString$1(input) || isArray$1(input) || isNodeList$1(input)) && !input.length || isObject$1(input) && !Object.keys(input).length; + }; + + var isUrl = function isUrl(input) { + // Accept a URL object + if (instanceOf$1(input, window.URL)) { + return true; + } // Must be string from here + + + if (!isString$1(input)) { + return false; + } // Add the protocol if required + + + var string = input; + + if (!input.startsWith('http://') || !input.startsWith('https://')) { + string = "http://".concat(input); + } + + try { + return !isEmpty$1(new URL(string).hostname); + } catch (e) { + return false; + } + }; + + var is$1 = { + nullOrUndefined: isNullOrUndefined$1, + object: isObject$1, + number: isNumber$1, + string: isString$1, + boolean: isBoolean$1, + function: isFunction$1, + array: isArray$1, + weakMap: isWeakMap, + nodeList: isNodeList$1, + element: isElement$1, + textNode: isTextNode, + event: isEvent$1, + keyboardEvent: isKeyboardEvent, + cue: isCue, + track: isTrack, + promise: isPromise, + url: isUrl, + empty: isEmpty$1 + }; + + // ========================================================================== + var transitionEndEvent = function () { + var element = document.createElement('span'); + var events = { + WebkitTransition: 'webkitTransitionEnd', + MozTransition: 'transitionend', + OTransition: 'oTransitionEnd otransitionend', + transition: 'transitionend' + }; + var type = Object.keys(events).find(function (event) { + return element.style[event] !== undefined; + }); + return is$1.string(type) ? events[type] : false; + }(); // Force repaint of element + + function repaint(element, delay) { + setTimeout(function () { + try { + // eslint-disable-next-line no-param-reassign + element.hidden = true; // eslint-disable-next-line no-unused-expressions + + element.offsetHeight; // eslint-disable-next-line no-param-reassign + + element.hidden = false; + } catch (e) {// Do nothing + } + }, delay); + } + + // ========================================================================== + // Browser sniffing + // Unfortunately, due to mixed support, UA sniffing is required + // ========================================================================== + var browser = { + isIE: + /* @cc_on!@ */ + !!document.documentMode, + isEdge: window.navigator.userAgent.includes('Edge'), + isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent), + isIPhone: /(iPhone|iPod)/gi.test(navigator.platform), + isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform) + }; + + function cloneDeep(object) { + return JSON.parse(JSON.stringify(object)); + } // Get a nested value in an object + + function getDeep(object, path) { + return path.split('.').reduce(function (obj, key) { + return obj && obj[key]; + }, object); + } // Deep extend destination object with N more objects + + function extend() { + var target = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + for (var _len = arguments.length, sources = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + sources[_key - 1] = arguments[_key]; + } + + if (!sources.length) { + return target; + } + + var source = sources.shift(); + + if (!is$1.object(source)) { + return target; + } + + Object.keys(source).forEach(function (key) { + if (is$1.object(source[key])) { + if (!Object.keys(target).includes(key)) { + Object.assign(target, _defineProperty({}, key, {})); + } + + extend(target[key], source[key]); + } else { + Object.assign(target, _defineProperty({}, key, source[key])); + } + }); + return extend.apply(void 0, [target].concat(sources)); + } + + function wrap(elements, wrapper) { + // Convert `elements` to an array, if necessary. + var targets = elements.length ? elements : [elements]; // Loops backwards to prevent having to clone the wrapper on the + // first element (see `child` below). + + Array.from(targets).reverse().forEach(function (element, index) { + var child = index > 0 ? wrapper.cloneNode(true) : wrapper; // Cache the current parent and sibling. + + var parent = element.parentNode; + var sibling = element.nextSibling; // Wrap the element (is automatically removed from its current + // parent). + + child.appendChild(element); // If the element had a sibling, insert the wrapper before + // the sibling to maintain the HTML structure; otherwise, just + // append it to the parent. + + if (sibling) { + parent.insertBefore(child, sibling); + } else { + parent.appendChild(child); + } + }); + } // Set attributes + + function setAttributes(element, attributes) { + if (!is$1.element(element) || is$1.empty(attributes)) { + return; + } // Assume null and undefined attributes should be left out, + // Setting them would otherwise convert them to "null" and "undefined" + + + Object.entries(attributes).filter(function (_ref) { + var _ref2 = _slicedToArray(_ref, 2), + value = _ref2[1]; + + return !is$1.nullOrUndefined(value); + }).forEach(function (_ref3) { + var _ref4 = _slicedToArray(_ref3, 2), + key = _ref4[0], + value = _ref4[1]; + + return element.setAttribute(key, value); + }); + } // Create a DocumentFragment + + function createElement(type, attributes, text) { + // Create a new + var element = document.createElement(type); // Set all passed attributes + + if (is$1.object(attributes)) { + setAttributes(element, attributes); + } // Add text node + + + if (is$1.string(text)) { + element.innerText = text; + } // Return built element + + + return element; + } // Inaert an element after another + + function insertAfter(element, target) { + if (!is$1.element(element) || !is$1.element(target)) { + return; + } + + target.parentNode.insertBefore(element, target.nextSibling); + } // Insert a DocumentFragment + + function insertElement(type, parent, attributes, text) { + if (!is$1.element(parent)) { + return; + } + + parent.appendChild(createElement(type, attributes, text)); + } // Remove element(s) + + function removeElement(element) { + if (is$1.nodeList(element) || is$1.array(element)) { + Array.from(element).forEach(removeElement); + return; + } + + if (!is$1.element(element) || !is$1.element(element.parentNode)) { + return; + } + + element.parentNode.removeChild(element); + } // Remove all child elements + + function emptyElement(element) { + if (!is$1.element(element)) { + return; + } + + var length = element.childNodes.length; + + while (length > 0) { + element.removeChild(element.lastChild); + length -= 1; + } + } // Replace element + + function replaceElement(newChild, oldChild) { + if (!is$1.element(oldChild) || !is$1.element(oldChild.parentNode) || !is$1.element(newChild)) { + return null; + } + + oldChild.parentNode.replaceChild(newChild, oldChild); + return newChild; + } // Get an attribute object from a string selector + + function getAttributesFromSelector(sel, existingAttributes) { + // For example: + // '.test' to { class: 'test' } + // '#test' to { id: 'test' } + // '[data-test="test"]' to { 'data-test': 'test' } + if (!is$1.string(sel) || is$1.empty(sel)) { + return {}; + } + + var attributes = {}; + var existing = extend({}, existingAttributes); + sel.split(',').forEach(function (s) { + // Remove whitespace + var selector = s.trim(); + var className = selector.replace('.', ''); + var stripped = selector.replace(/[[\]]/g, ''); // Get the parts and value + + var parts = stripped.split('='); + + var _parts = _slicedToArray(parts, 1), + key = _parts[0]; + + var value = parts.length > 1 ? parts[1].replace(/["']/g, '') : ''; // Get the first character + + var start = selector.charAt(0); + + switch (start) { + case '.': + // Add to existing classname + if (is$1.string(existing.class)) { + attributes.class = "".concat(existing.class, " ").concat(className); + } else { + attributes.class = className; + } + + break; + + case '#': + // ID selector + attributes.id = selector.replace('#', ''); + break; + + case '[': + // Attribute selector + attributes[key] = value; + break; + } + }); + return extend(existing, attributes); + } // Toggle hidden + + function toggleHidden(element, hidden) { + if (!is$1.element(element)) { + return; + } + + var hide = hidden; + + if (!is$1.boolean(hide)) { + hide = !element.hidden; + } // eslint-disable-next-line no-param-reassign + + + element.hidden = hide; + } // Mirror Element.classList.toggle, with IE compatibility for "force" argument + + function toggleClass(element, className, force) { + if (is$1.nodeList(element)) { + return Array.from(element).map(function (e) { + return toggleClass(e, className, force); + }); + } + + if (is$1.element(element)) { + var method = 'toggle'; + + if (typeof force !== 'undefined') { + method = force ? 'add' : 'remove'; + } + + element.classList[method](className); + return element.classList.contains(className); + } + + return false; + } // Has class name + + function hasClass(element, className) { + return is$1.element(element) && element.classList.contains(className); + } // Element matches selector + + function matches$1(element, selector) { + var _Element = Element, + prototype = _Element.prototype; + + function match() { + return Array.from(document.querySelectorAll(selector)).includes(this); + } + + var method = prototype.matches || prototype.webkitMatchesSelector || prototype.mozMatchesSelector || prototype.msMatchesSelector || match; + return method.call(element, selector); + } // Closest ancestor element matching selector (also tests element itself) + + function closest(element, selector) { + var _Element2 = Element, + prototype = _Element2.prototype; // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill + + function closestElement() { + var el = this; + + do { + if (matches$1.matches(el, selector)) return el; + el = el.parentElement || el.parentNode; + } while (el !== null && el.nodeType === 1); + + return null; + } + + var method = prototype.closest || closestElement; + return method.call(element, selector); + } // Find all elements + + function getElements(selector) { + return this.elements.container.querySelectorAll(selector); + } // Find a single element + + function getElement(selector) { + return this.elements.container.querySelector(selector); + } // Set focus and tab focus class + + function setFocus() { + var element = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var tabFocus = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + if (!is$1.element(element)) { + return; + } // Set regular focus + + + element.focus({ + preventScroll: true + }); // If we want to mimic keyboard focus via tab + + if (tabFocus) { + toggleClass(element, this.config.classNames.tabFocus); + } + } + + var defaultCodecs = { + 'audio/ogg': 'vorbis', + 'audio/wav': '1', + 'video/webm': 'vp8, vorbis', + 'video/mp4': 'avc1.42E01E, mp4a.40.2', + 'video/ogg': 'theora' + }; // Check for feature support + + var support = { + // Basic support + audio: 'canPlayType' in document.createElement('audio'), + video: 'canPlayType' in document.createElement('video'), + // Check for support + // Basic functionality vs full UI + check: function check(type, provider, playsinline) { + var canPlayInline = browser.isIPhone && playsinline && support.playsinline; + var api = support[type] || provider !== 'html5'; + var ui = api && support.rangeInput && (type !== 'video' || !browser.isIPhone || canPlayInline); + return { + api: api, + ui: ui + }; + }, + // Picture-in-picture support + // Safari & Chrome only currently + pip: function () { + if (browser.isIPhone) { + return false; + } // Safari + // https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls + + + if (is$1.function(createElement('video').webkitSetPresentationMode)) { + return true; + } // Chrome + // https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture + + + if (document.pictureInPictureEnabled && !createElement('video').disablePictureInPicture) { + return true; + } + + return false; + }(), + // Airplay support + // Safari only currently + airplay: is$1.function(window.WebKitPlaybackTargetAvailabilityEvent), + // Inline playback support + // https://webkit.org/blog/6784/new-video-policies-for-ios/ + playsinline: 'playsInline' in document.createElement('video'), + // Check for mime type support against a player instance + // Credits: http://diveintohtml5.info/everything.html + // Related: http://www.leanbackplayer.com/test/h5mt.html + mime: function mime(input) { + if (is$1.empty(input)) { + return false; + } + + var _input$split = input.split('/'), + _input$split2 = _slicedToArray(_input$split, 1), + mediaType = _input$split2[0]; + + var type = input; // Verify we're using HTML5 and there's no media type mismatch + + if (!this.isHTML5 || mediaType !== this.type) { + return false; + } // Add codec if required + + + if (Object.keys(defaultCodecs).includes(type)) { + type += "; codecs=\"".concat(defaultCodecs[input], "\""); + } + + try { + return Boolean(type && this.media.canPlayType(type).replace(/no/, '')); + } catch (e) { + return false; + } + }, + // Check for textTracks support + textTracks: 'textTracks' in document.createElement('video'), + // Sliders + rangeInput: function () { + var range = document.createElement('input'); + range.type = 'range'; + return range.type === 'range'; + }(), + // Touch + // NOTE: Remember a device can be mouse + touch enabled so we check on first touch event + touch: 'ontouchstart' in document.documentElement, + // Detect transitions support + transitions: transitionEndEvent !== false, + // Reduced motion iOS & MacOS setting + // https://webkit.org/blog/7551/responsive-design-for-motion/ + reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches + }; + + // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md + // https://www.youtube.com/watch?v=NPM6172J22g + + var supportsPassiveListeners = function () { + // Test via a getter in the options object to see if the passive property is accessed + var supported = false; + + try { + var options = Object.defineProperty({}, 'passive', { + get: function get() { + supported = true; + return null; + } + }); + window.addEventListener('test', null, options); + window.removeEventListener('test', null, options); + } catch (e) {// Do nothing + } + + return supported; + }(); // Toggle event listener + + + function toggleListener(element, event, callback) { + var _this = this; + + var toggle = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + var passive = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : true; + var capture = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : false; + + // Bail if no element, event, or callback + if (!element || !('addEventListener' in element) || is$1.empty(event) || !is$1.function(callback)) { + return; + } // Allow multiple events + + + var events = event.split(' '); // Build options + // Default to just the capture boolean for browsers with no passive listener support + + var options = capture; // If passive events listeners are supported + + if (supportsPassiveListeners) { + options = { + // Whether the listener can be passive (i.e. default never prevented) + passive: passive, + // Whether the listener is a capturing listener or not + capture: capture + }; + } // If a single node is passed, bind the event listener + + + events.forEach(function (type) { + if (_this && _this.eventListeners && toggle) { + // Cache event listener + _this.eventListeners.push({ + element: element, + type: type, + callback: callback, + options: options + }); + } + + element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options); + }); + } // Bind event handler + + function on(element) { + var events = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + var callback = arguments.length > 2 ? arguments[2] : undefined; + var passive = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; + var capture = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + toggleListener.call(this, element, events, callback, true, passive, capture); + } // Unbind event handler + + function off(element) { + var events = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + var callback = arguments.length > 2 ? arguments[2] : undefined; + var passive = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; + var capture = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + toggleListener.call(this, element, events, callback, false, passive, capture); + } // Bind once-only event handler + + function once(element) { + var _this2 = this; + + var events = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + var callback = arguments.length > 2 ? arguments[2] : undefined; + var passive = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; + var capture = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + + var onceCallback = function onceCallback() { + off(element, events, onceCallback, passive, capture); + + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + callback.apply(_this2, args); + }; + + toggleListener.call(this, element, events, onceCallback, true, passive, capture); + } // Trigger event + + function triggerEvent(element) { + var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + var bubbles = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + var detail = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; + + // Bail if no element + if (!is$1.element(element) || is$1.empty(type)) { + return; + } // Create and dispatch the event + + + var event = new CustomEvent(type, { + bubbles: bubbles, + detail: _objectSpread2(_objectSpread2({}, detail), {}, { + plyr: this + }) + }); // Dispatch the event + + element.dispatchEvent(event); + } // Unbind all cached event listeners + + function unbindListeners() { + if (this && this.eventListeners) { + this.eventListeners.forEach(function (item) { + var element = item.element, + type = item.type, + callback = item.callback, + options = item.options; + element.removeEventListener(type, callback, options); + }); + this.eventListeners = []; + } + } // Run method when / if player is ready + + function ready() { + var _this3 = this; + + return new Promise(function (resolve) { + return _this3.ready ? setTimeout(resolve, 0) : on.call(_this3, _this3.elements.container, 'ready', resolve); + }).then(function () {}); + } + + /** + * Silence a Promise-like object. + * This is useful for avoiding non-harmful, but potentially confusing "uncaught + * play promise" rejection error messages. + * @param {Object} value An object that may or may not be `Promise`-like. + */ + + function silencePromise(value) { + if (is$1.promise(value)) { + value.then(null, function () {}); + } + } + + function validateRatio(input) { + if (!is$1.array(input) && (!is$1.string(input) || !input.includes(':'))) { + return false; + } + + var ratio = is$1.array(input) ? input : input.split(':'); + return ratio.map(Number).every(is$1.number); + } + function reduceAspectRatio(ratio) { + if (!is$1.array(ratio) || !ratio.every(is$1.number)) { + return null; + } + + var _ratio = _slicedToArray(ratio, 2), + width = _ratio[0], + height = _ratio[1]; + + var getDivider = function getDivider(w, h) { + return h === 0 ? w : getDivider(h, w % h); + }; + + var divider = getDivider(width, height); + return [width / divider, height / divider]; + } + function getAspectRatio(input) { + var parse = function parse(ratio) { + return validateRatio(ratio) ? ratio.split(':').map(Number) : null; + }; // Try provided ratio + + + var ratio = parse(input); // Get from config + + if (ratio === null) { + ratio = parse(this.config.ratio); + } // Get from embed + + + if (ratio === null && !is$1.empty(this.embed) && is$1.array(this.embed.ratio)) { + ratio = this.embed.ratio; + } // Get from HTML5 video + + + if (ratio === null && this.isHTML5) { + var _this$media = this.media, + videoWidth = _this$media.videoWidth, + videoHeight = _this$media.videoHeight; + ratio = reduceAspectRatio([videoWidth, videoHeight]); + } + + return ratio; + } // Set aspect ratio for responsive container + + function setAspectRatio(input) { + if (!this.isVideo) { + return {}; + } + + var wrapper = this.elements.wrapper; + var ratio = getAspectRatio.call(this, input); + + var _ref = is$1.array(ratio) ? ratio : [0, 0], + _ref2 = _slicedToArray(_ref, 2), + w = _ref2[0], + h = _ref2[1]; + + var padding = 100 / w * h; + wrapper.style.paddingBottom = "".concat(padding, "%"); // For Vimeo we have an extra
to hide the standard controls and UI + + if (this.isVimeo && !this.config.vimeo.premium && this.supported.ui) { + var height = 100 / this.media.offsetWidth * parseInt(window.getComputedStyle(this.media).paddingBottom, 10); + var offset = (height - padding) / (height / 50); + this.media.style.transform = "translateY(-".concat(offset, "%)"); + } else if (this.isHTML5) { + wrapper.classList.toggle(this.config.classNames.videoFixedRatio, ratio !== null); + } + + return { + padding: padding, + ratio: ratio + }; + } + + // ========================================================================== + var html5 = { + getSources: function getSources() { + var _this = this; + + if (!this.isHTML5) { + return []; + } + + var sources = Array.from(this.media.querySelectorAll('source')); // Filter out unsupported sources (if type is specified) + + return sources.filter(function (source) { + var type = source.getAttribute('type'); + + if (is$1.empty(type)) { + return true; + } + + return support.mime.call(_this, type); + }); + }, + // Get quality levels + getQualityOptions: function getQualityOptions() { + // Whether we're forcing all options (e.g. for streaming) + if (this.config.quality.forced) { + return this.config.quality.options; + } // Get sizes from elements + + + return html5.getSources.call(this).map(function (source) { + return Number(source.getAttribute('data-res')); + }).filter(Boolean); + }, + setup: function setup() { + if (!this.isHTML5) { + return; + } + + var player = this; // Set speed options from config + + player.options.speed = player.config.speed.options; // Set aspect ratio if fixed + + if (!is$1.empty(this.config.ratio)) { + setAspectRatio.call(player); + } // Quality + + + Object.defineProperty(player.media, 'quality', { + get: function get() { + // Get sources + var sources = html5.getSources.call(player); + var source = sources.find(function (s) { + return s.getAttribute('src') === player.source; + }); // Return size, if match is found + + return source && Number(source.getAttribute('data-res')); + }, + set: function set(input) { + if (player.quality === input) { + return; + } // If we're using an an external handler... + + + if (player.config.quality.forced && is$1.function(player.config.quality.onChange)) { + player.config.quality.onChange(input); + } else { + // Get sources + var sources = html5.getSources.call(player); // Get first match for requested size + + var source = sources.find(function (s) { + return Number(s.getAttribute('data-res')) === input; + }); // No matching source found + + if (!source) { + return; + } // Get current state + + + var _player$media = player.media, + currentTime = _player$media.currentTime, + paused = _player$media.paused, + preload = _player$media.preload, + readyState = _player$media.readyState, + playbackRate = _player$media.playbackRate; // Set new source + + player.media.src = source.getAttribute('src'); // Prevent loading if preload="none" and the current source isn't loaded (#1044) + + if (preload !== 'none' || readyState) { + // Restore time + player.once('loadedmetadata', function () { + player.speed = playbackRate; + player.currentTime = currentTime; // Resume playing + + if (!paused) { + silencePromise(player.play()); + } + }); // Load new source + + player.media.load(); + } + } // Trigger change event + + + triggerEvent.call(player, player.media, 'qualitychange', false, { + quality: input + }); + } + }); + }, + // Cancel current network requests + // See https://github.com/sampotts/plyr/issues/174 + cancelRequests: function cancelRequests() { + if (!this.isHTML5) { + return; + } // Remove child sources + + + removeElement(html5.getSources.call(this)); // Set blank video src attribute + // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error + // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection + + this.media.setAttribute('src', this.config.blankVideo); // Load the new empty source + // This will cancel existing requests + // See https://github.com/sampotts/plyr/issues/174 + + this.media.load(); // Debugging + + this.debug.log('Cancelled network requests'); + } + }; + + // ========================================================================== + + function dedupe(array) { + if (!is$1.array(array)) { + return array; + } + + return array.filter(function (item, index) { + return array.indexOf(item) === index; + }); + } // Get the closest value in an array + + function closest$1(array, value) { + if (!is$1.array(array) || !array.length) { + return null; + } + + return array.reduce(function (prev, curr) { + return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev; + }); + } + + // ========================================================================== + + function generateId(prefix) { + return "".concat(prefix, "-").concat(Math.floor(Math.random() * 10000)); + } // Format string + + function format(input) { + for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + + if (is$1.empty(input)) { + return input; + } + + return input.toString().replace(/{(\d+)}/g, function (match, i) { + return args[i].toString(); + }); + } // Get percentage + + function getPercentage(current, max) { + if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) { + return 0; + } + + return (current / max * 100).toFixed(2); + } // Replace all occurances of a string in a string + + var replaceAll = function replaceAll() { + var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; + var find = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + var replace = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : ''; + return input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), replace.toString()); + }; // Convert to title case + + var toTitleCase = function toTitleCase() { + var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; + return input.toString().replace(/\w\S*/g, function (text) { + return text.charAt(0).toUpperCase() + text.substr(1).toLowerCase(); + }); + }; // Convert string to pascalCase + + function toPascalCase() { + var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; + var string = input.toString(); // Convert kebab case + + string = replaceAll(string, '-', ' '); // Convert snake case + + string = replaceAll(string, '_', ' '); // Convert to title case + + string = toTitleCase(string); // Convert to pascal case + + return replaceAll(string, ' ', ''); + } // Convert string to pascalCase + + function toCamelCase() { + var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; + var string = input.toString(); // Convert to pascal case + + string = toPascalCase(string); // Convert first character to lowercase + + return string.charAt(0).toLowerCase() + string.slice(1); + } // Remove HTML from a string + + function stripHTML(source) { + var fragment = document.createDocumentFragment(); + var element = document.createElement('div'); + fragment.appendChild(element); + element.innerHTML = source; + return fragment.firstChild.innerText; + } // Like outerHTML, but also works for DocumentFragment + + function getHTML(element) { + var wrapper = document.createElement('div'); + wrapper.appendChild(element); + return wrapper.innerHTML; + } + + var resources = { + pip: 'PIP', + airplay: 'AirPlay', + html5: 'HTML5', + vimeo: 'Vimeo', + youtube: 'YouTube' + }; + var i18n = { + get: function get() { + var key = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; + var config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + if (is$1.empty(key) || is$1.empty(config)) { + return ''; + } + + var string = getDeep(config.i18n, key); + + if (is$1.empty(string)) { + if (Object.keys(resources).includes(key)) { + return resources[key]; + } + + return ''; + } + + var replace = { + '{seektime}': config.seekTime, + '{title}': config.title + }; + Object.entries(replace).forEach(function (_ref) { + var _ref2 = _slicedToArray(_ref, 2), + k = _ref2[0], + v = _ref2[1]; + + string = replaceAll(string, k, v); + }); + return string; + } + }; + + var Storage = /*#__PURE__*/function () { + function Storage(player) { + _classCallCheck(this, Storage); + + this.enabled = player.config.storage.enabled; + this.key = player.config.storage.key; + } // Check for actual support (see if we can use it) + + + _createClass(Storage, [{ + key: "get", + value: function get(key) { + if (!Storage.supported || !this.enabled) { + return null; + } + + var store = window.localStorage.getItem(this.key); + + if (is$1.empty(store)) { + return null; + } + + var json = JSON.parse(store); + return is$1.string(key) && key.length ? json[key] : json; + } + }, { + key: "set", + value: function set(object) { + // Bail if we don't have localStorage support or it's disabled + if (!Storage.supported || !this.enabled) { + return; + } // Can only store objectst + + + if (!is$1.object(object)) { + return; + } // Get current storage + + + var storage = this.get(); // Default to empty object + + if (is$1.empty(storage)) { + storage = {}; + } // Update the working copy of the values + + + extend(storage, object); // Update storage + + window.localStorage.setItem(this.key, JSON.stringify(storage)); + } + }], [{ + key: "supported", + get: function get() { + try { + if (!('localStorage' in window)) { + return false; + } + + var test = '___test'; // Try to use it (it might be disabled, e.g. user is in private mode) + // see: https://github.com/sampotts/plyr/issues/131 + + window.localStorage.setItem(test, test); + window.localStorage.removeItem(test); + return true; + } catch (e) { + return false; + } + } + }]); + + return Storage; + }(); + + // ========================================================================== + // Fetch wrapper + // Using XHR to avoid issues with older browsers + // ========================================================================== + function fetch(url) { + var responseType = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'text'; + return new Promise(function (resolve, reject) { + try { + var request = new XMLHttpRequest(); // Check for CORS support + + if (!('withCredentials' in request)) { + return; + } + + request.addEventListener('load', function () { + if (responseType === 'text') { + try { + resolve(JSON.parse(request.responseText)); + } catch (e) { + resolve(request.responseText); + } + } else { + resolve(request.response); + } + }); + request.addEventListener('error', function () { + throw new Error(request.status); + }); + request.open('GET', url, true); // Set the required response type + + request.responseType = responseType; + request.send(); + } catch (e) { + reject(e); + } + }); + } + + // ========================================================================== + + function loadSprite(url, id) { + if (!is$1.string(url)) { + return; + } + + var prefix = 'cache'; + var hasId = is$1.string(id); + var isCached = false; + + var exists = function exists() { + return document.getElementById(id) !== null; + }; + + var update = function update(container, data) { + // eslint-disable-next-line no-param-reassign + container.innerHTML = data; // Check again incase of race condition + + if (hasId && exists()) { + return; + } // Inject the SVG to the body + + + document.body.insertAdjacentElement('afterbegin', container); + }; // Only load once if ID set + + + if (!hasId || !exists()) { + var useStorage = Storage.supported; // Create container + + var container = document.createElement('div'); + container.setAttribute('hidden', ''); + + if (hasId) { + container.setAttribute('id', id); + } // Check in cache + + + if (useStorage) { + var cached = window.localStorage.getItem("".concat(prefix, "-").concat(id)); + isCached = cached !== null; + + if (isCached) { + var data = JSON.parse(cached); + update(container, data.content); + } + } // Get the sprite + + + fetch(url).then(function (result) { + if (is$1.empty(result)) { + return; + } + + if (useStorage) { + window.localStorage.setItem("".concat(prefix, "-").concat(id), JSON.stringify({ + content: result + })); + } + + update(container, result); + }).catch(function () {}); + } + } + + // ========================================================================== + + var getHours = function getHours(value) { + return Math.trunc(value / 60 / 60 % 60, 10); + }; + var getMinutes = function getMinutes(value) { + return Math.trunc(value / 60 % 60, 10); + }; + var getSeconds = function getSeconds(value) { + return Math.trunc(value % 60, 10); + }; // Format time to UI friendly string + + function formatTime() { + var time = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var displayHours = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + var inverted = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + + // Bail if the value isn't a number + if (!is$1.number(time)) { + return formatTime(undefined, displayHours, inverted); + } // Format time component to add leading zero + + + var format = function format(value) { + return "0".concat(value).slice(-2); + }; // Breakdown to hours, mins, secs + + + var hours = getHours(time); + var mins = getMinutes(time); + var secs = getSeconds(time); // Do we need to display hours? + + if (displayHours || hours > 0) { + hours = "".concat(hours, ":"); + } else { + hours = ''; + } // Render + + + return "".concat(inverted && time > 0 ? '-' : '').concat(hours).concat(format(mins), ":").concat(format(secs)); + } + + var controls = { + // Get icon URL + getIconUrl: function getIconUrl() { + var url = new URL(this.config.iconUrl, window.location); + var cors = url.host !== window.location.host || browser.isIE && !window.svg4everybody; + return { + url: this.config.iconUrl, + cors: cors + }; + }, + // Find the UI controls + findElements: function findElements() { + try { + this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper); // Buttons + + this.elements.buttons = { + play: getElements.call(this, this.config.selectors.buttons.play), + pause: getElement.call(this, this.config.selectors.buttons.pause), + restart: getElement.call(this, this.config.selectors.buttons.restart), + rewind: getElement.call(this, this.config.selectors.buttons.rewind), + fastForward: getElement.call(this, this.config.selectors.buttons.fastForward), + mute: getElement.call(this, this.config.selectors.buttons.mute), + pip: getElement.call(this, this.config.selectors.buttons.pip), + airplay: getElement.call(this, this.config.selectors.buttons.airplay), + settings: getElement.call(this, this.config.selectors.buttons.settings), + captions: getElement.call(this, this.config.selectors.buttons.captions), + fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen) + }; // Progress + + this.elements.progress = getElement.call(this, this.config.selectors.progress); // Inputs + + this.elements.inputs = { + seek: getElement.call(this, this.config.selectors.inputs.seek), + volume: getElement.call(this, this.config.selectors.inputs.volume) + }; // Display + + this.elements.display = { + buffer: getElement.call(this, this.config.selectors.display.buffer), + currentTime: getElement.call(this, this.config.selectors.display.currentTime), + duration: getElement.call(this, this.config.selectors.display.duration) + }; // Seek tooltip + + if (is$1.element(this.elements.progress)) { + this.elements.display.seekTooltip = this.elements.progress.querySelector(".".concat(this.config.classNames.tooltip)); + } + + return true; + } catch (error) { + // Log it + this.debug.warn('It looks like there is a problem with your custom controls HTML', error); // Restore native video controls + + this.toggleNativeControls(true); + return false; + } + }, + // Create icon + createIcon: function createIcon(type, attributes) { + var namespace = 'http://www.w3.org/2000/svg'; + var iconUrl = controls.getIconUrl.call(this); + var iconPath = "".concat(!iconUrl.cors ? iconUrl.url : '', "#").concat(this.config.iconPrefix); // Create + + var icon = document.createElementNS(namespace, 'svg'); + setAttributes(icon, extend(attributes, { + 'aria-hidden': 'true', + focusable: 'false' + })); // Create the to reference sprite + + var use = document.createElementNS(namespace, 'use'); + var path = "".concat(iconPath, "-").concat(type); // Set `href` attributes + // https://github.com/sampotts/plyr/issues/460 + // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href + + if ('href' in use) { + use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path); + } // Always set the older attribute even though it's "deprecated" (it'll be around for ages) + + + use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path); // Add to + + icon.appendChild(use); + return icon; + }, + // Create hidden text label + createLabel: function createLabel(key) { + var attr = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + var text = i18n.get(key, this.config); + + var attributes = _objectSpread2(_objectSpread2({}, attr), {}, { + class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') + }); + + return createElement('span', attributes, text); + }, + // Create a badge + createBadge: function createBadge(text) { + if (is$1.empty(text)) { + return null; + } + + var badge = createElement('span', { + class: this.config.classNames.menu.value + }); + badge.appendChild(createElement('span', { + class: this.config.classNames.menu.badge + }, text)); + return badge; + }, + // Create a
if needed + + if (is$1.empty(source)) { + source = player.media.getAttribute(player.config.attributes.embed.id); + } + + var id = parseId(source); // Build an iframe + + var iframe = createElement('iframe'); + var src = format(player.config.urls.vimeo.iframe, id, params); + iframe.setAttribute('src', src); + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('allow', 'autoplay,fullscreen,picture-in-picture'); // Set the referrer policy if required + + if (!is$1.empty(referrerPolicy)) { + iframe.setAttribute('referrerPolicy', referrerPolicy); + } // Inject the package + + + var poster = player.poster; + + if (premium) { + iframe.setAttribute('data-poster', poster); + player.media = replaceElement(iframe, player.media); + } else { + var wrapper = createElement('div', { + class: player.config.classNames.embedContainer, + 'data-poster': poster + }); + wrapper.appendChild(iframe); + player.media = replaceElement(wrapper, player.media); + } // Get poster image + + + fetch(format(player.config.urls.vimeo.api, id), 'json').then(function (response) { + if (is$1.empty(response)) { + return; + } // Get the URL for thumbnail + + + var url = new URL(response[0].thumbnail_large); // Get original image + + url.pathname = "".concat(url.pathname.split('_')[0], ".jpg"); // Set and show poster + + ui.setPoster.call(player, url.href).catch(function () {}); + }); // Setup instance + // https://github.com/vimeo/player.js + + player.embed = new window.Vimeo.Player(iframe, { + autopause: player.config.autopause, + muted: player.muted + }); + player.media.paused = true; + player.media.currentTime = 0; // Disable native text track rendering + + if (player.supported.ui) { + player.embed.disableTextTrack(); + } // Create a faux HTML5 API using the Vimeo API + + + player.media.play = function () { + assurePlaybackState.call(player, true); + return player.embed.play(); + }; + + player.media.pause = function () { + assurePlaybackState.call(player, false); + return player.embed.pause(); + }; + + player.media.stop = function () { + player.pause(); + player.currentTime = 0; + }; // Seeking + + + var currentTime = player.media.currentTime; + Object.defineProperty(player.media, 'currentTime', { + get: function get() { + return currentTime; + }, + set: function set(time) { + // Vimeo will automatically play on seek if the video hasn't been played before + // Get current paused state and volume etc + var embed = player.embed, + media = player.media, + paused = player.paused, + volume = player.volume; + var restorePause = paused && !embed.hasPlayed; // Set seeking state and trigger event + + media.seeking = true; + triggerEvent.call(player, media, 'seeking'); // If paused, mute until seek is complete + + Promise.resolve(restorePause && embed.setVolume(0)) // Seek + .then(function () { + return embed.setCurrentTime(time); + }) // Restore paused + .then(function () { + return restorePause && embed.pause(); + }) // Restore volume + .then(function () { + return restorePause && embed.setVolume(volume); + }).catch(function () {// Do nothing + }); + } + }); // Playback speed + + var speed = player.config.speed.selected; + Object.defineProperty(player.media, 'playbackRate', { + get: function get() { + return speed; + }, + set: function set(input) { + player.embed.setPlaybackRate(input).then(function () { + speed = input; + triggerEvent.call(player, player.media, 'ratechange'); + }).catch(function () { + // Cannot set Playback Rate, Video is probably not on Pro account + player.options.speed = [1]; + }); + } + }); // Volume + + var volume = player.config.volume; + Object.defineProperty(player.media, 'volume', { + get: function get() { + return volume; + }, + set: function set(input) { + player.embed.setVolume(input).then(function () { + volume = input; + triggerEvent.call(player, player.media, 'volumechange'); + }); + } + }); // Muted + + var muted = player.config.muted; + Object.defineProperty(player.media, 'muted', { + get: function get() { + return muted; + }, + set: function set(input) { + var toggle = is$1.boolean(input) ? input : false; + player.embed.setVolume(toggle ? 0 : player.config.volume).then(function () { + muted = toggle; + triggerEvent.call(player, player.media, 'volumechange'); + }); + } + }); // Loop + + var loop = player.config.loop; + Object.defineProperty(player.media, 'loop', { + get: function get() { + return loop; + }, + set: function set(input) { + var toggle = is$1.boolean(input) ? input : player.config.loop.active; + player.embed.setLoop(toggle).then(function () { + loop = toggle; + }); + } + }); // Source + + var currentSrc; + player.embed.getVideoUrl().then(function (value) { + currentSrc = value; + controls.setDownloadUrl.call(player); + }).catch(function (error) { + _this.debug.warn(error); + }); + Object.defineProperty(player.media, 'currentSrc', { + get: function get() { + return currentSrc; + } + }); // Ended + + Object.defineProperty(player.media, 'ended', { + get: function get() { + return player.currentTime === player.duration; + } + }); // Set aspect ratio based on video size + + Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(function (dimensions) { + var _dimensions = _slicedToArray(dimensions, 2), + width = _dimensions[0], + height = _dimensions[1]; + + player.embed.ratio = [width, height]; + setAspectRatio.call(_this); + }); // Set autopause + + player.embed.setAutopause(player.config.autopause).then(function (state) { + player.config.autopause = state; + }); // Get title + + player.embed.getVideoTitle().then(function (title) { + player.config.title = title; + ui.setTitle.call(_this); + }); // Get current time + + player.embed.getCurrentTime().then(function (value) { + currentTime = value; + triggerEvent.call(player, player.media, 'timeupdate'); + }); // Get duration + + player.embed.getDuration().then(function (value) { + player.media.duration = value; + triggerEvent.call(player, player.media, 'durationchange'); + }); // Get captions + + player.embed.getTextTracks().then(function (tracks) { + player.media.textTracks = tracks; + captions.setup.call(player); + }); + player.embed.on('cuechange', function (_ref) { + var _ref$cues = _ref.cues, + cues = _ref$cues === void 0 ? [] : _ref$cues; + var strippedCues = cues.map(function (cue) { + return stripHTML(cue.text); + }); + captions.updateCues.call(player, strippedCues); + }); + player.embed.on('loaded', function () { + // Assure state and events are updated on autoplay + player.embed.getPaused().then(function (paused) { + assurePlaybackState.call(player, !paused); + + if (!paused) { + triggerEvent.call(player, player.media, 'playing'); + } + }); + + if (is$1.element(player.embed.element) && player.supported.ui) { + var frame = player.embed.element; // Fix keyboard focus issues + // https://github.com/sampotts/plyr/issues/317 + + frame.setAttribute('tabindex', -1); + } + }); + player.embed.on('bufferstart', function () { + triggerEvent.call(player, player.media, 'waiting'); + }); + player.embed.on('bufferend', function () { + triggerEvent.call(player, player.media, 'playing'); + }); + player.embed.on('play', function () { + assurePlaybackState.call(player, true); + triggerEvent.call(player, player.media, 'playing'); + }); + player.embed.on('pause', function () { + assurePlaybackState.call(player, false); + }); + player.embed.on('timeupdate', function (data) { + player.media.seeking = false; + currentTime = data.seconds; + triggerEvent.call(player, player.media, 'timeupdate'); + }); + player.embed.on('progress', function (data) { + player.media.buffered = data.percent; + triggerEvent.call(player, player.media, 'progress'); // Check all loaded + + if (parseInt(data.percent, 10) === 1) { + triggerEvent.call(player, player.media, 'canplaythrough'); + } // Get duration as if we do it before load, it gives an incorrect value + // https://github.com/sampotts/plyr/issues/891 + + + player.embed.getDuration().then(function (value) { + if (value !== player.media.duration) { + player.media.duration = value; + triggerEvent.call(player, player.media, 'durationchange'); + } + }); + }); + player.embed.on('seeked', function () { + player.media.seeking = false; + triggerEvent.call(player, player.media, 'seeked'); + }); + player.embed.on('ended', function () { + player.media.paused = true; + triggerEvent.call(player, player.media, 'ended'); + }); + player.embed.on('error', function (detail) { + player.media.error = detail; + triggerEvent.call(player, player.media, 'error'); + }); // Rebuild UI + + setTimeout(function () { + return ui.build.call(player); + }, 0); + } + }; + + // ========================================================================== + + function parseId$1(url) { + if (is$1.empty(url)) { + return null; + } + + var regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; + return url.match(regex) ? RegExp.$2 : url; + } // Set playback state and trigger change (only on actual change) + + + function assurePlaybackState$1(play) { + if (play && !this.embed.hasPlayed) { + this.embed.hasPlayed = true; + } + + if (this.media.paused === play) { + this.media.paused = !play; + triggerEvent.call(this, this.media, play ? 'play' : 'pause'); + } + } + + function getHost(config) { + if (config.noCookie) { + return 'https://www.youtube-nocookie.com'; + } + + if (window.location.protocol === 'http:') { + return 'http://www.youtube.com'; + } // Use YouTube's default + + + return undefined; + } + + var youtube = { + setup: function setup() { + var _this = this; + + // Add embed class for responsive + toggleClass(this.elements.wrapper, this.config.classNames.embed, true); // Setup API + + if (is$1.object(window.YT) && is$1.function(window.YT.Player)) { + youtube.ready.call(this); + } else { + // Reference current global callback + var callback = window.onYouTubeIframeAPIReady; // Set callback to process queue + + window.onYouTubeIframeAPIReady = function () { + // Call global callback if set + if (is$1.function(callback)) { + callback(); + } + + youtube.ready.call(_this); + }; // Load the SDK + + + loadScript(this.config.urls.youtube.sdk).catch(function (error) { + _this.debug.warn('YouTube API failed to load', error); + }); + } + }, + // Get the media title + getTitle: function getTitle(videoId) { + var _this2 = this; + + var url = format(this.config.urls.youtube.api, videoId); + fetch(url).then(function (data) { + if (is$1.object(data)) { + var title = data.title, + height = data.height, + width = data.width; // Set title + + _this2.config.title = title; + ui.setTitle.call(_this2); // Set aspect ratio + + _this2.embed.ratio = [width, height]; + } + + setAspectRatio.call(_this2); + }).catch(function () { + // Set aspect ratio + setAspectRatio.call(_this2); + }); + }, + // API ready + ready: function ready() { + var player = this; // Ignore already setup (race condition) + + var currentId = player.media && player.media.getAttribute('id'); + + if (!is$1.empty(currentId) && currentId.startsWith('youtube-')) { + return; + } // Get the source URL or ID + + + var source = player.media.getAttribute('src'); // Get from
if needed + + if (is$1.empty(source)) { + source = player.media.getAttribute(this.config.attributes.embed.id); + } // Replace the