WSSCode Blog

Youtube Looper is back!

February 03, 2021

Today I like to talk about a pet project I have, the Youtube Looper:

Youtube Looper

The video you saw in the image there is from a channel called CifraClub. They provide high-quality music lessons in their channel.

If you play some instrument, you know how useful it can be to practice specific sections of a song over and over again.

On top of many video lessons available on Youtube, it also has one of the most significant music collections on the internet. This is where Youtube Looper fits. It extends Youtube to allow you to create loops in video sections.

In my case, I use it to practice music. I heard from friends in Medicine that they would love to use it to reinforce some learning. If you have any application on looping video sections, this extension may help you!

You can find/download the extension at https://chrome.google.com/webstore/detail/youtube-looper/bidjeabmcpopfddfcnpniceojmkklcje

For the rest of this article, I’ll talk about some interesting point of its development, and you find to learn a few React and Chrome extensions tips and tricks on the way.

Making Youtube Looper

Youtube Looper is a simple extension. All it uses from the Chrome infra is the ability to inject a content script on a page.

Here is the manifest.json that describes the extension for Chromium-based browsers:

{
  "name": "Youtube Looper",
  "version": "2021.02.02",
  "description": "Custom loops extension for Youtube videos",
  "manifest_version": 2,
  "content_scripts": [
    {
      "matches": ["*://*.youtube.com/*"],
      "js": [
        "js/youtube/main.js"
      ],
      "all_frames": true
    }
  ],
  "icons": {
    "16": "icon16.png",
    "48": "icon48.png",
    "128": "icon128.png"
  },
  "web_accessible_resources": [
    "js/*"
  ]
}

A content script is some Javascript code that Chrome injects in the page when it matches the pattern.

To use CLJS as a content script, we can use the standard browser target with Shadow CLJS:

{:deps
 {:aliases [:dev]}

 :builds
 {:app
  {:target     :browser
   :output-dir "shells/chrome/js/youtube"
   :asset-path "/js/youtube"
   :modules    {:main {:entries [com.wsscode.media-looper.chrome.content-script.main]}}}}}

Inject UI extensions

This extension adds elements in three different places, noted in the following picture:

Injections

To make this, I decided to use a React component at the root to manage the insertions.

(h/defnc YoutubeLooper []
  (let [popup   (hooks/use-memo [] (create-popup-container))
        control (hooks/use-memo [] (create-looper-button popup))]
    (hooks/use-effect []
      (gdom/insertChildAt (video-player-container-node) popup)
      #(gdom/removeNode popup))

    (hooks/use-effect []
      (add-control control)
      #(gdom/removeNode control))

    (create-portal #js [(h/$ LooperControl)] popup)))

The main thing to note is the use of the effect hook to insert the nodes when the component starts, and to clean up when we unmount (this will be useful later).

In the end, I used a portal to render the main component at one of the custom nodes.

This is enough to add the new control and make it show and hide the LooperControl.

From that component down is standard React.

React State Hook

To interface with React, I’m using the Helix library.

Helix is a thin wrapper around React, and it’s been my tool of choice for simple UI’s.

One of the things we can do with hooks is to control the state in the application. I like to share with you some thoughts around the state hook and CLJS.

Get/set tuple

In the Javascript world, when you create a state hook, it returns a tuple containing the value of that state, and a function to update that state.

Helix follows the same pattern, and the resulting code looks like this:

; a counter button made using a state hook
(let [[count set-count!] (hooks/use-state 0)]
  (dom/button {:onClick #(set-count! (inc count))} count))

It’s simple, but it bothers me that this pattern keeps the getter and setter too separated. This means if you want to pass down both the getter and setter, you need to send two things.

A remedy is to threat the tuple as a thing, so you can pass the tuple itself down, for example:

(let [[count set-count! :as count-state] (hooks/use-state 0)]
  ; some input that will read and be able to update the contents of the counter
  (my-input {:state count-state})
  (dom/button {:onClick #(set-count! (inc count))} count))

It works, but now we have three things… It feels like it can be better. Let’s explore other options.

Reference pattern

The next obvious thing that comes to my mind is to use a custom reference type to handle that state construct. We are used to doing that with atoms in Clojure in general. This is also what Reagent does, but since Reagent came before React Hooks. It uses something different to manage the state underneath.

Here is how we can make something like it, based on hooks underneath:

(deftype ReactAtomState [value set-value!]
  IDeref
  (-deref [o] value)

  IReset
  (-reset! [o new-value] (doto new-value set-value!))

  ISwap
  (-swap! [a f] (set-value! (f value)))
  (-swap! [a f x] (set-value! (f value x)))
  (-swap! [a f x y] (set-value! (f value x y)))
  (-swap! [a f x y more] (set-value! (apply f value x y more))))

(defn use-state-atom [initial-value]
  (let [[value set-value!] (hooks/use-state initial-value)]
    (->ReactAtomState value set-value!)))

; our counter example using the state atom
(let [count* (use-state-atom 0)]
  (dom/button {:onClick #(swap! count* inc)} @count*))

The goods of this approach:

  • Uses familiar reference interfaces, with the ability to deref, reset and swap.
  • Keeps result in a single “thing”

The bad of this approach is that the way the data changes is different from how a standard ref does it, here is an example to illustrate:

(let [state* (use-state-atom 10)]
  (dom/button {:onClick
               (fn []
                 (swap! state* inc)
                 ; this will log 10, not 11
                 (js/console.log @state*))}
    "Click me"))

The reason for this result is the way react updates state. It requires a new render pass to happen to see the updated value. In other words, it doesn’t change the ref value immediately. This can confuse developers because of the long built expectations about how reference type works.

A new way

To use state atoms or not, I keep going over this every time I’m writing some new UI with Helix. This last time I had a new idea, an attempt to keep the state in a single thing, but without having to mess with reference expectations.

Now I’ll present to you the function state pattern:

(deftype ReactFnState [value set-value!]
  IDeref
  (-deref [o] value)

  IFn
  (-invoke [o x] (set-value! x)))

(defn use-fstate [initial-value]
  (let [[value set-value!] (hooks/use-state initial-value)]
    (->ReactFnState value set-value!)))

; counter using function state
(let [count! (use-fstate 0)]
  (dom/button {:onClick #(count! (inc @count!))} @count!))

This is the approach I’m trying now, still figuring out how I feel about it, but it’s promising. It does the job of keeping getter and setter in a single thing, while the pattern is different enough from reference types to avoid wrong expectations.

Persistent state

The Youtube Looper extension remembers the loops you create for each video. I used LocalStorage to make this persistence. Here is a thin layer to make it easier to handle LocalStorage with Clojurescript:

(ns com.wsscode.media-looper.local-storage
  (:refer-clojure :exclude [get set])
  (:require [cljs.reader :refer [read-string]]))

(defn safe-read [s]
  (try
    (read-string s)
    (catch :default _ nil)))

(defn get
  ([k]
   (safe-read (js/localStorage.getItem (pr-str k))))
  ([k initial]
   (or (safe-read (js/localStorage.getItem (pr-str k)))
       initial)))

(defn set [k v]
  (js/localStorage.setItem (pr-str k) (pr-str v)))

With these helpers, you can freely use all standard Clojurescript types in both keys and values for storage.

The fun thing is, because of our abstraction around state, we can as easy make a state hook that automatically loads and saves from local storage:

(defn use-persistent-state [store-key initial-value]
  (let [[value set-value!] (hooks/use-state (local-storage/get store-key initial-value))
        set-persistent! (fn [x]
                          (local-storage/set store-key x)
                          (doto x set-value!))]
    (->ReactFnState value set-persistent!)))

; persistent counter, only changed the instantiation
(let [count! (use-persistent-state ::counter 0)]
  (dom/button {:onClick #(count! (inc @count!))} @count!))

This is neat! I use that to manage the list of loops. For the key, I use something based on the video id (which I figure by reading the location.href).

Handle page changes

Youtube is a Single Page Application. This means the extension may load on any page of it, and the page will change. This means I have to handle the case of starting on a page that’s not a video. Also, need to properly switch the data if the user navigates from one video to another.

I first tried to hook in the history API to get page changes. I couldn’t make it work.

So I moved to a more brute force idea: I check the URL every 500ms, if it changes, page changed:

(defn listen-url-changes [cb]
  (let [url*  (atom nil)
        timer (js/setInterval
                (fn []
                  (let [new-url js/location.href]
                    (when (not= new-url @url*)
                      (cb new-url)
                      (reset! url* new-url))))
                500)]
    #(js/clearInterval timer)))

Then, the idea is to on every page change, clean up the previous React stack (if there is any), and start it over again.

(defn integrate-looper []
  (let [app-node (wdom/el "div" {:class "media-looper-container"})]
    (gdom/appendChild js/document.body app-node)
    (listen-url-changes
      (fn [e]
        (unmount app-node)

        (when (video-id)
          (mount (h/$ YoutubeLooper) app-node))))))

The (video-id) fn return nil if we are not in a video page.

Full sources

That’s what I wanted to chat about on this project. If you still curious about other parts of it, please let me know, and I may post more details.

You can find the full sources for Media Looper at https://github.com/wilkerlucio/media-looper


Follow closer

If you like to know in more details about my projects check my open Roam database where you can see development details almost daily.

Support my work

I'm currently an independent developer and I spent quite a lot of my personal time doing open-source work. If my work is valuable for you or your company, please consider supporting my work though Patreon, this way you can help me have more available time to keep doing this work. Thanks!

Current supporters

And here I like to give a thanks to my current supporters:

Albrecht Schmidt
Austin Finlinson
Daemian Mack
Kendall Buchanan
Mark Wardle
Michael Glaesemann
Oleg, Iar, Anton
West