If you have read the first part of this series you may have noticed an issue with the implementation proposed on that post: the sound does not get updated if you change the song url. I haven’t bound the sound widget to the property.
To solve this we have to observe changes on our model’s url
property and reload the sound when it get’s updated. We can do that by just adding a .observes('url')
to the end of our loadSound
method:
App.SongController = Ember.ObjectController.extend({
// ...
loadSound: function() {
// ...
}.observes('url'),
That’s all cool but now it doesn’t unload the old sound, and also we cannot reuse this awesome sound player for any other model since it’s all strongly tied to our SongController.
I want to be able to create a sound player by just implementing a simple template and calling a helper, maybe have some customizations on the view but that’s it. here is what I want to write in the end:
<script type="text/x-handlebars" id="song">
{{soundPlayer url=url}}
</script>
<script type="text/x-handlebars" id="soundplayer">
<p>{{view.position}} of {{view.duration}}</p>
<button class="btn" {{action 'toggle' target="view"}}>{{view.playPauseLink}}</button>
<button class="btn" {{action 'stop' target="view"}} {{bindAttr disabled="view.isStopped"}}>Stop</button>
</script>
App.SoundPlayerView = Ember.SoundPlayerView.extend({
templateName: 'soundplayer',
playPauseLink: function() {
if (this.get('isPlaying')) {
return 'Pause';
} else {
return 'Play';
}
}.property('isPlaying'),
});
Ember.Handlebars.helper('soundPlayer', App.SoundPlayerView);
So now I’m black-boxing any implementation detail about how to manage a sound and I’m just worrying about the looks here, I could easily implement a progress bar for the position/duration here without having to know how it works. That’s all assuming Ember.SoundPlayerView
exists. But it doesn’t, we have to create it.
I have defined what to expect from this Ember.SoundPlayerView
object so I’m ready to test drive it. Let us start by re-implementing what we had on part one so we have a place to start fixing our problem.
describe('Ember.SoundPlayerView', function() {
// ...
describe('with a sound url', function() {
describe('sound is loaded', function() {
it('is stopped', function() { /* ... */ });
it('plays', function() { /* ... */ });
it('toggles playback', function() { /* ... */ });
describe('when it is playing', function() {
// ...
it('tracks the progress', function() { /* ... */ });
it('knows the total duration', function() { /* ... */ });
it('pauses', function() { /* ... */ });
it('finishes', function() { /* ... */ });
describe('when it is paused', function() {
// ...
it('resumes', function() { /* ... */ });
});
it('stops', function() { /* ... */ });
});
});
});
This reflects what we had on part one, the complete gist of this test suite can be found here. The implementation to make this pass is not very much different from the one we wrote on part one, you can find it on this gist. There is one key difference there, I am now using the ember’s StateManager.
As stated in it’s own documentation, Ember.StateManager
is a part of Ember’s implementation of a finite state machine. Our sound player has a few well-defined states. It can be not-ready, ready, loaded, playing, paused or stopped. We handled that with a couple of booleans before, but we didn’t quite covered everything and adding to that with more booleans would start to get confusing.
Let’s break our state machine appart, when we initialize our app SoundManager is not ready it has to prepare itself to be ready to start loading sounds, we describe that with the following state:
Ember.SoundPlayerManager = Ember.StateManager.extend({
initialState: 'preparing',
preparing: Ember.State.create({
ready: function(manager, context) {
manager.transitionTo('unloaded');
}
}),
// ...
Once SoundManager is ready we transition from that state to unloaded
by calling the ‘ready’ action:
Ember.SoundPlayerView = Ember.View.extend({
init: function() {
var manager = Ember.SoundPlayerManager.create();
var self = this;
this.set("stateManager", manager);
soundManager.onready(function() {
manager.send('ready');
self.loadSound();
});
this._super();
},
// ...
When the sound is loading we represent is as the unloaded
state and it can transition to stopped
, we do that after the sound is loaded in our soundLoaded
callback: this.get('stateManager').send('loaded');
.
The remainder states are basic playback states stopped
, playing
and paused
, and they can transition between each other freely with simple play/pause/stop actions.
Ember’s StateManager is a powerful feature and it’s the thing behind the awesome router. I’m still yet to learn all it’s features so I might come back later with more.
OK. With our new implementation covered and now that we briefly understand ember’s StateManager let’s fix our original problem. First of, we write it on a spec:
describe('changing the sound', function() {
var oldSound;
beforeEach(function() {
oldSound = player.get('sound');
spyOn(oldSound, 'destruct').andCallThrough();
player.play();
player.set('url', '/__spec__/fixtures/song2.mp3');
});
it('reloads the sound', function() {
expect(player.get('sound').url).toBe('/__spec__/fixtures/song2.mp3');
});
it('unloads the old sound', function() {
expect(oldSound.destruct).toHaveBeenCalled();
expect(player.get('isStopped')).toBe(true);
expect(player.get('isLoading')).toBe(true);
});
});
So basically whenever we change the url
we want the sound to be unloaded and we want our new sound to be put in place. SoundManager’s destruct
method will do the unloading for us, so we make sure that get’s called, ant that the states are updated accordingly.
As I mentioned before, we’ll have to observe the sound’s url
and load the new sound when it get’s changed. The implementation for that is quite simple:
Ember.SoundPlayerView = Ember.View.extend({
// ...
urlChanged: function() {
this.soundObject.destruct();
this.soundObject = undefined;
this.get('stateManager').transitionTo('unloaded');
this.loadSound();
}.observes('url'),
// ..
Line by line:
We destroy the soundObject calling SoundManager’s destruct
method.
Then we unset the soundObject
so our loadSound
method will load it back from the most current url property.
Then we transition our stateManager to the unloaded
state again, because we know that our sound is now not loaded.
And finally we ask our object to load it’s sound by calling loadSound
once again.
The .observes('url')
part will tell ember to watch the ‘url’ property for changes and run this method whenever that property is modified.
And there we have it, all green.
The complete code for this sample app is published here: https://github.com/luan/ember-soundmanager-part2