Skip to content

Instantly share code, notes, and snippets.

@travisperson
Created December 12, 2017 17:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save travisperson/2177af3c2781082242477cf62e7b1d60 to your computer and use it in GitHub Desktop.
Save travisperson/2177af3c2781082242477cf62e7b1d60 to your computer and use it in GitHub Desktop.
Main File: audio-saga.js
import { delay } from 'redux-saga'
import { cancel, fork, select, call, put, takeEvery } from 'redux-saga/effects'
import { Audio } from 'browser'
import Channel from 'promise-channel'
// List of all AudioElement node events we will be listening on and providing redux
// dispatches for. All of these events will go out under the `AUDIO_SAGA:EVENT` type
// but can be translated into individual events for better reducer support.
const eventList = [
'ended',
'play',
'pause',
'playing',
'timeupdate',
'volumechange',
'waiting',
'seeking',
'seeked',
'stalled',
'suspend',
'canplay',
'loadedmetadata',
'change',
'progress',
'load',
'loadend',
'loadstart',
'loadeddata',
'durationchange'
]
//
// Action creators
//
// Action type constants
const PLAY = 'AUDIO_SAGA:PLAY'
const PAUSE = 'AUDIO_SAGA:PAUSE'
const SEEK = 'AUDIO_SAGA:SEEK'
const SOURCE = 'AUDIO_SAGA:SOURCE'
const EVENT = 'AUDIO_SAGA:EVENT'
// Origin values for `seek`
export const SEEK_SET = 'SEEK_SET'
export const SEEK_CUR = 'SEEK_CUR'
export const actions = {
play: () => {
return {
type: PLAY
}
},
pause: () => {
return {
type: PAUSE
}
},
seek: (offset, origin) => {
return {
type: SEEK,
payload: { offset, origin }
}
},
setSource: src => {
return {
type: SOURCE,
payload: { src }
}
}
}
//
// Saga methods that can be invoked via `call` effect
//
export function * play() {
yield put(actions.play())
}
export function * pause() {
yield put(actions.pause())
}
export function * seek(offset, origin = SEEK_CUR) {
yield put(actions.seek(offset, origin))
}
export function * setSource(src) {
yield put(actions.setSource(src))
}
// Converts generic AUDIO_SAGA:EVENT actions into seperate named actions.
// This isn't really needed, and the user does not need to include this
// middleware for AudioSaga to work. It's provided as a conviences.
// It takes an option event array which can be used to only propagate a
// selection of events
export function createAudioSagaMiddleware(events = eventList) {
return store => next => action => {
if (action.type === EVENT && events.includes(action.payload.type)) {
return store.dispatch({
type: `AUDIO_SAGA_${action.payload.type.toUpperCase()}`,
payload: {
...action.payload
}
})
}
return next(action)
}
}
// Main saga that manages the audio node
export default function * audio({ selectors }) {
const audioChannel = new Channel()
const audioNode = new Audio()
audioNode.crossOrigin = 'anonymous'
// Attach listners to all of the audio nodes events. The events are placed
// into a promise channel which we will take from and dispatch to redux. This
// is needed as we can't yield from the event listener itself, nor do we have
// direct access (keeping to the API of saga) to the store dispatch.
for (const eventName of eventList) {
audioNode.addEventListener(eventName, event => {
audioChannel.put(event)
})
}
// Makes the audio node play from its current source
yield takeEvery(PLAY, function * () {
audioNode.play()
})
// Update the audio nodes source, continue playing if we are already playing
yield takeEvery(SOURCE, function * ({ payload: { src } }) {
const playing = yield select(selectors.playing)
if (audioNode.src !== src) {
audioNode.src = src
audioNode.load()
if (playing) {
audioNode.play()
}
}
})
// Pause the audio node, and clear the sync timer to stop publishing time
// events
yield takeEvery(PAUSE, function * () {
audioNode.pause()
})
// Binds a value between a min and a max
function boundValue(min, max, value) {
if (value < min) {
return min
} else if (value > max) {
return max
} else {
return value
}
}
// Seek the audio node to the desired position as a percentage of its duration
yield takeEvery(SEEK, function * ({ payload: { offset, origin } }) {
if (origin === SEEK_CUR) {
audioNode.currentTime = boundValue(0, audio.duration, audioNode.currentTime + offset)
} else if (origin === SEEK_SET) {
audioNode.currentTime = boundValue(0, audio.duration, offset)
}
})
// Main loop that converts the audio node events into redux events
while (true) {
const event = yield audioChannel.take()
const { type, timeStamp, loaded, total } = event
const { muted, paused, ended, duration, currentTime, currentSrc } = audioNode
yield put({
type: EVENT,
payload: {
type, timeStamp, muted, paused, ended, duration, currentTime, currentSrc, loaded, total
}
})
}
}
const {
document,
Audio
} = window;
export default window;
export {
document,
Audio
};
const mapDispatchToProps = (dispatch) => {
return {
play: () => dispatch({ type: 'USR:PLAY' }),
pause: () => dispatch({ type: 'USR:PAUSE' }),
seek: offset => dispatch({ type: 'USR:SEEK', payload: { offset } }),
jump: offset => dispatch({ type: 'USR:JUMP', payload: { offset } }),
prev: () => dispatch({ type: 'USR:PREV' }),
next: () => dispatch({ type: 'USR:NEXT' }),
}
}
export default class Channel {
constructor() {
this.messageQueue = [];
this.resolveQueue = [];
}
put(msg) {
if (this.resolveQueue.length) {
this.resolveQueue.shift()(msg);
} else {
this.messageQueue.push(msg);
}
}
take() {
if (this.messageQueue.length) {
return Promise.resolve(this.messageQueue.shift());
} else {
return new Promise((resolve) => this.resolveQueue.push(resolve));
}
}
}
export default function * saga() {
yield spawn(audio, {
selectors: {
playing: state => state.player.playing
}
})
yield takeEvery('USR:IMPORT_BOOK', function * ({ payload: { hash } }) {
yield call(addBook)
})
yield takeEvery('USR:PLAY', function * () {
yield call(play)
})
yield takeEvery('USR:PAUSE', function * () {
yield call(pause)
})
yield takeEvery('USR:SEEK', function * ({ payload }) {
yield call(seek, payload.offset, SEEK_CUR)
})
yield takeEvery('USR:JUMP', function * ({ payload }) {
yield call(seek, payload.offset, SEEK_SET)
})
yield takeEvery('USR:PREV', function * ({ payload }) {
const currentBook = yield select(currentBookSelector)
const currentChapter = yield select(currentChapterSelector)
if (!currentBook) {
return;
}
const chapters = yield select(chaptersSelector, currentBook);
const index = chapters.indexOf(currentChapter);
// -1?
if (index === 0) {
yield call(seek, 0, SEEK_SET)
}
if (index - 1 >= 0) {
yield call(updateChapterAndSelect, chapters[index - 1])
} else {
yield call(pause)
}
})
yield takeEvery('USR:NEXT', function * ({ payload }) {
const currentBook = yield select(currentBookSelector)
const currentChapter = yield select(currentChapterSelector)
if (!currentBook) {
return;
}
const chapters = yield select(chaptersSelector, currentBook);
const index = chapters.indexOf(currentChapter);
if (index === chapters.length - 1) {
yield call(pause)
}
if (index + 1 < chapters.length) {
yield call(updateChapterAndSelect, chapters[index + 1])
} else {
yield call(pause)
}
})
yield takeEvery('USR:SELECT_BOOK', function * ({ payload }) {
yield call(selectBook, payload.hash)
})
yield takeEvery('USR:SELECT_CHAPTER', function * ({ payload }) {
yield call(updateChapterAndSelect, payload.hash)
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment