Developing on Staxmanade

How to Base64 and save a binary audio file to local storage and play it back in the browser

(Comments)

I wanted to see if it was possible to save a small audio file into localStorage, read it back out and play the file. In this post I'll show you short example on how to download an audio file, save it to localStorage, read it back and set it up for playback.

Disclaimers

works on my machine

  • This was tested in IE 10 (Win 8), Chrome 46 (Mac), and Firefox 41 (Mac); however, some of the api's and techniques used in this demo are not supported in all browsers, such as the FileReader, Blob, Promise, and fetch api's. The Promise and fetch api's can be polyfilled. There may be polyfills for the other api's, but I haven't researched those.
  • This post isn't going to go into much of the "should I do this", as I'm sure you can come up with many reasons why you shouldn't. But I couldn't find any examples that demonstrated these steps in one place. So I prototyped the idea and am putting it here in case I do want to use this in the future sometime (or maybe you do too).
  • My tests in Chrome didn't go great if I tried to re-run the experiment multiple times. Sometimes it would work, other times it seemed to get into a bad state and always raised a MediaError event. Refreshing the page would get it working again.

First we need an audio file

I don't want to point to any specific audio example since I'd feel bad if some poor soul's hosted mp3 file gets hammered (not likely) because of this example. But you just need a link to a simple, short mp3 (or whatever audio type you're trying to test). If you look at the sample below replace <<SampleAudioUrlHere>> with the link to your test audio file.

Won't fit in localStorage?

If you're trying to save an audio file that's too large as a Base64 encoded audio file will be larger than it's original size and we don't get very much space in localStorage then, ya you're using a file that's too large... Get something smaller or don't to this. Just sayin :P

How does it work?

  1. Use fetch api we can easily get at the blob()
  2. Run the Blob through the FileReader
  3. Which also handily turned it into a data url for us
  4. The data url is just a base64 encoded string which is easy to save to localStorage
  5. Read the string back out of localStorage
  6. Set the audio's src attribute to the audio data url
  7. Profit!

While I was prototyping this I was borrowing someone else short mp3 file and to work around CORS (cross origin http request) I used the handy https://crossorigin.me/<<SampleAudioUrlHere>> service. This may be ok to do for a prototype, but you should't typically run your requests through this service. It's insecure and against pretty much all the different web religions.

Show me the code

This was just a quick get-er-done example. Lots of not-great-practices, but it demonstrates the possibility. Enjoy!

<!DOCTYPE html>
<html>

  <head>
    <script>

      // Code goes here
      var audioFileUrl = '<<SampleAudioUrlHere>>';

      window.onload = function() {

        var downloadButton = document.getElementById('download');
        var audioControl = document.getElementById('audio');

        audioControl.onerror = function(){
          console.log(audioControl.error);
        };

        downloadButton.addEventListener('click', function() {

          audioControl.src = null;

          fetch(audioFileUrl)
            .then(function(res) {
              res.blob().then(function(blob) {
                var size = blob.size;
                var type = blob.type;

                var reader = new FileReader();
                reader.addEventListener("loadend", function() {

                  // console.log('reader.result:', reader.result);

                  // 1: play the base64 encoded data directly works
                  // audioControl.src = reader.result;

                  // 2: Serialize the data to localStorage and read it back then play...
                  var base64FileData = reader.result.toString();

                  var mediaFile = {
                    fileUrl: audioFileUrl,
                    size: blob.size,
                    type: blob.type,
                    src: base64FileData
                  };

                  // save the file info to localStorage
                  localStorage.setItem('myTest', JSON.stringify(mediaFile));

                  // read out the file info from localStorage again
                  var reReadItem = JSON.parse(localStorage.getItem('myTest'));

                  audioControl.src = reReadItem.src;

                });

                reader.readAsDataURL(blob);

              });
            });

        });

      };

    </script>
  </head>

  <body>
    <button id="download">Run Example</button>
    <br />
    <audio controls="true" id="audio" src=""></audio>
  </body>

</html>

I hope you found this quick tutorial useful. Would love to hear any feedback or thoughts on the approach.

As earways, Happy Listening!

Integrate WinJS.Navigation with the Browser's History

(Comments)

I've been playing with WinJS a bit lately, specifically the React-WinJS and wanted the native WinJS Navigation to play a little nicer with a web browser. The original/default environment for WinJS app is within a WinRT/Metro application where there is no "url/address" bar to be seen.

My uneducated guess is that the WinJS team decided not to worry about how WinJS.Navigation would integrate with a normal browser's history as there doesn't appear to be native integration or documentation about how to do it so far.

I asked the team if they had plans to work on any integration options, but only asked that last night so don't expect to hear back from over the weekend.

UPDATE: I got tired of updating this blog post with my bug fixes/iterations of the idea - so I've moved it over to GitHub: github.com/staxmanade/WinJSBrowserHistory.

So I spent a moment and prototyped one possible solution which works for this simple test using the browser's history api since I'm not looking to support browsers older than IE 10.

Ideally we could leverage WinJS controls without worrying about how to "integrate" the WinJS.Navigation with anything, but sadly some of the WinJS controls take a dependency on WinJS.Navigation (like the BackButton) so finding a way to play nice with this can be challenging.

If you want to get this prototype running yourself, you can:

  1. save both files below to a folder
  2. start up a simple web server. (I like to use nws)

This prototype is 2 files:

  • index.html <-- basic JSPM bootstrapping and configuration
  • app.jsx <-- my whole navigation app in here...

index.html

Couple mentions on this bootstrapping code:

  1. I set the background style to black (since in app.jsx I'm using the WinJS dark css) - this avoids a flash from white to black when the page loads
  2. This is using SystemJS which makes it really easy to prototype and bootstrap dependencies like WinJS and React. Please don't deploy something like this to production - follow proper JSPM production workflow procedures...
  3. The map section in the System.config defines a pointer to a fork of react-winjs I have that supports React 0.14 (so if you find this in the future and need it, try to use the native react-winjs if they've merged in my pull request instead.)
<!DOCTYPE html>
<html>
  <head>
    <style media="screen"> body{ background-color: black; } </style>
    <script src="https://jspm.io/system@0.18.js"></script>
    <script type="text/javascript">
      System.config({
        transpiler: 'babel',
        packages: {
          './': {
            defaultExtension: false
          }
        },
        map: {
          'react': 'npm:react@0.14',
          'react-winjs': 'github:staxmanade/react-winjs@2.4.0-react-0.14',
        }
      });

      System.import('./app.jsx');

    </script>
  </head>
  <body>
    <div id="main"></div>
  </body>
</html>

app.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import WinJS from 'npm:winjs';
import 'npm:winjs/css/ui-dark.css!';
import { BackButton } from 'react-winjs';


class WinJSBrowserHistory {
    isNavigationBeingHandled;
    isWinJSNavigationBackBeingHandled;
    isNavigationTriggeredByPopStateEvent;

    constructor(onApplyNavigaitonChange) {

        if(typeof onApplyNavigaitonChange !== "function") {
          throw new Error("Expecting first argumet to be a function that can take 2 parametes (location, state) => {}");
        }

        this.onApplyNavigaitonChange = onApplyNavigaitonChange;

        WinJS.Navigation.addEventListener("beforenavigate", this.handleBeforeNavigate.bind(this));
        WinJS.Navigation.addEventListener("navigating", this.handleNavigating.bind(this));
        WinJS.Navigation.addEventListener("navigated", this.handleNavigated.bind(this));

        window.addEventListener('popstate', (eventObject) => {
            console.log('popstate', this.isNavigationBeingHandled, eventObject);

            if(!this.isNavigationBeingHandled && !this.isWinJSNavigationBackBeingHandled) {
              this.handlePopState(eventObject);
            }
            this.isWinJSNavigationBackBeingHandled = false;
        })
    }

    cleanup() {
      WinJS.Navigation.removeEventListener("navigated", this.handleNavigated);
    }


    handlePopState(eventObject) {
      console.log("handlePopState", eventObject, location.hash);

      this.isNavigationTriggeredByPopStateEvent = true;

      WinJS.Navigation.navigate(location.hash, location.state);
    }

    handleBeforeNavigate(eventObject) {
        this.isNavigationBeingHandled = true;
        console.log("handleBeforeNavigate:", eventObject);
    }

    handleNavigating(eventObject) {
        console.log("handleNavigating:", eventObject);
        console.log("handleNavigating delta:", eventObject.detail.delta);

        var location = eventObject.detail.location;
        var state = eventObject.detail.state;
        var delta = eventObject.detail.delta;

        this.onApplyNavigaitonChange(location, state);

        if(delta < 0) {
            this.isWinJSNavigationBackBeingHandled = true;
            window.history.go(delta);
        } else {
            //router.setRoute(location);
            window.history.pushState(state, "", "#" + location);
        }
    }

    handleNavigated(eventObject) {
        this.isNavigationBeingHandled = false;
        this.isNavigationTriggeredByPopStateEvent = false;

        console.log("handleNavigated", eventObject);
    }

}


export default class App extends React.Component {

    constructor(props) {
        super(props);

        this.winJSBrowserHistory = new WinJSBrowserHistory(this.onApplyNavigaitonChange.bind(this));

        this.state = {
            nav: {
                state: WinJS.Navigation.state,
                location: WinJS.Navigation.location
            }
        }
    }

    componentWillMount () {
        console.log("componentWillMount");
        WinJS.Navigation.navigate(this.state.nav.location, this.state.nav.state);
    }

    componentWillUnmount () {
        console.log("componentWillUnmount");
    }

    onApplyNavigaitonChange(location, state) {
        this.setState({
            nav: {
                location: location,
                state: state
            }
        });
    }

    gotoPage1Nested() {
        WinJS.Navigation.navigate("/page1/nested");
    }

    gotoPage1() {
        WinJS.Navigation.navigate("/page1");
    }

    render() {

        console.log("render() location:", this.state.nav.location);

        var componentWithBackButton = component => {
            return (
                <div>
                    <BackButton />
                    <div>
                        {component}
                    </div>
                </div>
            );
        };

        var page;

        switch(this.state.nav.location) {
            case "/page1":
                page = componentWithBackButton(<div>page 1<br/> <button type="button" onClick={this.gotoPage1Nested.bind(this)}>Goto Page 1 Nested</button></div>);
            break;

            case "/page1/nested":
                page = componentWithBackButton(<div>page 1 nested</div>);
            break;

            case "/page2":
                page = componentWithBackButton(<div>page 1</div>);
            break;

            default:
                page = (
                    <div>
                      <button type="button" onClick={this.gotoPage1.bind(this)}>Goto Page 1</button>
                    </div>
                );
        }

        return page;
    }
}

ReactDOM.render(<App />, document.getElementById('main'));

Next I'd like to see if I could leverage something like flatiron/director for routing and get it to play nice with WinJS.Navigation and if I do, I'll post it as well...

Hope this helps.