A Better Safari Books Online Experience

Now, my experience with Safari Books Online has been great so far, mainly due to the free access I get thanks to Toronto Public Library. As far as I can see from the feature set, you can create playlists and the dashboard is tailored towards your use of content. This is, of course, a completely different mileage I get from the free service. There is none of that custom profile you can keep and sometimes if there are too many people logged in using the Library account, then you are locked out.

However, none of this is what I’d like to talk about in this post. I want to talk about a different kind of user experience and maybe offer a solution. At least, it’s what I’ve needed all this time without realizing I’ve had the power to do something about it. So, shall we?

Definition of the Problem

A preview of user experience

First of all, let’s define what constitutes a less than ideal user experience. The left and right arrow to jump between videos is nice but what if you want to skip a bunch of videos? Then, you scroll down to see the video titles in the table of contents. Click one, and? You just hang there while listening to the video’s audio kick in. Ok, let’s scroll up to get an idea of what this episode is talking about. Nope, skip more. Scroll down, hit, wait, scroll up. Enough!

Solution: Hack It!

I’m using the terms hacking in the way it’s defined at this source. How can we make the user experience better? I’d like to see the table of contents visible next to the video so that while keeping an eye on the video, I can still scroll and check out the titles. At the very least, if I have to keep the table of contents where it is, the click event should take me to the top of the screen so I can peruse the video. However, this is still awkward because I’d have to scroll down again to find the last title. On a page with so many items, unlike the example picture I posted, the practice quickly gets tedious. So, we should aim for keeping the table of contents (TOC from this point on) beside the video.

The arrow buttons are taking too much space horizontally. We should move them somewhere out of the way but still keep them accessible. Why not assign some keyboard shortcuts to trigger them?

The video seems to be responding to keyboard events such as pressing arrow and space keys to forward, rewind and play/pause the stream. Can we mimic or even enhance this behaviour even when the video is not in focus? Meaning, when a page element is clicked, the video will lose focus, therefore none of those events will fire and command the video as it was described.

Here is a quick look at how the final work looks like. You’ll notice that I’ve removed the Overview and Extras tabs since they are not essential to the way I consume the content. If I need to access the exercise files link in the Extras tab, I’ll do so either early on or later by refreshing the page and returning the interface to its original format. At that point, I can quickly switch it to the way I want it: less clutter. Similarly, Overview tab is completely unnecessary once I read it and understand the nature of the content. So, bye bye!

Additionally, there is some work done to keep the general style, especially with the borders. You’ll see that it’s a complete illusion that gives you the idea that these 2 major components, video and TOC area, are part of the same parent. Well, they actually are but not from a styling perspective so I had to keep my changes to a minimum to achieve the desired effect. Without further ado…

Implementation

First, a quick code dump then I’ll explain in detail how I achieved what I have described so far.

var kdp;
var scrubInSeconds = 10;
var altModifier = 2;
var shiftModifier = 3;
var paused = false;
var tocWrapperScrollTop = 0;
var tocWrapperScrollEventAttached = false;

function rearrange(event, jqXHR, ajaxOptions) {
  if (ajaxOptions && /ajaxtoc/.test(ajaxOptions.url) === false) return;

  Array.from(document.querySelector("#pagewrapid").childNodes)
    .filter(
      child =>
      child.nodeName !== "INPUT" && child.id !== "metadata_flashactive" && child.className !== "catalog_container"
    )
    .forEach(selector => selector.remove());

  pagewrapid = document.querySelector("#pagewrapid");
  toc = document.querySelector(".videotoc");
  tocWrapper = document.createElement("div");
  tocWrapper.id = "tocWrapper";
  toc.parentNode.appendChild(tocWrapper);

  if (tocWrapperScrollEventAttached === false) attachTocWrapperScrollEvent();

  tocWrapper.appendChild(toc);
  tocWrapper.setAttribute("style", "overflow:auto;height:550px;");
  tocWrapper.scrollTop = tocWrapperScrollTop;
  pagewrapid.setAttribute(
    "style",
    "width:1570px;display:flex;justify-content: space-around;border: 1px solid #7990A2;border-radius:5px"
  );
  document.querySelector("#metadata_flashactive").setAttribute("style", "align-self:flex-start;");
  document.querySelector(".shadowBox1").setAttribute("style", "border-radius:0;padding:0;margin:0;border:0");
  document
    .querySelector(".catalog_container")
    .setAttribute("style", "height:606px;margin:0;padding:25px;border:0;border-radius:0px");
  document.getElementById("bcv_next").setAttribute("style", "top:-12%");
  document.getElementById("bcv_previous").setAttribute("style", "top:-12%; right:20px; left:inherit");
}

function attachTocWrapperScrollEvent() {
  tocWrapperScrollEventAttached = true;
  tocWrapper.onscroll = event => (tocWrapperScrollTop = event.target.scrollTop);
}

function keyEvents(event) {
  switch (event.code) {
    case "Period":
      document.getElementById("bcv_next").click();
      break;
    case "Comma":
      document.getElementById("bcv_previous").click();
      break;
    case "ArrowRight":
      adjustTime("forward", event.altKey, event.shiftKey);
      break;
    case "ArrowLeft":
      adjustTime("backward", event.altKey, event.shiftKey);
      break;
    case "Space":
      kdp.sendNotification(paused ? "doPlay" : "doPause");
      paused = !paused;
      break;
    default:
      break;
  }
}

function adjustTime(transition, altKey, shiftKey) {
  var currentTime = kdp.evaluate("{video.player.currentTime}");
  var timeAdjustment = scrubInSeconds * (altKey ? altModifier : 1) * (shiftKey ? shiftModifier : 1);

  if (transition === "forward" && currentTime + timeAdjustment < kdp.evaluate("{duration}")) {
    kdp.sendNotification("doSeek", currentTime + timeAdjustment);
  }
  if (transition === "backward" && currentTime - timeAdjustment > 0) {
    kdp.sendNotification("doSeek", currentTime - timeAdjustment);
  }
}

kWidget.addReadyCallback(function (playerId) {
  kdp = document.getElementById(playerId);
});

document.addEventListener("keydown", keyEvents);
$(document).ajaxSuccess(rearrange);
$(document).ajaxSend(() => (tocWrapperScrollTop = document.getElementById("tocWrapper").scrollTop));

rearrange();

Flags and Variables

var kdp;
var scrubInSeconds = 10;
var altModifier = 2;
var shiftModifier = 3;
var paused = false;
var tocWrapperScrollTop = 0;
var tocWrapperScrollEventAttached = false;

kdp is a reference to the media player the website is using. It’ll be essential later when we implement keyboard shortcuts. The following three variables are also related to the player, which defines how many seconds we want to travel in the stream, also the modifier rate if the alt and/or shift keys are pressed. paused flag is trivial as it depends on space key toggle.

The last two statements are much more interesting: tocWrapperScrollTop and tocWrapperScrollEventAttached. The former holds the value of the last scroll when you scroll down in the TOC area.  The latter is a one-off flag that makes sure that the event handler for such scrolling event is registered once. This is a cheap and dirty way of mimicking jQuery’s one method.

Some Prep Work

Before I present the heavy duty parts of the code, I would like to get event listeners and some startup stuff out of the way. Lines 83-89 are doing exactly that except line 89 where we have a bit of work done for simplicity sake. I just didn’t want to write another method for a simple task and instead depended on an anonymous event handler.

kWidget.addReadyCallback(function (playerId) {
  kdp = document.getElementById(playerId);
});

document.addEventListener("keydown", keyEvents);
$(document).ajaxSuccess(rearrange);
$(document).ajaxSend(() => (tocWrapperScrollTop = document.getElementById("tocWrapper").scrollTop));

Line 88-89 are also the only two places where I used jQuery. I’ve tried really hard to keep this whole exercise in the realm of native Javascript. Therefore, it would have made more sense to use XMLHttpRequest. However, as far as I can tell, there is no static method that captures any Ajax calls the way jQuery Ajax methods do. Let me explain this more clearly. First of all, the way XMLHttpRequest works, you need to create an instance of it and listen to its onload event so you can figure out at which ready state the call is. Problem is we can neither depend on document events nor our own XMLHttpRequest instance because we have no control over the first one due to the fact that we are doing this whole work after the page is rendered, and the latter due to the fact that the instance is created by the page code. We could dig up the instance they use to make the calls but that’s a deep rabbit hole I didn’t want to dive in.

There is another way to intercept or piggyback on Ajax calls. You can alter XMLHttpRequest.prototype so send and open methods are alerting you the way jQuery AjaxSend and AjaxSuccess work. However, doing this left a bad taste in my mouth so why bother?

When all is ready, we can initiate our little scheme by calling rearrange in line 91. You may have noticed that rearrange is also a callback for ajaxSuccess, meaning when the Ajax call is finished and the result is returned to the page, we would like to rearrange things again. Now, we get to the part why I needed all that investigation regarding XMLHttpRequest. Every time you click a title in the TOC, a call is made to pull the necessary strings to bring the correct video stream. However, the real reason why we need is more simple and even ridiculous. There is, in fact, multiple Ajax calls initialized by clicking a simple link. For some weird design decision, the coders of the site decided to refresh the whole TOC. Why?

Whatever that reason is, all the hard work we did in rearrange function initially by manually calling it has to be done again. Also, it’ll be more clear when we examine rearrange function but we would like to keep the last scroll position to restore its position since the whole table will be rerendered and therefore reset.

The Crux of the Matter

I start rearrange function by filtering the necessary Ajax call. Remember, the site is making multiple calls, we don’t want to run this function unnecessarily. Performance is not an issue since we are not running this for millions of users out there. Nevertheless, let’s redeem ourselves after the XMLHttpCallback fiasco. A basic check on whether the options are included and they carry a certain pattern pertaining to TOC is enough to halt the function. The rest of the function is mostly layout work.

Array.from(document.querySelector("#pagewrapid").childNodes)
    .filter(
      child =>
      child.nodeName !== "INPUT" && child.id !== "metadata_flashactive" && child.className !== "catalog_container"
    )
    .forEach(selector => selector.remove());

document.querySelector is a good alternative to jQuery selectors. However, you have to convert the results to an Array and that’s what Array.from does. Otherwise, the result set will be a NodeList which won’t allow you to use methods like filter, map or reduce. That being said, here is a practical explanation of why I needed lines 12-17. Line 30 sets the display property of pagewrapid, which holds the video and TOC area, as flex. At this point, the spacing between these 2 areas become important to get the style applied for the desired effect. However, there are some invisible elements lurking in pagewrapid. This adds unnecessary amount of space so let’s get rid of those creeping elements by filtering everything but what we need. I decided to keep the hidden input fields since they were not harmful to the layout and they probably play a role in the way their code does its job for the site functionality. metadata_flashactive and catalog_container are what we are going to stylize soon.

  toc = document.querySelector(".videotoc");
  tocWrapper = document.createElement("div");
  tocWrapper.id = "tocWrapper";
  toc.parentNode.appendChild(tocWrapper);

  if (tocWrapperScrollEventAttached === false) attachTocWrapperScrollEvent();

  tocWrapper.appendChild(toc);

The rest of the function mainly takes care of styling and the registration of one event handler for keeping track of scroll position when we interact with the TOC. An important detail here is Line 23. Due to the way the DOM is structured, it was not possible to add a scroll bar to the TOC so I created an extra div in its parent container and pushed the TOC into it so I could enable scrolling. I also added some padding to push it from the border so the rounded corners wouldn’t cut off the top section of the scroll bar because it was looking jagged. If I’m going to all this trouble, I may as well make it look nice.

Extras

attachTocWrapperScrollEvent is nothing but a clean way of implementing that one-off event handler for scrolling. Since rearrange function will be called multiple times, you wouldn’t want to attach multiple events to TOC. Once the scroll position is set in the event handle, the position will be recalled and applied in line 29.

...
  tocWrapper.scrollTop = tocWrapperScrollTop;
  pagewrapid.setAttribute(
    "style",
    "width:1570px;display:flex;justify-content: space-around;border: 1px solid #7990A2;border-radius:5px"
  );
  document.querySelector("#metadata_flashactive").setAttribute("style", "align-self:flex-start;");
  document.querySelector(".shadowBox1").setAttribute("style", "border-radius:0;padding:0;margin:0;border:0");
  document
    .querySelector(".catalog_container")
    .setAttribute("style", "height:606px;margin:0;padding:25px;border:0;border-radius:0px");
  document.getElementById("bcv_next").setAttribute("style", "top:-12%");
  document.getElementById("bcv_previous").setAttribute("style", "top:-12%; right:20px; left:inherit");
}

function attachTocWrapperScrollEvent() {
  tocWrapperScrollEventAttached = true;
  tocWrapper.onscroll = event => (tocWrapperScrollTop = event.target.scrollTop);
}

You may recall that we bound keydown event handler for document in line 87. keyEvents function is simply distributing the responsibility between the arrow buttons and the video interaction. If the user wants to switch to the next or previous video, they should simply press period or comma key respectively. Those keys actually look more like the arrow keys you see on the screen so I thought it might be nice to have some similarity and I had other plans for the standard arrow keys as you can see in line 56 and 59.

...
case "ArrowRight":
   adjustTime("forward", event.altKey, event.shiftKey);
   break;
case "ArrowLeft":
   adjustTime("backward", event.altKey, event.shiftKey);
   break;

This is where most of the variables we defined at the beginning of our code will come into play. Depending on whether I pressed alt and/or shift key, I wanted to scrub the video forward or backward by some amount. Reading Kaltura media player support page helped a lot to figure out the right commands. Keep in mind, because we are interacting with the Kaltura media player thanks to kdp variable, the video doesn’t have to be actively in focus. The same goes for space key when you want to stop/resume the video.

Wrapping Up

There you have it. Now, what do you do with this code? Good question. You can open the dev tools in Chrome and slap this code in the console. It’ll run and you’ll have a better experience. However, it makes more sense if you kept this code somewhere handy so you wouldn’t have to copy/paste again.

So, I added this code to my bookmark panel. Just right click the bookmark area and choose “Add Page”. In the panel that shows up, type the name of the bookmark you want to make. It’ll by default show the title of the page you are visiting so I named mine to “Fix Safari Layout”. For the URL section, first type javascript: then copy and paste the full code there. It doesn’t have to look beautiful and such and, in fact, Chrome will remove all the whitespace so if you want to edit it in the future, it makes sense to keep the original somewhere else.

Once you visit Safari Books, you can hit that bookmark and enjoy a better experience. I know I will. Feel free to change the style values in rearrange to your hearts’ content. You can also find the code at my Github gist which has been updated since I published this article. The current version has volume and speed shortcuts as well as some other enhancements but the spirit is the same. It uses Promise unlike other modifiers which might be a topic for a future article.


Recent posts