Created
December 12, 2017 17:00
-
-
Save travisperson/2177af3c2781082242477cf62e7b1d60 to your computer and use it in GitHub Desktop.
Main File: audio-saga.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
}) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const { | |
document, | |
Audio | |
} = window; | |
export default window; | |
export { | |
document, | |
Audio | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }), | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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