Video seeking and screenshotting for non-youtube videos

The youtube-timestamp macro is truly great, but it only works for youtube. What if you have a bunch of videos for the classes that you want to have timestamped and annotated? Fear not, there’s custom.js for that :smile:

First, you need to define a couple new macroses in your config.edn:

 :macros {"nasvideo" "[:video.nasvideo {:src \"$1\" :controls true}]"
          "nastimestamp" "[:a.svg-small.youtube-timestamp.nastimestamp
                           {:data-timestamp \"$1\"}
                           [:svg.h-5.w-5 {:fill \"currentColor\" :viewBox \"0 0 20 20\"}
                            [:path {:clip-rule \"evenodd\"
                                    :d \"M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z\"
                                    :fill-rule \"evenodd\"}]]
                           \"$1\"]"
          "nasvideoscreen" "[:div.nasvideoscreen {:data-timestamp \"$1\"}]"}

{{nasvideo http://whatever/link/to/video.mp4}} will be how you inject videos into a block. For any nested block you can use {{nastimestamp xx:yy}} where xx:yy is minutes and seconds to add the timestamp link, or {{nasvideoscreen xx:yy}} to insert a screenshot of the video at the given timestamp. Note that for the latter, the http server you got videos on better support ranges, or logseq will have to download the whole video.

Now, unfortunately you can’t make the magic in config alone (as you can’t inject on-click functions inside the macros). So you’ll want to add this bit of js into your custom.js:

// wrap everything in IIFE. It's basically a safeguard against whatever else
// you might have in your custom.js already
(function () {
  const initialGraphUrl = logseq.api.get_current_graph().url;

  const targetElement = document.documentElement;
  
  // returns a video tag with the class nasvideo 9as injected by
  // the {{nasvideo}} macro that shares the same parent block with the link.
  const findVideoElement = (el) => {
    const closestBlock = el.closest('[blockid]');
    if (!closestBlock) {
      return undefined;
    }
    const closestVideo = closestBlock.querySelector('video.nasvideo');
    if (!closestVideo) {
      return findVideoElement(closestBlock.parentElement);
    }
    return closestVideo;
  };

  // parses the MM:SS timestamp into seconds.
  const parseTimestamp = (el) => {
    const ts = el.dataset.timestamp;
    const [min, sec] = ts.split(':')
    const seconds = (+min) * 60 + (+sec);
    return seconds;
  };

  // When the timestamp link is clicked, find the nearby {{nasvideo}}, seek
  // to the expected timestamp, and play.
  const onClickHandler = (e) => {
    const video = findVideoElement(e.target);
    if (!video) {
      return;
    }
    video.currentTime = parseTimestamp(e.target);
    video.play();
  };

  // For any div.nasvideoscreen found, find the parent video and create a new
  // video element with the same source and proper timestamp. We do not want to
  // modify the video elements created in the macro, as that makes logseq sad.
  const patchVideoScreen = (el) => {
    if (el.hasChildNodes()) {
      return;
    }
    const video = findVideoElement(el);
    if (!video) {
      el.textContent = 'ERR: no video';
    }
    const screenVideo = document.createElement('video');
    screenVideo.src = video.src;
    screenVideo.currentTime = parseTimestamp(el);
    el.appendChild(screenVideo);
  };

  const observer = new MutationObserver((mutationsList, observer) => {
    // a safety check: stop observing if we switched to a different graph than
    // the one we started in.
    const currentGraphUrl = logseq.api.get_current_graph().url;
    if (currentGraphUrl !== initialGraphUrl) {
      console.warn(`stuck in another graph, unsubscribe!`);
      observer.disconnect();
      return;
    }

    // hook up the observers for DOM changes. Trigger on links and
    // nasvideoscreen divs.
    mutationsList.forEach(mutation => {
      if (mutation.type === 'childList') {
        mutation.addedNodes?.forEach((n) => {
          if (n.nodeName == 'A' && n.classList.contains('nastimestamp')) {
            console.log('patching timestamp', n);
            n.onclick = onClickHandler;
            return;
          }
          if (n.nodeName == 'DIV' && n.className == 'nasvideoscreen') {
            console.log('patching videoscreen', n);
            patchVideoScreen(n);
            return;
          }
          n.querySelectorAll('a.nastimestamp').forEach((el) => {
            console.log('patching timestamp in QSA', el);
            el.onclick = onClickHandler;
          });
          n.querySelectorAll('div.nasvideoscreen').forEach((el) => {
            console.warn('patching videoscreen in QSA', el);
            patchVideoScreen(el);
          });
        });
      }
    });
  });

  // Start observing for changes and do the initial run. It's safe to modify the
  // same element twice.
  observer.observe(targetElement, { subtree: true, childList: true });
  console.warn('observing for timestamps')
  document.querySelectorAll('a.nastimestamp').forEach((n) => {
    console.log('patching initial timestamp', n);
    n.onclick = onClickHandler;
  });
  document.querySelectorAll('div.nasvideoscreen').forEach((n) => {
    console.log('patching initial videoscreens', n);
    patchVideoScreen(n);
  });
})();