diff --git a/src/dash.js b/src/dash.js
new file mode 100644
index 00000000..38e895cb
--- /dev/null
+++ b/src/dash.js
@@ -0,0 +1,202 @@
+const { Gio, GLib } = imports.gi;
+const Debug = imports.src.debug;
+const Misc = imports.src.misc;
+
+const { debug } = Debug;
+
+function generateDash(info)
+{
+ if(
+ !info.streamingData
+ || !info.streamingData.adaptiveFormats
+ || !info.streamingData.adaptiveFormats.length
+ )
+ return null;
+
+ /* TODO: Options in prefs to set preferred video formats for adaptive streaming */
+ const videoStream = info.streamingData.adaptiveFormats.find(stream => {
+ return (stream.mimeType.startsWith('video/mp4') && stream.quality === 'hd1080');
+ });
+ const audioStream = info.streamingData.adaptiveFormats.find(stream => {
+ return (stream.mimeType.startsWith('audio/mp4'));
+ });
+
+ if(!videoStream || !audioStream)
+ return null;
+
+ const bufferSec = Math.min(4, info.videoDetails.lengthSeconds);
+
+ return [
+ ``,
+ ``,
+ ` `,
+ _addAdaptationSet([videoStream]),
+ _addAdaptationSet([audioStream]),
+ ` `,
+ ``
+ ].join('\n');
+}
+
+function saveDashPromise(dash)
+{
+ return new Promise((resolve, reject) => {
+ const tempPath = GLib.get_tmp_dir() + '/.clapper.mpd';
+ const dashFile = Gio.File.new_for_path(tempPath);
+
+ debug('saving dash file');
+
+ dashFile.replace_contents_bytes_async(
+ GLib.Bytes.new_take(dash),
+ null,
+ false,
+ Gio.FileCreateFlags.NONE,
+ null
+ )
+ .then(() => {
+ debug('saved dash file');
+ resolve(dashFile.get_uri());
+ })
+ .catch(err => reject(err));
+ });
+}
+
+function _addAdaptationSet(streamsArr)
+{
+ const mimeInfo = _getMimeInfo(streamsArr[0].mimeType);
+
+ const adaptArr = [
+ `contentType="${mimeInfo.content}"`,
+ `mimeType="${mimeInfo.type}"`,
+ `subsegmentAlignment="true"`,
+ `subsegmentStartsWithSAP="1"`,
+ ];
+
+ const widthArr = [];
+ const heightArr = [];
+ const fpsArr = [];
+
+ const representations = [];
+
+ for(let stream of streamsArr) {
+ /* No point parsing if no URL */
+ if(!stream.url)
+ continue;
+
+ if(stream.width && stream.height) {
+ widthArr.push(stream.width);
+ heightArr.push(stream.height);
+ }
+ if(stream.fps)
+ fpsArr.push(stream.fps);
+
+ representations.push(_getStreamRepresentation(stream));
+ }
+
+ if(widthArr.length && heightArr.length) {
+ const maxWidth = Math.max.apply(null, widthArr);
+ const maxHeight = Math.max.apply(null, heightArr);
+ const par = _getPar(maxWidth, maxHeight);
+
+ adaptArr.push(`maxWidth="${maxWidth}"`);
+ adaptArr.push(`maxHeight="${maxHeight}"`);
+ adaptArr.push(`par="${par}"`);
+ }
+ if(fpsArr.length) {
+ const maxFps = Math.max.apply(null, fpsArr);
+
+ adaptArr.push(`maxFrameRate="${maxFps}"`);
+ }
+
+ const adaptationSet = [
+ ` `,
+ representations.join('\n'),
+ ` `
+ ];
+
+ return adaptationSet.join('\n');
+}
+
+function _getStreamRepresentation(stream)
+{
+ const mimeInfo = _getMimeInfo(stream.mimeType);
+
+ const repOptsArr = [
+ `id="${stream.itag}"`,
+ `codecs="${mimeInfo.codecs}"`,
+ `bandwidth="${stream.bitrate}"`,
+ ];
+
+ if(stream.width && stream.height) {
+ repOptsArr.push(`width="${stream.width}"`);
+ repOptsArr.push(`height="${stream.height}"`);
+ repOptsArr.push(`sar="1:1"`);
+ }
+ if(stream.fps)
+ repOptsArr.push(`frameRate="${stream.fps}"`);
+
+ const repArr = [
+ ` `,
+ ];
+ if(stream.audioChannels) {
+ const audioConfArr = [
+ `schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"`,
+ `value="${stream.audioChannels}"`,
+ ];
+ repArr.push(` `);
+ }
+
+ const encodedURL = Misc.encodeHTML(stream.url);
+ const segRange = `${stream.indexRange.start}-${stream.indexRange.end}`;
+ const initRange = `${stream.initRange.start}-${stream.initRange.end}`;
+
+ repArr.push(
+ ` ${encodedURL}`,
+ ``,
+ ` `,
+ );
+
+ return repArr.join('\n');
+}
+
+function _getMimeInfo(mimeType)
+{
+ const mimeArr = mimeType.split(';');
+
+ let codecs = mimeArr.find(info => info.includes('codecs')).split('=')[1];
+ codecs = codecs.substring(1, codecs.length - 1);
+
+ const mimeInfo = {
+ content: mimeArr[0].split('/')[0],
+ type: mimeArr[0],
+ codecs,
+ };
+
+ return mimeInfo;
+}
+
+function _getPar(width, height)
+{
+ const gcd = _getGCD(width, height);
+
+ width /= gcd;
+ height /= gcd;
+
+ return `${width}:${height}`;
+}
+
+function _getGCD(width, height)
+{
+ return (height)
+ ? _getGCD(height, width % height)
+ : width;
+}
diff --git a/src/main.js b/src/main.js
index 76d169c4..54e20d2a 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,8 +1,10 @@
imports.gi.versions.Gdk = '4.0';
imports.gi.versions.Gtk = '4.0';
-const { Gst } = imports.gi;
+const { Gio, Gst } = imports.gi;
+
Gst.init(null);
+Gio._promisify(Gio._LocalFilePrototype, 'replace_contents_bytes_async', 'replace_contents_finish');
const { App } = imports.src.app;
diff --git a/src/misc.js b/src/misc.js
index 695b4380..6e5da48a 100644
--- a/src/misc.js
+++ b/src/misc.js
@@ -1,4 +1,4 @@
-const { Gio, GstAudio, Gdk, Gtk } = imports.gi;
+const { Gio, Gdk, Gtk } = imports.gi;
const Debug = imports.src.debug;
const { debug } = Debug;
@@ -95,3 +95,12 @@ function getFormattedTime(time, showHours)
const parsed = (hours) ? `${hours}:` : '';
return parsed + `${minutes}:${seconds}`;
}
+
+function encodeHTML(text)
+{
+ return text.replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/src/player.js b/src/player.js
index 6e9dc054..56c12acd 100644
--- a/src/player.js
+++ b/src/player.js
@@ -1,7 +1,9 @@
const { Gdk, Gio, GObject, Gst, GstClapper, Gtk } = imports.gi;
const ByteArray = imports.byteArray;
+const Dash = imports.src.dash;
const Debug = imports.src.debug;
const Misc = imports.src.misc;
+const YouTube = imports.src.youtube;
const { PlayerBase } = imports.src.playerBase;
const { debug } = Debug;
@@ -17,6 +19,7 @@ class ClapperPlayer extends PlayerBase
this.seek_done = true;
this.doneStartup = false;
this.needsFastSeekRestore = false;
+ this.customVideoTitle = null;
this.playOnFullscreen = false;
this.quitOnStop = false;
@@ -40,8 +43,20 @@ class ClapperPlayer extends PlayerBase
set_uri(uri)
{
- if(Gst.Uri.get_protocol(uri) !== 'file')
- return super.set_uri(uri);
+ this.customVideoTitle = null;
+
+ if(Gst.Uri.get_protocol(uri) !== 'file') {
+ const [isYouTubeUri, videoId] = YouTube.checkYouTubeUri(uri);
+
+ if(!isYouTubeUri)
+ return super.set_uri(uri);
+
+ this.getYouTubeUriAsync(videoId)
+ .then(ytUri => super.set_uri(ytUri))
+ .catch(debug);
+
+ return;
+ }
let file = Gio.file_new_for_uri(uri);
if(!file.query_exists(null)) {
@@ -52,12 +67,38 @@ class ClapperPlayer extends PlayerBase
return;
}
- if(uri.endsWith('.claps'))
- return this.load_playlist_file(file);
+ if(uri.endsWith('.claps')) {
+ this.load_playlist_file(file);
+
+ return;
+ }
super.set_uri(uri);
}
+ async getYouTubeUriAsync(videoId)
+ {
+ const client = new YouTube.YouTubeClient();
+ const info = await client.getVideoInfoPromise(videoId).catch(debug);
+
+ if(!info)
+ throw new Error('no YouTube video info');
+
+ const dash = Dash.generateDash(info);
+ const videoUri = (dash)
+ ? await Dash.saveDashPromise(dash).catch(debug)
+ : client.getBestCombinedUri(info);
+
+ if(!videoUri)
+ throw new Error('no YouTube video URI');
+
+ this.customVideoTitle = (info.videoDetails && info.videoDetails.title)
+ ? info.videoDetails.title
+ : videoId;
+
+ return videoUri;
+ }
+
load_playlist_file(file)
{
const stream = new Gio.DataInputStream({
diff --git a/src/widget.js b/src/widget.js
index b3252e77..fc852954 100644
--- a/src/widget.js
+++ b/src/widget.js
@@ -318,6 +318,9 @@ class ClapperWidget extends Gtk.Grid
{
let title = mediaInfo.get_title();
+ if(!title)
+ title = this.player.customVideoTitle;
+
if(!title) {
const subtitle = this.player.playlistWidget.getActiveFilename();
diff --git a/src/youtube.js b/src/youtube.js
new file mode 100644
index 00000000..b4d832d9
--- /dev/null
+++ b/src/youtube.js
@@ -0,0 +1,143 @@
+const { GLib, GObject, Gst, Soup } = imports.gi;
+const ByteArray = imports.byteArray;
+const Debug = imports.src.debug;
+
+const { debug } = Debug;
+
+var YouTubeClient = GObject.registerClass(
+class ClapperYouTubeClient extends Soup.Session
+{
+ _init()
+ {
+ super._init({
+ timeout: 5,
+ });
+ }
+
+ 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;
+
+ while(tries--) {
+ debug(`obtaining YouTube video info: ${videoId}`);
+
+ const info = await this._getInfoPromise(url).catch(debug);
+ if(!info) {
+ debug(`failed, remaining tries: ${tries}`);
+ continue;
+ }
+
+ /* Check if video is playable */
+ if(
+ !info.playabilityStatus
+ || !info.playabilityStatus.status === 'OK'
+ )
+ return reject(new Error('video is not playable'));
+
+ /* Check if data contains streaming URIs */
+ if(!info.streamingData)
+ return reject(new Error('video response data is missing URIs'));
+
+ return resolve(info);
+ }
+
+ reject(new Error('could not obtain YouTube video info'));
+ });
+ }
+
+ getBestCombinedUri(info)
+ {
+ if(
+ !info.streamingData.formats
+ || !info.streamingData.formats.length
+ )
+ return null;
+
+ const combinedStream = info.streamingData.formats[
+ info.streamingData.formats.length - 1
+ ];
+
+ if(!combinedStream || !combinedStream.url)
+ return null;
+
+ return combinedStream.url;
+ }
+
+ _getInfoPromise(url)
+ {
+ return new Promise((resolve, reject) => {
+ const message = Soup.Message.new('GET', url);
+ let data = '';
+
+ const chunkSignal = message.connect('got-chunk', (msg, chunk) => {
+ debug(`got chunk of data, length: ${chunk.length}`);
+
+ const chunkData = chunk.get_data();
+ data += (chunkData instanceof Uint8Array)
+ ? ByteArray.toString(chunkData)
+ : chunkData;
+ });
+
+ this.queue_message(message, (session, msg) => {
+ msg.disconnect(chunkSignal);
+
+ debug('got message response');
+
+ if(msg.status_code !== 200)
+ return reject(new Error(`response code: ${msg.status_code}`));
+
+ debug('parsing video info JSON');
+
+ const gstUri = Gst.Uri.from_string('?' + data);
+
+ if(!gstUri)
+ return reject(new Error('could not convert query to URI'));
+
+ const playerResponse = gstUri.get_query_value('player_response');
+
+ if(!playerResponse)
+ return reject(new Error('no player response in query'));
+
+ let info = null;
+
+ try { info = JSON.parse(playerResponse); }
+ catch(err) { debug(err.message) }
+
+ if(!info)
+ return reject(new Error('could not parse video info JSON'));
+
+ debug('successfully parsed video info JSON');
+
+ resolve(info);
+ });
+ });
+ }
+});
+
+function checkYouTubeUri(uri)
+{
+ const gstUri = Gst.Uri.from_string(uri);
+ gstUri.normalize();
+
+ const host = gstUri.get_host();
+
+ let success = true;
+ let videoId = null;
+
+ switch(host) {
+ case 'www.youtube.com':
+ case 'youtube.com':
+ videoId = gstUri.get_query_value('v');
+ break;
+ case 'youtu.be':
+ videoId = gstUri.get_path_segments()[1];
+ break;
+ default:
+ success = false;
+ break;
+ }
+
+ return [success, videoId];
+}