From c89d488c30a46a5b8e257dd9b8f3046c10a3b394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Dzi=C4=99giel?= Date: Fri, 12 Mar 2021 13:05:58 +0100 Subject: [PATCH] Prefetch YouTube video info on hover Speed up loading of YouTube videos by downloading and parsing their info before video is dropped into player. --- src/player.js | 9 +++-- src/widget.js | 28 +++++++++++++ src/youtube.js | 106 ++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/player.js b/src/player.js index 82f0f3ce..0efbbb0c 100644 --- a/src/player.js +++ b/src/player.js @@ -26,6 +26,7 @@ class ClapperPlayer extends PlayerBase this.needsTocUpdate = true; this.keyPressCount = 0; + this.ytClient = null; const keyController = new Gtk.EventControllerKey(); keyController.connect('key-pressed', this._onWidgetKeyPressed.bind(this)); @@ -78,8 +79,10 @@ class ClapperPlayer extends PlayerBase async getYouTubeUriAsync(videoId) { - const client = new YouTube.YouTubeClient(); - const info = await client.getVideoInfoPromise(videoId).catch(debug); + if(!this.ytClient) + this.ytClient = new YouTube.YouTubeClient(); + + const info = await this.ytClient.getVideoInfoPromise(videoId).catch(debug); if(!info) throw new Error('no YouTube video info'); @@ -87,7 +90,7 @@ class ClapperPlayer extends PlayerBase const dash = Dash.generateDash(info); const videoUri = (dash) ? await Dash.saveDashPromise(dash).catch(debug) - : client.getBestCombinedUri(info); + : this.ytClient.getBestCombinedUri(info); if(!videoUri) throw new Error('no YouTube video URI'); diff --git a/src/widget.js b/src/widget.js index fc852954..50a4aabe 100644 --- a/src/widget.js +++ b/src/widget.js @@ -4,6 +4,7 @@ const Debug = imports.src.debug; const Dialogs = imports.src.dialogs; const Misc = imports.src.misc; const { Player } = imports.src.player; +const YouTube = imports.src.youtube; const Revealers = imports.src.revealers; const { debug } = Debug; @@ -721,9 +722,11 @@ class ClapperWidget extends Gtk.Grid { const dropTarget = new Gtk.DropTarget({ actions: Gdk.DragAction.COPY, + preload: true, }); dropTarget.set_gtypes([GObject.TYPE_STRING]); dropTarget.connect('drop', this._onDataDrop.bind(this)); + dropTarget.connect('notify::value', this._onDropValueNotify.bind(this)); return dropTarget; } @@ -897,6 +900,31 @@ class ClapperWidget extends Gtk.Grid this.posY = posY; } + _onDropValueNotify(dropTarget) + { + if(!dropTarget.value) + return; + + const uris = dropTarget.value.split(/\r?\n/); + const firstUri = uris[0]; + + if(uris.length > 1 || !Gst.uri_is_valid(firstUri)) + return; + + /* Check if user is dragging a YouTube link */ + const [isYouTubeUri, videoId] = YouTube.checkYouTubeUri(firstUri); + if(!isYouTubeUri) return; + + /* Since this is a YouTube video, + * create YT client if it was not created yet */ + if(!this.player.ytClient) + this.player.ytClient = new YouTube.YouTubeClient(); + + /* Speed up things by prefetching new video info before drop */ + if(!this.player.ytClient.compareLastVideoId(videoId)) + this.player.ytClient.getVideoInfoPromise(videoId).catch(debug); + } + _onDataDrop(dropTarget, value, x, y) { const playlist = value.split(/\r?\n/).filter(uri => { diff --git a/src/youtube.js b/src/youtube.js index b4d832d9..ff6ee95f 100644 --- a/src/youtube.js +++ b/src/youtube.js @@ -4,27 +4,54 @@ const Debug = imports.src.debug; const { debug } = Debug; -var YouTubeClient = GObject.registerClass( -class ClapperYouTubeClient extends Soup.Session +var YouTubeClient = GObject.registerClass({ + Signals: { + 'info-resolved': { + param_types: [GObject.TYPE_BOOLEAN] + } + } +}, class ClapperYouTubeClient extends Soup.Session { _init() { super._init({ timeout: 5, }); + + /* videoID of current active download */ + this.downloadingVideoId = null; + + this.downloadAborted = false; + this.lastInfo = null; } getVideoInfoPromise(videoId) { - return new Promise(async (resolve, reject) => { - const url = `https://www.youtube.com/get_video_info?video_id=${videoId}&el=embedded`; - let tries = 2; + /* If in middle of download and same videoID, + * resolve to current download */ + if( + this.downloadingVideoId + && this.downloadingVideoId === videoId + ) + return this._getCurrentDownloadPromise(); + return new Promise(async (resolve, reject) => { + /* Do not redownload info for the same video */ + if(this.compareLastVideoId(videoId)) + return resolve(this.lastInfo); + + this.abort(); + + let tries = 2; while(tries--) { debug(`obtaining YouTube video info: ${videoId}`); + this.downloadingVideoId = videoId; - const info = await this._getInfoPromise(url).catch(debug); + const info = await this._getInfoPromise(videoId).catch(debug); if(!info) { + if(this.downloadAborted) + return reject(new Error('download aborted')); + debug(`failed, remaining tries: ${tries}`); continue; } @@ -33,16 +60,31 @@ class ClapperYouTubeClient extends Soup.Session if( !info.playabilityStatus || !info.playabilityStatus.status === 'OK' - ) + ) { + this.emit('info-resolved', false); + this.downloadingVideoId = null; + return reject(new Error('video is not playable')); + } /* Check if data contains streaming URIs */ - if(!info.streamingData) + if(!info.streamingData) { + this.emit('info-resolved', false); + this.downloadingVideoId = null; + return reject(new Error('video response data is missing URIs')); + } + + this.lastInfo = info; + this.emit('info-resolved', true); + this.downloadingVideoId = null; return resolve(info); } + this.emit('info-resolved', false); + this.downloadingVideoId = null; + reject(new Error('could not obtain YouTube video info')); }); } @@ -65,9 +107,45 @@ class ClapperYouTubeClient extends Soup.Session return combinedStream.url; } - _getInfoPromise(url) + compareLastVideoId(videoId) + { + if(!this.lastInfo) + return false; + + if( + !this.lastInfo + || !this.lastInfo.videoDetails + || this.lastInfo.videoDetails.videoId !== videoId + /* TODO: check if video expired */ + ) + return false; + + return true; + } + + _getCurrentDownloadPromise() + { + debug('resolving after current download finishes'); + + return new Promise((resolve, reject) => { + const infoResolvedSignal = this.connect('info-resolved', (self, success) => { + this.disconnect(infoResolvedSignal); + + debug('current download finished, resolving'); + + if(!success) + return reject(new Error('info resolve was unsuccessful')); + + /* At this point new video info is set */ + resolve(this.lastInfo); + }); + }); + } + + _getInfoPromise(videoId) { return new Promise((resolve, reject) => { + const url = `https://www.youtube.com/get_video_info?video_id=${videoId}&el=embedded`; const message = Soup.Message.new('GET', url); let data = ''; @@ -85,8 +163,14 @@ class ClapperYouTubeClient extends Soup.Session debug('got message response'); - if(msg.status_code !== 200) - return reject(new Error(`response code: ${msg.status_code}`)); + const statusCode = msg.status_code; + + /* Internal Soup codes mean download abort + * or some other error that cannot be handled */ + this.downloadAborted = (statusCode < 10); + + if(statusCode !== 200) + return reject(new Error(`response code: ${statusCode}`)); debug('parsing video info JSON');