Rasmus​.krats​.se

Tracker-free youtube embedding

Posted 2022-04-03 18:04. Tagged , , , , , .

Sometimes I want to embed a video on my site. Most of the videos I want to embed are on youtube. Klicking “share” on a video and choosing embed, I get a bunch of html code I can copy into a post. Something like this, for example:

<iframe width="560" height="315" src="https://www.youtube.com/embed/3St1CoH1rKU"
  title="YouTube video player" frameborder="0"
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
  allowfullscreen>
</iframe>

That’s very easy. The downside is that youtube gets a request for the iframe (and a bunch of stuff it loads from there) every time someone looks at my page, which makes it possible for them to track you, my reader, across my site and other sites that includes videos.

So, what can I do? I can replace the iframe with a “play” button, and make that button replace itself with the iframe when pressed. Like this:

<div id="xyzzy">
  <button onclick='document.getElementById("xyzzy").innerHTML="<iframe...>"'>Play</button>
</div>

Now, the browser will not talk to youtube unless the reader presses the Play button. I can also make the div contain the title of the video, a notice that pressing play will send data to youtube, and even style it with a preview of the video as a background image. Read on for all the details of how to do that!

I don’t want to put all that (and more) in the markdown source for every blog post where I embed a video. I prefer something like this:

```!embed
https://youtu.be/5ANSNVo05lE
```

Since I have a program that reads my markdown and converts it to html for the web server, and I already have the code block syntax extended for leaflet maps and qr-codes, adding a syntax for embedding stuff is no big deal.

One thing youtube does nicely is that they support the oembed api. So I can query https://www.youtube.com/oembed?url=https://youtu.be/eqWUJ0zAPqc&format=json for the video I want to embed and get something like this:

{
  "title": "All Of Me (Allison Young Cover ft. Luca Pino + Kyle Morgan)",
  "author_name": "Allison Young",
  "author_url": "https://www.youtube.com/channel/UCyVttxb4-zHo62CB4lwvMXg",
  "type": "video",
  "height": 113,
  "width": 200,
  "version": "1.0",
  "provider_name": "YouTube",
  "provider_url": "https://www.youtube.com/",
  "thumbnail_height": 360,
  "thumbnail_width": 480,
  "thumbnail_url": "https://i.ytimg.com/vi/eqWUJ0zAPqc/hqdefault.jpg",
  "html": "<iframe width=\"200\" height=\"113\" src=\"https://www.youtube.com/embed/eqWUJ0zAPqc?feature=oembed\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe>"
}

This gives me not only the html code to actually embed the video, but also the title and a thumbnail image that I can use as a placeholder, and the width and height of the video so I can get the proper aspect ratio for it.

Now, if I just put that thumbnail_url in an img tag (or a background-image css property), that kind of defeats the purpose of not accessing youtube unless the reader presses play. But I can fetch the image and then serve it from my own server.

So now I have a nice looking preview, and until the reader presses play, everything is loaded from my site without tracking. When the user does press play, the browser replaces my preview with an embedded video … that has a play button to start it. That won’t do, the reader already pressed play! Luckily, I can fix that with a little search-and-replace in the iframe html code. I need to add an autoplay=1 query parameter to the src attribute. Luckily, the oembed reponse gives me a feature=oembed query argument that’s not really needed, so I can just search and replace that.

Rust code

The first thing I need is a struct to get the information I want from the oembed api:

/// The interesting parts of an oembed response.
#[derive(Debug, Deserialize)]
struct EmbedData {
    title: String,
    height: u32,
    width: u32,
    thumbnail_url: String,
    html: String,
}

The Debug derive is just for trying things out, and possibly for error handling. The Deserialize comes from serde and makes it possible to get a json response parsed directly from a reqwest response into the struct, like this:

let embed: EmbedData = client
    .get("https://www.youtube.com/oembed")
    .query(&[("url", data), ("format", "json")])
    .send()?
    .error_for_status()?
    .json()?;

The struct only contains the fields I care about (the extra fields in the json example above are ignored). If youtube would change the api so one of the fields I want is missing (or if I would make a typo in the struct declaration), the question mark after .json() would return an error.

Next, I download an image for the preview. I noticed that the image named in the json data is always named hqdefault.jpg, but some videos have a preview in better resolution named maxresdefault.jpg, so I check for that first, and if that is not found, I try the given url.

let img = embed.thumbnail_url;
let img = fetch_content(&client, &img.replace("hqdefault.jpg", "maxresdefault.jpg"))
    .or_else(|_| fetch_content(&client, &img))?;
let img = loader.store_asset(year, &format!("{id}.jpg"), &img.0, &img.1)?;

Both fetch_content and store_asset are helper functions. The first takes a client and a url, and gives the content-type and body data on success. The second takes a year, a file name, a content-type and some content, stores that in my assets table and returns a local url for it.

After that, all that remains is writing out the html code:

writeln!(
    self.out,
    "<figure id='{id}' class='wrapiframe' style='padding-bottom: {aspect}%'>\
     \n  <figcaption>{title}</figcaption>\
     \n  <img class='ifrprev' src='{img}' width='{width}' height='{height}'>\
     \n  <div class='ifrprev'><button onclick='document.getElementById(\"{id}\")\
     .innerHTML=\"{iframe}\"'>⏵ Play</button>\
     \n  <p>{notice}</p></div>\
     </figure>",
    title = embed.title,
    aspect = 100. * f64::from(embed.height) / f64::from(embed.width),
    height = embed.height,
    width = embed.width,
    iframe = embed
        .html
        .replace("?feature=oembed", "?autoplay=1")
        .replace('\'', "\\\'")
        .replace('"', "\\\""),
)?;

The quote replacement on embed.html is to make it fit in a javascript string. The other replacement tells youtube to autoplay the video. Normally, that is a very bad idea, but in this case, the youtube code will only execute after the reader presses play, so the autoplay is really a regular play.

Markup and styling

The generated markup (before pressing play) looks like the following. I think a figure is probably a semantically apropriate element, both for the preview and for wrapping the iframe when video is loaded.

<figure id='' class='wrapiframe' style='padding-bottom: 55.25%'>\
   <figcaption></figcaption>
   <img class='ifrprev' src='' width='' height=''>
   <div class='ifrprev'>
     <button onclick=''>⏵ Play</button>
     <p></p>
   </div>
</figure>

The style attribute may feel a bit off, but the aspect ratio is different for each video, so it needs to be in the markup, and I see no real benefit in putting it in a data-aspect attribute and having javascript make a style from it. The width and height attributes on the img tag is not the real width and height of the preview image, but rather the width and height of the video in lowest-available resolution. I don’t think that is a problem either.

As for styling, there is quite a lot of it, but the important parts are as follows:

figure.wrapiframe {
    position: relative;
    width: -moz-available;
    height: 0;
    padding-bottom: 56.25%; // Default, to override per video

    figcaption {
        background: $solid_bg;
        top: 2%;
        position: absolute;
        z-index: 1;
    }
    iframe, .ifrprev {
        position: absolute;
        left: 0; right: 0; top: 0; bottom: 0;
        width: 100%; height: 100%;
    }
    div.ifrprev {
        display: flex;
        flex-flow: column;
        p {
            background: $solid_bg;
        }
    }
}

The wrapper is a positioning context (by having position: relative), on which the figcaption and the div is absolutley positioned. The wrapper has zero height, but a padding-bottom that is a percentage. Since that percentage is evaluated as percent of the box width, that is a good way to specify aspect ratio. The image covers the entire figure, and the other stuff is placed above it (by z-index for the caption and by just appearing later in the markup for the rest).

Example

Do you want to hear some music? If you don’t press play, you can read this post without youtube having any possibility of tracking you. If you do press play, youtube can track you, but at least then you get to hear a nice tune by Väsen.

Feel free to inspect the html and css before (and after) pressing play.

Väsen - Eklunda Polska #3

Klicking play embedds a youtube video. That makes it possible for youtube to track you.

Comments

Write a comment

Basic markdown is accepted.

Your name (or pseudonym).

Not published, except as gravatar.

Your presentation / homepage (if any).