├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── assetsources ├── TabIcons.afdesign ├── design.sketch └── svg │ ├── Audio.svg │ ├── Chapters.svg │ ├── Download.svg │ ├── Info.svg │ ├── Share.svg │ ├── Transcripts.svg │ └── optimize.sh ├── build ├── blocks │ ├── dev-server.js │ ├── dir.js │ ├── entry.js │ ├── index.js │ ├── optimization.js │ ├── output.js │ ├── plugins.js │ ├── resolve.js │ └── rules.js ├── webpack.config.cdn.js ├── webpack.config.dev.js └── webpack.config.prod.js ├── cypress.json ├── cypress ├── fixtures │ ├── audio.json │ ├── chapters.json │ ├── contributors.json │ ├── episode.json │ ├── reference.json │ ├── runtime.json │ └── show.json ├── helpers │ └── state.js ├── integration │ ├── controllbar.spec.js │ ├── header.spec.js │ ├── progressbar.spec.js │ ├── tab-audio.spec.js │ ├── tab-chapters.spec.js │ ├── tab-files.spec.js │ ├── tab-info.spec.js │ ├── tab-share.spec.js │ ├── url-parameters.spec.js │ └── visible-components.spec.js ├── plugins │ └── index.js ├── selectors │ ├── controllbar.js │ ├── header.js │ ├── index.js │ ├── progressbar.js │ └── tabs │ │ ├── audio.js │ │ ├── chapters.js │ │ ├── files.js │ │ ├── info.js │ │ └── share.js ├── support │ ├── commands.js │ └── index.js └── testbed │ ├── assets │ ├── bender.png │ ├── episode.png │ ├── fry.png │ ├── podlove.m4a │ ├── podlove.mp3 │ ├── professor.png │ └── show.png │ ├── embed.html │ └── test.html ├── docs ├── .vuepress │ ├── components │ │ ├── ColorPicker.vue │ │ ├── JsonEditor.vue │ │ ├── Playground.vue │ │ ├── PodloveWebPlayer.vue │ │ ├── StoreDispatch.vue │ │ └── StoreSubscribe.vue │ ├── config.js │ └── public │ │ ├── favicon.png │ │ └── fixtures │ │ ├── chapters │ │ └── fg55.json │ │ ├── example.json │ │ ├── fg45.json │ │ ├── fs207.json │ │ ├── fs220.json │ │ └── transcripts │ │ ├── cs309.json │ │ ├── fg45.json │ │ ├── fg55.json │ │ └── fs207.json ├── config.md ├── embedding.md ├── extensions.md ├── index.md ├── installation.md ├── player-dispatches.md ├── player-subscriptions.md ├── playground.md └── theme.md ├── now.json ├── package-lock.json ├── package.json ├── readme.md ├── screenshot.png ├── scripts ├── deploy-ghpages.sh └── dev.js ├── src ├── app.js ├── components │ ├── App.vue │ ├── header │ │ ├── Error.vue │ │ ├── Header.vue │ │ ├── Info.vue │ │ └── Poster.vue │ ├── icons │ │ ├── AudioIcon.vue │ │ ├── CalendarIcon.vue │ │ ├── ChapterBackIcon.vue │ │ ├── ChapterNextIcon.vue │ │ ├── ChaptersIcon.vue │ │ ├── ClockIcon.vue │ │ ├── CloseIcon.vue │ │ ├── CopyIcon.vue │ │ ├── DownloadIcon.vue │ │ ├── EmbedIcon.vue │ │ ├── ErrorIcon.vue │ │ ├── FacebookIcon.vue │ │ ├── FilesAudioIcon.vue │ │ ├── FollowIcon.vue │ │ ├── InfoIcon.vue │ │ ├── LinkIcon.vue │ │ ├── LinkedinIcon.vue │ │ ├── MailIcon.vue │ │ ├── MinusIcon.vue │ │ ├── NextSearchIcon.vue │ │ ├── PauseIcon.vue │ │ ├── PinterestIcon.vue │ │ ├── PlayIcon.vue │ │ ├── PlusIcon.vue │ │ ├── PodlovePlayerIcon.vue │ │ ├── PreviousSearchIcon.vue │ │ ├── RedditIcon.vue │ │ ├── ReloadIcon.vue │ │ ├── SearchDeleteIcon.vue │ │ ├── SettingsIcon.vue │ │ ├── ShareChapterIcon.vue │ │ ├── ShareEpisodeIcon.vue │ │ ├── ShareIcon.vue │ │ ├── SharePlaytimeIcon.vue │ │ ├── ShareShowIcon.vue │ │ ├── SpeakerIcon.vue │ │ ├── StepBackIcon.vue │ │ ├── StepForwardIcon.vue │ │ ├── TranscriptsIcon.vue │ │ ├── TwitterIcon.vue │ │ └── icon-container.js │ ├── player │ │ ├── Player.vue │ │ ├── control-bar │ │ │ ├── ChapterBackButton.vue │ │ │ ├── ChapterNextButton.vue │ │ │ ├── ControlBar.vue │ │ │ ├── LoadingIndicator.vue │ │ │ ├── PlayButton.vue │ │ │ ├── StepBackButton.vue │ │ │ └── StepForwardButton.vue │ │ └── progress-bar │ │ │ ├── ChapterIndicator.vue │ │ │ ├── CurrentChapter.vue │ │ │ ├── Progress.vue │ │ │ ├── ProgressBar.vue │ │ │ └── Timer.vue │ ├── shared │ │ ├── Button.vue │ │ ├── ButtonGroup.vue │ │ ├── CopyTooltip.vue │ │ ├── Footer.vue │ │ ├── InputGroup.vue │ │ ├── InputSelect.vue │ │ ├── InputSlider.vue │ │ ├── InputText.vue │ │ ├── Overlay.vue │ │ ├── Slider.vue │ │ ├── TabBody.vue │ │ ├── TabHeader.vue │ │ └── TabHeaderItem.vue │ └── tabs │ │ ├── Tabs.vue │ │ ├── audio │ │ ├── Audio.vue │ │ ├── AudioRate.vue │ │ └── AudioVolume.vue │ │ ├── chapters │ │ ├── ChapterEntry.vue │ │ └── Chapters.vue │ │ ├── files │ │ ├── File.vue │ │ └── Files.vue │ │ ├── info │ │ └── Info.vue │ │ ├── share │ │ ├── Share.vue │ │ ├── ShareChannels.vue │ │ ├── ShareContent.vue │ │ ├── ShareEmbed.vue │ │ ├── ShareLink.vue │ │ └── channels │ │ │ ├── ChannelEmbed.vue │ │ │ ├── ChannelFacebook.vue │ │ │ ├── ChannelLinkedin.vue │ │ │ ├── ChannelMail.vue │ │ │ ├── ChannelPinterest.vue │ │ │ ├── ChannelReddit.vue │ │ │ └── ChannelTwitter.vue │ │ └── transcripts │ │ ├── Entry.vue │ │ ├── Follow.vue │ │ ├── Prerender.vue │ │ ├── Render.vue │ │ ├── Search.vue │ │ └── Transcripts.vue ├── core │ ├── directives │ │ ├── index.js │ │ ├── marquee.js │ │ └── resize.js │ ├── index.js │ └── lang │ │ └── index.js ├── effects │ ├── chapters.js │ ├── chapters.test.js │ ├── components │ │ ├── episode.js │ │ ├── episode.test.js │ │ ├── index.js │ │ ├── live.js │ │ └── live.test.js │ ├── index.js │ ├── keyboard.js │ ├── keyboard.test.js │ ├── playback.js │ ├── playback.test.js │ ├── player.js │ ├── player.test.js │ ├── quantiles.js │ ├── quantiles.test.js │ ├── runtime.js │ ├── storage │ │ ├── episode.js │ │ ├── episode.test.js │ │ ├── index.js │ │ ├── live.js │ │ └── live.test.js │ ├── transcripts │ │ ├── active.js │ │ ├── active.test.js │ │ ├── fetch.js │ │ ├── fetch.test.js │ │ ├── fixtures.js │ │ ├── index.js │ │ ├── search.js │ │ └── search.test.js │ ├── volume.js │ └── volume.test.js ├── embed │ ├── embed.js │ ├── loader.js │ ├── share.js │ └── window.js ├── extensions │ └── external-events.js ├── lang │ ├── de.json │ ├── en.json │ ├── eo.json │ └── ru.json ├── media │ └── index.js ├── statics │ ├── example │ │ ├── episode.js │ │ ├── episode.json │ │ ├── example.js │ │ ├── files │ │ │ └── poster.jpg │ │ ├── standalone.html │ │ └── transcripts.json │ └── share.html ├── store │ ├── actions.js │ ├── buffer │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── chapters │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ └── selectors.js │ ├── components │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── effects.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── display │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── duration │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── episode │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── error │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── files │ │ ├── index.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ └── selectors.js │ ├── ghost │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── index.js │ ├── last-action │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── mode │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── muted │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── playback │ │ ├── actions.js │ │ ├── index.js │ │ └── reducer.js │ ├── player │ │ ├── actions.js │ │ └── actions.test.js │ ├── playstate │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── playtime │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── quantiles │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── rate │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── reducers.js │ ├── reference │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── runtime │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── selectors.js │ ├── share │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ └── selectors.js │ ├── show │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── speakers │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── tabs │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── theme │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── transcripts │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ ├── types.js │ ├── visible-components │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js │ └── volume │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── index.js │ │ ├── reducer.js │ │ └── reducer.test.js ├── styles │ ├── _animations.scss │ ├── _embed.scss │ ├── _font.scss │ ├── _global.scss │ ├── _inputs.scss │ ├── _loader.scss │ ├── _marquee.scss │ ├── _share.scss │ ├── _text.scss │ ├── _tooltip.scss │ ├── _transitions.scss │ ├── _utils.scss │ ├── _variables.scss │ ├── fonts │ │ ├── FiraMono-Regular.eot │ │ ├── FiraMono-Regular.woff │ │ ├── FiraMono-Regular.woff2 │ │ ├── FiraSans-Bold.eot │ │ ├── FiraSans-Bold.woff │ │ ├── FiraSans-Bold.woff2 │ │ ├── FiraSans-Light.eot │ │ ├── FiraSans-Light.woff │ │ ├── FiraSans-Light.woff2 │ │ ├── FiraSans-Regular.eot │ │ ├── FiraSans-Regular.woff │ │ └── FiraSans-Regular.woff2 │ └── resets │ │ ├── _button.scss │ │ ├── _input.scss │ │ ├── _lists.scss │ │ ├── _range.scss │ │ └── _resets.scss └── utils │ ├── binary-search.js │ ├── binary-search.test.js │ ├── chapters.js │ ├── chapters.test.js │ ├── dom.js │ ├── dom.test.js │ ├── effects.js │ ├── effects.test.js │ ├── helper.js │ ├── helper.test.js │ ├── keyboard.js │ ├── math.js │ ├── math.test.js │ ├── predicates.js │ ├── request.js │ ├── request.test.js │ ├── runtime.js │ ├── sandbox.js │ ├── storage.js │ ├── storage.test.js │ ├── text-search.js │ ├── text-search.test.js │ ├── time.js │ ├── time.test.js │ └── url.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [[ 3 | "env", 4 | { 5 | "targets": { 6 | "browsers": [ 7 | "last 2 versions", 8 | "safari >= 7" 9 | ] 10 | } 11 | } 12 | ]], 13 | "plugins": [ 14 | "lodash", 15 | "transform-object-rest-spread", 16 | "es6-promise", 17 | "syntax-dynamic-import" 18 | ], 19 | "env": { 20 | "AVA": { 21 | "plugins": [ 22 | [ 23 | "babel-plugin-webpack-alias", 24 | { 25 | "config": "build/webpack.config.prod.js" 26 | } 27 | ] 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | max_line_length = 120 14 | 15 | # We recommend you to keep these unchanged 16 | end_of_line = lf 17 | charset = utf-8 18 | trim_trailing_whitespace = true 19 | insert_final_newline = true 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | 24 | [*.html] 25 | indent_size = 4 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dotfiles 2 | .* 3 | !.gitignore 4 | !.jshintrc 5 | !.eslintrc 6 | !.editorconfig 7 | !.travis.yml 8 | !.babelrc 9 | !.circleci 10 | !.vuepress 11 | 12 | coverage 13 | 14 | # external dependencies 15 | node_modules 16 | bower_components 17 | 18 | # OS and editor artefacts 19 | *Thumbs.db 20 | atlassian-ide-plugin.xml 21 | 22 | # build artefacts 23 | _site 24 | dist 25 | coverage 26 | reports 27 | docs/media 28 | gh-pages-branch 29 | 30 | # logs 31 | yarn-error.log 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The 2-Clause BSD License 2 | SPDX short identifier: BSD-2-Clause 3 | 4 | Further resources on the 2-clause BSD license 5 | Note: This license has also been called the "Simplified BSD License" and the "FreeBSD License". See also the 3-clause BSD License. 6 | 7 | Copyright 2017 Podlove.org 8 | 9 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 10 | 11 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 16 | -------------------------------------------------------------------------------- /assetsources/TabIcons.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/assetsources/TabIcons.afdesign -------------------------------------------------------------------------------- /assetsources/design.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/assetsources/design.sketch -------------------------------------------------------------------------------- /assetsources/svg/Audio.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assetsources/svg/Chapters.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assetsources/svg/Download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assetsources/svg/Info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assetsources/svg/Share.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assetsources/svg/Transcripts.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assetsources/svg/optimize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # requires svgo: brew install svgo 3 | for f in *.svg; do 4 | echo $f":"; 5 | svgo --enable={sortAttrs} --multipass --pretty $f -o - | sed 's/svg /svg width="25" height="25" /g' | sed 's/fill="#fff"/:fill="color || '"'"'currentColor'"'"'"/g' | sed 's/stroke="#fff"/:stroke="color || '"'"'currentColor'"'"'"/g' 6 | echo; 7 | done -------------------------------------------------------------------------------- /build/blocks/dev-server.js: -------------------------------------------------------------------------------- 1 | const { distDir } = require('./dir') 2 | 3 | module.exports = (port = 8080) => ({ 4 | historyApiFallback: true, 5 | noInfo: true, 6 | overlay: true, 7 | inline: true, 8 | hot: true, 9 | disableHostCheck: true, 10 | host: '0.0.0.0', 11 | contentBase: distDir, 12 | port 13 | }) 14 | -------------------------------------------------------------------------------- /build/blocks/dir.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const sourceDir = path.resolve(__dirname, '..', '..', 'src') 4 | const distDir = path.resolve(__dirname, '..', '..', 'dist') 5 | const prepend = (input, prefix) => prefix ? `${prefix}/${input}` : input 6 | 7 | module.exports = { sourceDir, distDir, prepend } 8 | -------------------------------------------------------------------------------- /build/blocks/entry.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { sourceDir, prepend } = require('./dir') 3 | 4 | const prod = prefix => ({ 5 | embed: path.resolve(sourceDir, 'embed', 'embed.js'), 6 | 'extensions/external-events': path.resolve(sourceDir, 'extensions', 'external-events.js'), 7 | [prepend('window', prefix)]: path.resolve(sourceDir, 'embed', 'window.js'), 8 | [prepend('share', prefix)]: path.resolve(sourceDir, 'embed', 'share.js') 9 | }) 10 | 11 | const dev = () => ({ 12 | embed: path.resolve(sourceDir, 'embed', 'embed.js'), 13 | 'extensions/external-events': path.resolve(sourceDir, 'extensions', 'external-events.js'), 14 | window: path.resolve(sourceDir, 'embed', 'window.js'), 15 | share: path.resolve(sourceDir, 'embed', 'share.js'), 16 | example: path.resolve(sourceDir, 'statics', 'example', 'example.js') 17 | }) 18 | 19 | module.exports = { prod, dev } 20 | -------------------------------------------------------------------------------- /build/blocks/index.js: -------------------------------------------------------------------------------- 1 | const entry = require('./entry') 2 | const output = require('./output') 3 | const resolve = require('./resolve') 4 | const optimization = require('./optimization') 5 | const rules = require('./rules') 6 | const plugins = require('./plugins') 7 | const devServer = require('./dev-server') 8 | 9 | module.exports = { 10 | entry, output, optimization, rules, resolve, plugins, devServer 11 | } 12 | -------------------------------------------------------------------------------- /build/blocks/optimization.js: -------------------------------------------------------------------------------- 1 | const { prepend } = require('./dir') 2 | const ignoredChunks = ['embed', 'extensions/external-events'] 3 | 4 | module.exports = (prefix = '') => ({ 5 | splitChunks: { 6 | cacheGroups: { 7 | default: false, 8 | vendors: false, 9 | vendor: { 10 | name: 'vendor', 11 | chunks: chunk => ~[prepend('window', prefix), prepend('share', prefix)].indexOf(chunk.name), 12 | test: /node_modules/ 13 | }, 14 | styles: { 15 | name: 'style', 16 | test: /\.(s?css|vue)$/, 17 | enforce: true, 18 | chunks: chunk => chunk.name && !~ignoredChunks.indexOf(chunk.name), 19 | minChunks: 1 20 | } 21 | } 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /build/blocks/output.js: -------------------------------------------------------------------------------- 1 | const { distDir, prepend } = require('./dir') 2 | 3 | module.exports = (publicPath, prefix = '') => ({ 4 | path: distDir, 5 | filename: '[name].js', 6 | chunkFilename: prepend('[name].js', prefix), 7 | publicPath 8 | }) 9 | -------------------------------------------------------------------------------- /build/blocks/resolve.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { sourceDir } = require('./dir') 3 | 4 | module.exports = () => ({ 5 | extensions: ['*', '.js', '.vue', '.json'], 6 | alias: { 7 | store: path.resolve(sourceDir, 'store'), 8 | utils: path.resolve(sourceDir, 'utils'), 9 | shared: path.resolve(sourceDir, 'components', 'shared'), 10 | icons: path.resolve(sourceDir, 'components', 'icons'), 11 | components: path.resolve(sourceDir, 'components'), 12 | lang: path.resolve(sourceDir, 'lang'), 13 | core: path.resolve(sourceDir, 'core'), 14 | styles: path.resolve(sourceDir, 'styles') 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /build/webpack.config.cdn.js: -------------------------------------------------------------------------------- 1 | const { version } = require('../package') 2 | const BASE = `//cdn.podlove.org/web-player/4.x/` 3 | 4 | const { entry, output, resolve, optimization, rules, plugins } = require('./blocks') 5 | 6 | module.exports = { 7 | mode: 'production', 8 | entry: entry.prod(version), 9 | output: output(BASE, version), 10 | 11 | optimization: optimization(version), 12 | 13 | resolve: resolve(), 14 | 15 | module: { 16 | rules: [ 17 | rules.vue(), 18 | rules.javascript(), 19 | rules.images(), 20 | rules.styles('prod'), 21 | rules.fonts(version) 22 | ] 23 | }, 24 | 25 | plugins: [ 26 | plugins.vue(), 27 | plugins.css(version), 28 | plugins.minifyCss(), 29 | plugins.version(), 30 | plugins.base(`${BASE}${version}`), 31 | plugins.shareHtml(version), 32 | plugins.env('production') 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /build/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const { entry, output, resolve, devServer, rules, plugins, optimization } = require('./blocks') 2 | 3 | module.exports = { 4 | mode: 'development', 5 | 6 | entry: entry.dev(), 7 | output: output(), 8 | resolve: resolve(), 9 | 10 | optimization: optimization(), 11 | 12 | devtool: 'inline-source-map', 13 | devServer: devServer(9002), 14 | 15 | module: { 16 | rules: [ 17 | rules.vue(), 18 | rules.javascript(), 19 | rules.images(), 20 | rules.styles('dev'), 21 | rules.fonts(), 22 | rules.examples() 23 | ] 24 | }, 25 | 26 | plugins: [ 27 | plugins.vue(), 28 | plugins.base('.'), 29 | plugins.jarvis(1337), 30 | plugins.bundleAnalyzer(), 31 | plugins.hmr(), 32 | plugins.shareHtml(), 33 | ...plugins.devHtml('standalone.html'), 34 | plugins.env('development') 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /build/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const { entry, output, resolve, optimization, rules, plugins } = require('./blocks') 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: entry.prod(), 6 | output: output(), 7 | 8 | optimization: optimization(), 9 | 10 | resolve: resolve(), 11 | 12 | module: { 13 | rules: [ 14 | rules.vue(), 15 | rules.javascript(), 16 | rules.images(), 17 | rules.styles('prod'), 18 | rules.fonts() 19 | ] 20 | }, 21 | 22 | plugins: [ 23 | plugins.vue(), 24 | plugins.css(), 25 | plugins.minifyCss(), 26 | plugins.version(), 27 | plugins.base('.'), 28 | plugins.shareHtml(), 29 | plugins.env('production') 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "mvgejp", 3 | "baseUrl": "http://localhost:8080" 4 | } 5 | -------------------------------------------------------------------------------- /cypress/fixtures/audio.json: -------------------------------------------------------------------------------- 1 | { 2 | "audio": [{ 3 | "url": "assets/podlove.mp3", 4 | "size": "486839", 5 | "title": "MP3 Audio (mp3)", 6 | "mimeType": "audio\/mpeg" 7 | }, { 8 | "url": "assets/podlove.m4a", 9 | "size": "1931981", 10 | "title": "MP4 Audio (m4a)", 11 | "mimeType": "audio\/mp4" 12 | }] 13 | } 14 | -------------------------------------------------------------------------------- /cypress/fixtures/chapters.json: -------------------------------------------------------------------------------- 1 | { 2 | "chapters": [{ 3 | "start": "00:00:0.000", 4 | "title": "I'm a thing", 5 | "href": "", 6 | "image": "" 7 | }, { 8 | "start": "00:00:08.000", 9 | "title": "Yes! In your face, Gandhi", 10 | "href": "", 11 | "image": "" 12 | }, { 13 | "start": "00:00:10.000", 14 | "title": "Oh, you're a dollar naughtier than most", 15 | "href": "", 16 | "image": "" 17 | }] 18 | } 19 | -------------------------------------------------------------------------------- /cypress/fixtures/contributors.json: -------------------------------------------------------------------------------- 1 | { 2 | "contributors": [ 3 | { 4 | "avatar": "assets/fry.jpg", 5 | "name": "Philip J. Fry", 6 | "role": { 7 | "id": "9", 8 | "slug": "team", 9 | "title": "Team" 10 | }, 11 | "group": { 12 | "id": "1", 13 | "slug": "onair", 14 | "title": "On Air" 15 | }, 16 | "comment": null 17 | }, 18 | { 19 | "avatar": "assets/farnsworth.png", 20 | "name": "Professor Farnsworth", 21 | "role": { 22 | "id": "9", 23 | "slug": "team", 24 | "title": "Team" 25 | }, 26 | "group": { 27 | "id": "1", 28 | "slug": "onair", 29 | "title": "On Air" 30 | }, 31 | "comment": null 32 | }, 33 | { 34 | "avatar": "assets/leela.jpg", 35 | "name": "Turanga Leela", 36 | "role": { 37 | "id": "9", 38 | "slug": "team", 39 | "title": "Team" 40 | }, 41 | "group": { 42 | "id": "1", 43 | "slug": "onair", 44 | "title": "On Air" 45 | }, 46 | "comment": null 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /cypress/fixtures/episode.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "And until then, I can never die?", 3 | "subtitle": "We'll need to have a look inside you with this camera. Robot 1-X, save my friends! And Zoidberg! Michelle, I don't regret this, but I both rue and lament it. Look, last night was a mistake. So, how 'bout them Knicks?", 4 | "summary": "I'm a thing. Hello Morbo, how's the family? When I was first asked to make a film about my nephew, Hubert Farnsworth, I thought \"Why should I?\" Then later, Leela made the film. But if I did make it, you can bet there would have been more topless women on motorcycles. Roll film!\nI can explain. It's very valuable. You're going to do his laundry? Ah, the 'Breakfast Club' soundtrack! I can't wait til I'm old enough to feel ways about stuff! So, how 'bout them Knicks?", 5 | "publicationDate": "2999-11-02T20:31:30+00:00", 6 | "poster": "assets/episode.png", 7 | "duration": "00:00:12", 8 | "link": "http://link/to/episode" 9 | } 10 | -------------------------------------------------------------------------------- /cypress/fixtures/reference.json: -------------------------------------------------------------------------------- 1 | { 2 | "reference": { 3 | "config": "//localhost:8080/fixtures/example.json", 4 | "share": "//localhost:8080/share", 5 | "origin": "//localhost:8080/standalone.html" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /cypress/fixtures/runtime.json: -------------------------------------------------------------------------------- 1 | { 2 | "runtime": { 3 | "locale": "en" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/show.json: -------------------------------------------------------------------------------- 1 | { 2 | "show": { 3 | "title": "Belligerent and numerous.", 4 | "subtitle": "I saw you with those two \"ladies of the evening\" at Elzars. Explain that. Who am I making this out to? Tell them I hate them. Hello Morbo, how's the family? Guards! Bring me the forms I need to fill out to have her taken away!", 5 | "summary": "Kids don't turn rotten just from watching TV. You'll have all the Slurm you can drink when you're partying with Slurms McKenzie! It's just like the story of the grasshopper and the octopus. All year long, the grasshopper kept burying acorns for winter, while the octopus mooched off his girlfriend and watched TV. But then the winter came, and the grasshopper died, and the octopus ate all his acorns. Also he got a race car. Is any of this getting through to you?", 6 | "poster": "assets/show.png", 7 | "link": "http://link/to/show" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cypress/helpers/state.js: -------------------------------------------------------------------------------- 1 | const setState = (...fragments) => ({ PODLOVE_STORE }) => { 2 | const state = fragments.reduce((result, item) => Object.assign({}, result, item), {}) 3 | PODLOVE_STORE.dispatch({ type: 'INIT', payload: state }) 4 | } 5 | 6 | module.exports = { setState } 7 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/selectors/controllbar.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | controls: { 3 | playButton: { 4 | button: () => cy.get('#control-bar--play-button'), 5 | duration: () => cy.get('#control-bar--play-button--duration'), 6 | pause: () => cy.get('#control-bar--play-button--pause'), 7 | play: () => cy.get('#control-bar--play-button--play'), 8 | replay: () => cy.get('#control-bar--play-button--replay') 9 | }, 10 | steppers: { 11 | forward: () => cy.get('#control-bar--step-forward-button'), 12 | back: () => cy.get('#control-bar--step-back-button') 13 | }, 14 | chapters: { 15 | next: () => cy.get('#control-bar--chapter-next-button'), 16 | back: () => cy.get('#control-bar--chapter-back-button') 17 | } 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /cypress/selectors/header.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | show: { 3 | title: () => cy.get('#header-showtitle') 4 | }, 5 | episode: { 6 | title: () => cy.get('#header-title'), 7 | subtitle: () => cy.get('#header-subtitle') 8 | }, 9 | headerPosterContainer: () => cy.get('#header-poster'), 10 | headerPoster: () => cy.get('#header-poster img') 11 | }) 12 | -------------------------------------------------------------------------------- /cypress/selectors/index.js: -------------------------------------------------------------------------------- 1 | const controllbar = require('./controllbar') 2 | const progressbar = require('./progressbar') 3 | const header = require('./header') 4 | 5 | // Tabs 6 | const audio = require('./tabs/audio') 7 | const chapters = require('./tabs/chapters') 8 | const files = require('./tabs/files') 9 | const info = require('./tabs/info') 10 | const share = require('./tabs/share') 11 | 12 | module.exports = cy => Object.assign({}, controllbar(cy), progressbar(cy), header(cy), { 13 | tabs: { 14 | audio: audio(cy), 15 | chapters: chapters(cy), 16 | files: files(cy), 17 | info: info(cy), 18 | share: share(cy) 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /cypress/selectors/progressbar.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | progressbar: () => cy.get('#progress-bar'), 3 | timers: { 4 | left: () => cy.get('#progress-bar--timer-left'), 5 | current: () => cy.get('#progress-bar--timer-current') 6 | }, 7 | chapter: { 8 | current: () => cy.get('#progress-bar--current-chapter'), 9 | title: () => cy.get('#progress-bar--current-chapter .chapter-title') 10 | }, 11 | progress: { 12 | bar: () => cy.get('#progress-bar--progress'), 13 | range: () => cy.get('#progress-bar--progress .progress-range'), 14 | chapters: () => cy.get('#progress-bar--progress .chapters-progress .indicator') 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /cypress/selectors/tabs/audio.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | header: () => cy.get(`#tabs [rel="audio"]`), 3 | container: () => cy.get('#tab-audio'), 4 | volume: { 5 | current: () => cy.get('#tab-audio--volume--value'), 6 | input: () => cy.get('#tab-audio--volume--input input'), 7 | mute: () => cy.get('#tab-audio--volume--mute') 8 | }, 9 | rate: { 10 | current: () => cy.get('#tab-audio--rate--value'), 11 | input: () => cy.get('#tab-audio--rate--input input') 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /cypress/selectors/tabs/chapters.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | header: () => cy.get(`#tabs [rel="chapters"]`), 3 | container: () => cy.get('#tab-chapters'), 4 | entries: () => cy.get('#tab-chapters .chapters--entry'), 5 | indices: () => cy.get('#tab-chapters .chapters--entry .index'), 6 | titles: () => cy.get('#tab-chapters .chapters--entry .title'), 7 | activeTimer: () => cy.get('#tab-chapters .chapters--entry.active .timer'), 8 | timers: () => cy.get('#tab-chapters .chapters--entry .timer') 9 | }) 10 | -------------------------------------------------------------------------------- /cypress/selectors/tabs/files.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | header: () => cy.get(`#tabs [rel="files"]`), 3 | container: () => cy.get('#tab-files'), 4 | audio: () => cy.get('#tab-files--audio') 5 | }) 6 | -------------------------------------------------------------------------------- /cypress/selectors/tabs/info.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | header: () => cy.get(`#tabs [rel="info"]`), 3 | container: () => cy.get('#tab-info'), 4 | episode: { 5 | title: () => cy.get('#tab-info--episode-title'), 6 | meta: () => cy.get('#tab-info--episode-meta'), 7 | subtitle: () => cy.get('#tab-info--episode-subtitle'), 8 | summary: () => cy.get('#tab-info--episode-summary'), 9 | link: () => cy.get('#tab-info--episode-link') 10 | }, 11 | show: { 12 | title: () => cy.get('#tab-info--show-title'), 13 | poster: () => cy.get('#tab-info--show-poster'), 14 | summary: () => cy.get('#tab-info--show-summary'), 15 | link: () => cy.get('#tab-info--show-link') 16 | }, 17 | speakers: () => cy.get('#tab-info--speakers') 18 | }) 19 | -------------------------------------------------------------------------------- /cypress/selectors/tabs/share.js: -------------------------------------------------------------------------------- 1 | module.exports = cy => ({ 2 | header: () => cy.get(`#tabs [rel="share"]`), 3 | container: () => cy.get('#tab-share'), 4 | content: { 5 | show: () => cy.get('#tab-share--content--show'), 6 | episode: () => cy.get('#tab-share--content--episode'), 7 | chapter: () => cy.get('#tab-share--content--chapter'), 8 | time: () => cy.get('#tab-share--content--time') 9 | }, 10 | channels: { 11 | twitter: () => cy.get(`#tab-share--channels--twitter a`), 12 | reddit: () => cy.get(`#tab-share--channels--reddit a`), 13 | mail: () => cy.get(`#tab-share--channels--mail a`), 14 | pinterest: () => cy.get(`#tab-share--channels--pinterest a`), 15 | linkedin: () => cy.get(`#tab-share--channels--linkedin a`) 16 | }, 17 | embed: { 18 | container: () => cy.get('#tab-share--embed--link'), 19 | input: () => cy.get('#tab-share--share-embed--input'), 20 | size: () => cy.get('#tab-share--share-embed--size') 21 | }, 22 | link: { 23 | container: () => cy.get('#tab-share--share-link'), 24 | input: () => cy.get('#tab-share--share-link--input') 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | /* global Cypress, cy */ 2 | const domSelectors = require('../selectors') 3 | 4 | Cypress.Commands.add('bootstrap', () => { 5 | cy.visit('/test.html') 6 | cy.fixture('episode').as('episode') 7 | cy.fixture('show').as('show') 8 | cy.fixture('audio').as('audio') 9 | cy.fixture('chapters').as('chapters') 10 | cy.fixture('contributors').as('contributors') 11 | cy.fixture('reference').as('reference') 12 | cy.fixture('runtime').as('runtime') 13 | }) 14 | 15 | Cypress.Commands.add('embed', (params = {}) => { 16 | const query = Object.keys(params).reduce((result, key) => [...result, `${key}=${params[key]}`], []).join('&') 17 | cy.visit(`/embed.html${query ? '?' + query : ''}`) 18 | }) 19 | 20 | Cypress.Commands.add('iframe', { 21 | prevSubject: 'element' 22 | }, $iframe => { 23 | return new Cypress.Promise(resolve => { 24 | resolve($iframe.contents().find('body')) 25 | }) 26 | }) 27 | 28 | Cypress.Commands.add('play', () => { 29 | const selectors = domSelectors(cy) 30 | selectors.controls.playButton.button().then(btn => { 31 | btn.click() 32 | selectors.controls.playButton.pause() 33 | }) 34 | }) 35 | 36 | Cypress.Commands.add('pause', () => { 37 | const selectors = domSelectors(cy) 38 | 39 | selectors.controls.playButton.pause() 40 | selectors.controls.playButton.button().then(btn => { 41 | btn.click() 42 | }) 43 | }) 44 | 45 | Cypress.Commands.add('tab', tab => { 46 | const selectors = domSelectors(cy) 47 | selectors.tabs[tab].header().click() 48 | selectors.tabs[tab].container() 49 | }) 50 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | import './commands' 2 | -------------------------------------------------------------------------------- /cypress/testbed/assets/bender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/cypress/testbed/assets/bender.png -------------------------------------------------------------------------------- /cypress/testbed/assets/episode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/cypress/testbed/assets/episode.png -------------------------------------------------------------------------------- /cypress/testbed/assets/fry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/cypress/testbed/assets/fry.png -------------------------------------------------------------------------------- /cypress/testbed/assets/podlove.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/cypress/testbed/assets/podlove.m4a -------------------------------------------------------------------------------- /cypress/testbed/assets/podlove.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/cypress/testbed/assets/podlove.mp3 -------------------------------------------------------------------------------- /cypress/testbed/assets/professor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/cypress/testbed/assets/professor.png -------------------------------------------------------------------------------- /cypress/testbed/assets/show.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/cypress/testbed/assets/show.png -------------------------------------------------------------------------------- /cypress/testbed/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/.vuepress/components/JsonEditor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 36 | 37 | 54 | -------------------------------------------------------------------------------- /docs/.vuepress/components/Playground.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 35 | -------------------------------------------------------------------------------- /docs/.vuepress/components/PodloveWebPlayer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | 18 | 23 | -------------------------------------------------------------------------------- /docs/.vuepress/components/StoreSubscribe.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 37 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | const base = process.env.BASE || '' 2 | 3 | module.exports = { 4 | title: 'Podlove Web Player 4.0', 5 | base, 6 | description: 'The fast, flexible and responsive podcast player powered by podlove meta data.', 7 | theme: 'millidocs', 8 | head: [ 9 | ['link', { rel: 'icon', href: '/favicon.png' }], 10 | ['script', { type: 'text/javascript', src: `embed.js` }], 11 | ['script', { type: 'text/javascript', src: `extensions/external-events.js` }] 12 | ], 13 | markdown: { 14 | anchor: { 15 | permalink: false, 16 | permalinkBefore: false 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/docs/.vuepress/public/favicon.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | navigation: 1 4 | --- 5 | 6 | # Podlove Web Player 4.0 7 | ### The fast, flexible and responsive podcast player powered by podlove meta data. 8 | 9 | 10 | 11 | ## Features 12 | 13 | - Standalone and Wordpress Plugin 14 | - Responsive 15 | - Theming 16 | - Sharing 17 | - State Persistance 18 | - Deep Linking 19 | 20 | ## Previous Versions 21 | 22 | - [Podlove Web Player v3 Docs](docs.podlove.org/podlove-web-player-v3/) 23 | - [Podlove Web Player v2 Docs](docs.podlove.org/podlove-web-player-v3/versions/v2.html) 24 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | navigation: 2 3 | --- 4 | 5 | # Installation 6 | 7 | Podlove Webplayer can be integrated in different ways. We provide the always latest version via CDN or all versions as an npm package. 8 | 9 | ## CDN 10 | 11 | The easiest way to integrate the player is to simply integrate this script in your page: 12 | 13 | For https context: 14 | ```html 15 | 16 | ``` 17 | 18 | For http context: 19 | ```html 20 | 21 | ``` 22 | 23 | Afterwards `podlovePlayer` should be available on the window object: 24 | 25 | ```html 26 | 29 | ``` 30 | 31 | Please be aware to __not__ set `reference.base` because this will break the binding to the cdn. 32 | 33 | 34 | ## NPM 35 | 36 | If you want to serve a special player version you can find the player as the npm package [@podlove/podlove-web-player](https://www.npmjs.com/package/@podlove/podlove-web-player). 37 | 38 | To integrate the player you first have to install tha package: 39 | 40 | ```javascript 41 | npm install @podlove/podlove-web-player --save 42 | ``` 43 | 44 | Afterwards move the player assets to a public folder of some webserver. By default the player will try to load further chunks from the webserver base. If the player files are located in a subpath you have to adapt the `reference.base` accordingly (see [config]({{ $withBase('config.html') }}) 45 | -------------------------------------------------------------------------------- /docs/player-dispatches.md: -------------------------------------------------------------------------------- 1 | --- 2 | navigation: 8 3 | --- 4 | 5 | # Player Dispatches 6 | 7 | ```javascript 8 | podlovePlayer(selector, config).then(function (store) { 9 | store.dispatch({ 10 | type: TYPE, 11 | payload: PAYLOAD 12 | }) 13 | }); 14 | ``` 15 | 16 | Every player interaction can be triggered via the [redux store](http://redux.js.org/docs/api/Store.html). 17 | Accessing the players store enables the full control of the player while running. The [color picker]() is a good example. 18 | 19 | You can find a complete list of available types in the [types definition](https://github.com/podlove/podlove-web-player/blob/development/src/store/types.js). 20 | 21 | ## Dispatching to the Player 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/player-subscriptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | navigation: 9 3 | --- 4 | 5 | # Player Subscriptions 6 | 7 | ```javascript 8 | podlovePlayer(selector, config).then(function (store) { 9 | store.subscribe(() => { 10 | const { lastAction } = store.getState() 11 | // Do something with the last action 12 | console.log({ type: lastAction.type, payload: lastAction.payload }) 13 | }) 14 | }); 15 | ``` 16 | 17 | Every player interaction is reflected in the [redux store](http://redux.js.org/docs/api/Store.html). 18 | You can subscribe to every player event by attatching to the latest action. 19 | 20 | You can find a complete list of available types in the [types definition](https://github.com/podlove/podlove-web-player/blob/development/src/store/types.js). 21 | 22 | ## Subscribing to the Player 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/playground.md: -------------------------------------------------------------------------------- 1 | --- 2 | navigation: 5 3 | --- 4 | 5 | # Playground 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/theme.md: -------------------------------------------------------------------------------- 1 | --- 2 | navigation: 4 3 | --- 4 | 5 | # Theming 6 | 7 | 8 | 9 | ## Color Calculation 10 | 11 | The player requires at least one main color. If not provided the default color will be used. 12 | Without setting a highlight color the essential control elements are colored black or white depending on a calculated [WCGA contrast ratio](https://www.w3.org/TR/WCAG20/#contrast-ratiodef). 13 | With a highlight color in place these control elements will be colored accordingly. 14 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "alias": "podlove-web-player", 3 | "type": "npm", 4 | "engines": { 5 | "node": "10.0.0" 6 | }, 7 | "files": [ 8 | "src", 9 | "build", 10 | "docs", 11 | ".babelrc" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/screenshot.png -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'core' 2 | 3 | // Store 4 | import store from 'store' 5 | import actions from 'store/actions' 6 | 7 | // UI Components 8 | import App from './components/App' 9 | 10 | export default config => { 11 | // Initialize meta for store 12 | store.dispatch(actions.init(config)) 13 | 14 | window.PODLOVE_STORE = store 15 | 16 | return createApp('PodlovePlayer', App) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/header/Error.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 28 | 29 | 62 | -------------------------------------------------------------------------------- /src/components/header/Header.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 30 | 31 | 39 | -------------------------------------------------------------------------------- /src/components/icons/AudioIcon.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 27 | -------------------------------------------------------------------------------- /src/components/icons/CalendarIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/ChapterBackIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/icons/ChapterNextIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/icons/ChaptersIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/icons/ClockIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/CloseIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/icons/CopyIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/icons/DownloadIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/icons/EmbedIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/icons/ErrorIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/icons/FacebookIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/icons/FilesAudioIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/icons/FollowIcon.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 24 | 25 | 30 | -------------------------------------------------------------------------------- /src/components/icons/InfoIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/LinkIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /src/components/icons/LinkedinIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/icons/MailIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/icons/MinusIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/icons/NextSearchIcon.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /src/components/icons/PauseIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /src/components/icons/PinterestIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/icons/PlayIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/components/icons/PlusIcon.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/icons/PreviousSearchIcon.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /src/components/icons/ReloadIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/icons/SearchDeleteIcon.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /src/components/icons/ShareChapterIcon.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /src/components/icons/ShareEpisodeIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/components/icons/ShareIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/icons/SharePlaytimeIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/icons/ShareShowIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/icons/StepBackIcon.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 27 | -------------------------------------------------------------------------------- /src/components/icons/StepForwardIcon.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 27 | -------------------------------------------------------------------------------- /src/components/icons/TranscriptsIcon.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 21 | -------------------------------------------------------------------------------- /src/components/icons/TwitterIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/icons/icon-container.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { mapState } from 'redux-vuex' 3 | 4 | export default iconComponent => Vue.component('icon', { 5 | props: ['width', 'height'], 6 | 7 | render: function (h) { 8 | return h(iconComponent, { 9 | props: { 10 | color: this.theme.icon.color, 11 | background: this.theme.icon.background, 12 | width: this.width, 13 | height: this.height 14 | } 15 | }) 16 | }, 17 | 18 | data: mapState('theme') 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/player/Player.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 31 | 32 | 45 | -------------------------------------------------------------------------------- /src/components/player/control-bar/ChapterNextButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 43 | -------------------------------------------------------------------------------- /src/components/player/control-bar/LoadingIndicator.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 58 | -------------------------------------------------------------------------------- /src/components/player/control-bar/StepBackButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 32 | -------------------------------------------------------------------------------- /src/components/player/control-bar/StepForwardButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 30 | -------------------------------------------------------------------------------- /src/components/player/progress-bar/ChapterIndicator.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 34 | 35 | 51 | -------------------------------------------------------------------------------- /src/components/player/progress-bar/CurrentChapter.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 46 | 47 | 58 | -------------------------------------------------------------------------------- /src/components/player/progress-bar/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /src/components/shared/ButtonGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 34 | -------------------------------------------------------------------------------- /src/components/shared/CopyTooltip.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 51 | 52 | 57 | -------------------------------------------------------------------------------- /src/components/shared/Footer.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/shared/InputGroup.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | 22 | 51 | -------------------------------------------------------------------------------- /src/components/shared/InputSelect.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 34 | 35 | 51 | -------------------------------------------------------------------------------- /src/components/shared/InputText.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | 23 | 43 | -------------------------------------------------------------------------------- /src/components/shared/TabBody.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | 23 | 40 | -------------------------------------------------------------------------------- /src/components/tabs/audio/Audio.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 30 | 31 | 39 | -------------------------------------------------------------------------------- /src/components/tabs/chapters/Chapters.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | 28 | 36 | -------------------------------------------------------------------------------- /src/components/tabs/share/channels/ChannelEmbed.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 28 | -------------------------------------------------------------------------------- /src/components/tabs/share/channels/ChannelFacebook.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/tabs/share/channels/ChannelLinkedin.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 34 | -------------------------------------------------------------------------------- /src/components/tabs/share/channels/ChannelMail.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 34 | -------------------------------------------------------------------------------- /src/components/tabs/share/channels/ChannelPinterest.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/tabs/share/channels/ChannelReddit.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/tabs/share/channels/ChannelTwitter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 36 | -------------------------------------------------------------------------------- /src/components/tabs/transcripts/Follow.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 44 | -------------------------------------------------------------------------------- /src/components/tabs/transcripts/Prerender.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 29 | -------------------------------------------------------------------------------- /src/core/directives/index.js: -------------------------------------------------------------------------------- 1 | import resize from './resize' 2 | import marquee from './marquee' 3 | 4 | const registerDirectives = context => { 5 | context.Renderer.directive('resize', resize) 6 | context.Renderer.directive('marquee', marquee) 7 | 8 | return context 9 | } 10 | 11 | export { 12 | registerDirectives 13 | } 14 | -------------------------------------------------------------------------------- /src/core/directives/marquee.js: -------------------------------------------------------------------------------- 1 | import { setStyles, addClasses, removeClasses } from 'utils/dom' 2 | 3 | const marquee = el => { 4 | const scroller = el.firstChild 5 | 6 | const animationDuration = scroller.scrollWidth / 50 7 | 8 | setStyles({ 9 | 'white-space': 'nowrap', 10 | 'overflow-x': 'hidden' 11 | })(scroller) 12 | 13 | setStyles({ 14 | height: `${el.offsetHeight}px` 15 | })(el) 16 | 17 | setStyles({ 18 | height: `${scroller.offsetHeight}px`, 19 | width: 'auto' 20 | })(scroller) 21 | 22 | setStyles({ 23 | 'overflow-x': 'visible' 24 | })(scroller) 25 | 26 | setTimeout(() => { 27 | if (scroller.scrollWidth > el.offsetWidth) { 28 | addClasses('marquee-container')(el) 29 | addClasses('marquee')(scroller) 30 | setStyles({ 31 | 'animation-duration': `${animationDuration > 10 ? animationDuration : 10}s`, // min 10s 32 | width: `${scroller.scrollWidth}px` 33 | })(scroller) 34 | } else { 35 | removeClasses('marquee-container')(el) 36 | removeClasses('marquee')(scroller) 37 | } 38 | }, 0) 39 | } 40 | 41 | export default { 42 | bind (el) { 43 | window.addEventListener('resize', () => marquee(el)) 44 | }, 45 | inserted: marquee, 46 | update: marquee 47 | } 48 | -------------------------------------------------------------------------------- /src/core/directives/resize.js: -------------------------------------------------------------------------------- 1 | /* global MutationObserver */ 2 | 3 | export default { 4 | bind (el, { value }) { 5 | const observer = new MutationObserver(value) 6 | 7 | observer.observe(el, { childList: true }) 8 | window.addEventListener('resize', value) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { compose } from 'lodash/fp' 3 | import { head } from 'lodash' 4 | import { registerDirectives } from './directives' 5 | import { registerLang } from './lang' 6 | 7 | const registerApp = context => { 8 | return new context.Renderer({ 9 | i18n: context.i18n, 10 | el: head(document.getElementsByTagName(context.selector)), 11 | render: h => h(context.App) 12 | }) 13 | } 14 | 15 | const boot = compose(registerApp, registerLang, registerDirectives) 16 | 17 | export const createApp = (selector, App) => { 18 | return boot({ 19 | Renderer: Vue, 20 | App, 21 | selector 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/core/lang/index.js: -------------------------------------------------------------------------------- 1 | import VueI18n from 'vue-i18n' 2 | 3 | const messages = { 4 | en: require('../../lang/en.json'), 5 | de: require('../../lang/de.json'), 6 | eo: require('../../lang/eo.json'), 7 | ru: require('../../lang/ru.json') 8 | } 9 | 10 | export const registerLang = context => { 11 | context.Renderer.use(VueI18n) 12 | 13 | return { 14 | ...context, 15 | i18n: new VueI18n({ 16 | locale: 'en', 17 | fallbackLocale: 'en', 18 | messages 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/effects/components/index.js: -------------------------------------------------------------------------------- 1 | import episodeComponents from './episode' 2 | import liveComponents from './live' 3 | 4 | export default (store, action) => { 5 | const state = store.getState() 6 | 7 | if (state.mode === 'live') { 8 | liveComponents(store, action) 9 | } else { 10 | episodeComponents(store, action) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/effects/playback.js: -------------------------------------------------------------------------------- 1 | import actions from 'store/actions' 2 | import { SET_PLAYBACK_PARAMS, SET_PLAYTIME } from 'store/types' 3 | 4 | import { handleActions } from 'utils/effects' 5 | 6 | let paused = false 7 | 8 | export default handleActions({ 9 | [SET_PLAYBACK_PARAMS]: ({ dispatch }, { payload }) => { 10 | if (payload.starttime) { 11 | dispatch(actions.setPlaytime(payload.starttime)) 12 | dispatch(actions.idle()) 13 | } 14 | 15 | if (payload.autoplay) { 16 | dispatch(actions.play()) 17 | } 18 | }, 19 | 20 | [SET_PLAYTIME]: ({ dispatch }, { payload }, state) => { 21 | if (!state.playback.stoptime) { 22 | return 23 | } 24 | 25 | if (state.playback.stoptime <= payload && paused === false) { 26 | dispatch(actions.pause()) 27 | paused = true 28 | } 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /src/effects/quantiles.js: -------------------------------------------------------------------------------- 1 | import actions from 'store/actions' 2 | import { SET_PLAYTIME, NEXT_CHAPTER, PREVIOUS_CHAPTER, UPDATE_PLAYTIME } from 'store/types' 3 | 4 | import { handleActions } from 'utils/effects' 5 | 6 | let startTime = null 7 | 8 | const resetStarttime = () => { 9 | startTime = null 10 | } 11 | 12 | export default handleActions({ 13 | [SET_PLAYTIME]: ({ dispatch }, { payload }) => { 14 | if (!startTime) { 15 | startTime = payload 16 | } 17 | 18 | dispatch(actions.setQuantile(startTime, payload)) 19 | }, 20 | 21 | [NEXT_CHAPTER]: resetStarttime, 22 | [PREVIOUS_CHAPTER]: resetStarttime, 23 | [UPDATE_PLAYTIME]: resetStarttime 24 | }) 25 | -------------------------------------------------------------------------------- /src/effects/runtime.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | 3 | import { INIT } from 'store/types' 4 | import actions from 'store/actions' 5 | import runtime from 'utils/runtime' 6 | 7 | import { handleActions } from 'utils/effects' 8 | 9 | export default handleActions({ 10 | [INIT]: ({ dispatch }, { payload }) => { 11 | const config = get(payload, 'runtime', {}) 12 | 13 | dispatch(actions.setRuntime({ 14 | ...runtime, 15 | ...config 16 | })) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /src/effects/storage/index.js: -------------------------------------------------------------------------------- 1 | import episodeStorage from './episode' 2 | import liveStorage from './live' 3 | 4 | export default storage => (store, action) => { 5 | const state = store.getState() 6 | 7 | if (state.mode === 'live') { 8 | liveStorage(storage)(store, action) 9 | } else { 10 | episodeStorage(storage)(store, action) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/effects/storage/live.js: -------------------------------------------------------------------------------- 1 | import { get, noop } from 'lodash' 2 | import { hashCode } from 'hashcode' 3 | 4 | import { handleActions } from 'utils/effects' 5 | 6 | import actions from 'store/actions' 7 | import { INIT, SET_VOLUME, TOGGLE_TAB } from 'store/types' 8 | 9 | let storage = { 10 | get: noop, 11 | set: noop 12 | } 13 | 14 | const metaHash = config => 15 | hashCode().value({ 16 | ...config, 17 | playtime: 0 18 | }) 19 | 20 | export default storageFactory => handleActions({ 21 | [INIT]: ({ dispatch }, { payload }) => { 22 | storage = storageFactory(metaHash(payload)) 23 | 24 | const storedTabs = storage.get('tabs') 25 | const storedVolume = storage.get('volume') 26 | 27 | if (storedTabs) { 28 | dispatch(actions.setTabs(storedTabs)) 29 | } 30 | 31 | if (storedVolume) { 32 | dispatch(actions.setVolume(storedVolume)) 33 | } 34 | }, 35 | 36 | [SET_VOLUME]: (store, action, state) => { 37 | const volume = get(state, 'volume', 1) 38 | storage.set('volume', volume) 39 | }, 40 | 41 | [TOGGLE_TAB]: (store, action, state) => { 42 | const tabs = get(state, 'tabs', {}) 43 | storage.set('tabs', tabs) 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /src/effects/transcripts/active.js: -------------------------------------------------------------------------------- 1 | import { binarySearch } from 'utils/binary-search' 2 | 3 | import { noop, debounce } from 'lodash' 4 | import { compose } from 'lodash/fp' 5 | 6 | import { prohibitiveDispatch, handleActions } from 'utils/effects' 7 | import { inAnimationFrame } from 'utils/helper' 8 | 9 | import actions from 'store/actions' 10 | import { SET_TRANSCRIPTS_TIMELINE, SET_TRANSCRIPTS_CHAPTERS, SET_PLAYTIME, UPDATE_PLAYTIME, DISABLE_GHOST_MODE, SIMULATE_PLAYTIME } from 'store/types' 11 | 12 | let update = noop 13 | let debouncedUpdate = noop 14 | 15 | const createIndex = ({ dispatch }, { payload = [] }, { playtime }) => { 16 | const searchIndex = payload.map(({ start }) => start) 17 | 18 | update = inAnimationFrame( 19 | compose( 20 | prohibitiveDispatch(dispatch, actions.updateTranscripts), 21 | binarySearch(searchIndex) 22 | ) 23 | ) 24 | 25 | debouncedUpdate = debounce(update, 200) 26 | update(playtime) 27 | } 28 | 29 | export default handleActions({ 30 | [SET_TRANSCRIPTS_TIMELINE]: createIndex, 31 | [SET_TRANSCRIPTS_CHAPTERS]: createIndex, 32 | 33 | [SET_PLAYTIME]: (_, { payload }) => update(payload), 34 | [UPDATE_PLAYTIME]: (_, { payload }) => update(payload), 35 | 36 | [DISABLE_GHOST_MODE]: (_, action, { playtime }) => debouncedUpdate(playtime), 37 | [SIMULATE_PLAYTIME]: (_, { payload }) => debouncedUpdate(payload) 38 | }) 39 | -------------------------------------------------------------------------------- /src/effects/transcripts/index.js: -------------------------------------------------------------------------------- 1 | // Fetches data from given transcript, transforms fetched data and dispatches transformed result to store 2 | import fetchEffects from './fetch' 3 | import activeEffects from './active' 4 | import searchEffects from './search' 5 | 6 | import { callWith } from 'utils/helper' 7 | 8 | const effects = [fetchEffects, activeEffects, searchEffects] 9 | 10 | export default (store, action) => effects.map(callWith(store, action)) 11 | -------------------------------------------------------------------------------- /src/effects/transcripts/search.js: -------------------------------------------------------------------------------- 1 | import { textSearch } from 'utils/text-search' 2 | 3 | import { noop } from 'lodash' 4 | import { compose } from 'lodash/fp' 5 | 6 | import { prohibitiveDispatch, handleActions } from 'utils/effects' 7 | import { inAnimationFrame } from 'utils/helper' 8 | 9 | import actions from 'store/actions' 10 | import { SET_TRANSCRIPTS_TIMELINE, SEARCH_TRANSCRIPTS } from 'store/types' 11 | 12 | let search = noop 13 | 14 | export default handleActions({ 15 | [SET_TRANSCRIPTS_TIMELINE]: ({ dispatch }, { payload = [] }) => { 16 | const searchIndex = payload 17 | .map(({ texts = [] }) => texts.map(({ text }) => text).join(' ')) 18 | 19 | search = inAnimationFrame( 20 | compose( 21 | prohibitiveDispatch(dispatch, actions.setTranscriptsSearchResults), 22 | textSearch(searchIndex) 23 | ) 24 | ) 25 | }, 26 | 27 | [SEARCH_TRANSCRIPTS]: (store, { payload }) => search(payload) 28 | }) 29 | -------------------------------------------------------------------------------- /src/effects/transcripts/search.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import sinon from 'sinon' 3 | import browserEnv from 'browser-env' 4 | 5 | import searchEffect from './search' 6 | import { timeline, state } from './fixtures' 7 | 8 | browserEnv(['window']) 9 | 10 | let store 11 | 12 | test.beforeEach(t => { 13 | window.requestAnimationFrame = cb => cb() 14 | 15 | store = { 16 | dispatch: sinon.stub(), 17 | getState: () => state 18 | } 19 | }) 20 | 21 | test(`transcripts - search: exports a function`, t => { 22 | t.is(typeof searchEffect, 'function') 23 | }) 24 | 25 | test(`transcripts - search: creates an search index on SET_TRANSCRIPTS_TIMELINE`, t => { 26 | searchEffect(store, { 27 | type: 'SET_TRANSCRIPTS_TIMELINE', 28 | payload: timeline 29 | }) 30 | 31 | searchEffect(store, { 32 | type: 'SEARCH_TRANSCRIPTS', 33 | payload: 'fooo' 34 | }) 35 | 36 | t.deepEqual(store.dispatch.getCall(0).args[0], { 37 | type: 'SET_SEARCH_TRANSCRIPTS_RESULTS', payload: [ 1 ] 38 | }) 39 | }) 40 | 41 | test(`transcripts - doesn't dispatch if no results are found`, t => { 42 | searchEffect(store, { 43 | type: 'SET_TRANSCRIPTS_TIMELINE', 44 | payload: timeline 45 | }) 46 | 47 | searchEffect(store, { 48 | type: 'SEARCH_TRANSCRIPTS', 49 | payload: 'xyz' 50 | }) 51 | 52 | t.deepEqual(store.dispatch.getCall(0).args[0], { 53 | type: 'SET_SEARCH_TRANSCRIPTS_RESULTS', payload: [] 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/effects/volume.js: -------------------------------------------------------------------------------- 1 | import actions from 'store/actions' 2 | import { SET_VOLUME } from 'store/types' 3 | 4 | import { handleActions } from 'utils/effects' 5 | 6 | export default handleActions({ 7 | [SET_VOLUME]: ({ dispatch }, { payload }) => 8 | payload <= 0 ? dispatch(actions.mute()) : dispatch(actions.unmute()) 9 | }) 10 | -------------------------------------------------------------------------------- /src/effects/volume.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import sinon from 'sinon' 3 | 4 | import volumeEffects from './volume' 5 | 6 | let store 7 | 8 | test.beforeEach(t => { 9 | store = { 10 | dispatch: sinon.stub(), 11 | getState: () => {} 12 | } 13 | }) 14 | 15 | test(`volumeEffects: it dispatches MUTE on SET_VOLUME if volume equals 0`, t => { 16 | volumeEffects(store, { 17 | type: 'SET_VOLUME', 18 | payload: 0 19 | }) 20 | 21 | t.deepEqual(store.dispatch.getCall(0).args[0], { 22 | type: 'MUTE' 23 | }) 24 | }) 25 | 26 | test(`volumeEffects: it dispatches UNMUTE on SET_VOLUME if volume is greate than 0`, t => { 27 | volumeEffects(store, { 28 | type: 'SET_VOLUME', 29 | payload: 0.2 30 | }) 31 | 32 | t.deepEqual(store.dispatch.getCall(0).args[0], { 33 | type: 'UNMUTE' 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/embed/loader.js: -------------------------------------------------------------------------------- 1 | import color from 'color' 2 | import { get } from 'lodash' 3 | 4 | import { tag } from 'utils/dom' 5 | // eslint-disable-next-line 6 | import css from '!css-loader!sass-loader!../styles/_loader.scss' 7 | 8 | const style = tag('style', css.toString()) 9 | 10 | const dom = ({ theme }) => { 11 | const light = '#fff' 12 | const dark = '#000' 13 | 14 | const main = get(theme, 'main', '#2B8AC6') 15 | const luminosity = color(main).luminosity() 16 | 17 | const highlight = get(theme, 'highlight', luminosity < 0.25 ? light : dark) 18 | 19 | return `
20 |
21 |
22 |
23 |
` 24 | } 25 | 26 | const script = tag('script', ` 27 | var loader = document.getElementById('loader') 28 | 29 | window.addEventListener('load', function() { 30 | loader.className += ' done' 31 | 32 | setTimeout(function () { 33 | loader.parentNode.removeChild(loader) 34 | }, 300) 35 | }) 36 | `) 37 | 38 | export default config => ([style, dom(config), script].join('')) 39 | -------------------------------------------------------------------------------- /src/embed/share.js: -------------------------------------------------------------------------------- 1 | import { urlParameters } from 'utils/url' 2 | import remoteConfig from 'utils/request' 3 | 4 | import { SET_PLAYBACK_PARAMS } from 'store/types' 5 | 6 | import app from '../app' 7 | 8 | remoteConfig(urlParameters.episode) 9 | .then(config => ({ ...config, display: 'embed' })) 10 | .then(app) 11 | .then(() => window.PODLOVE_STORE) 12 | .then(store => { 13 | store.dispatch({ 14 | type: SET_PLAYBACK_PARAMS, 15 | payload: urlParameters 16 | }) 17 | return store 18 | }) 19 | .catch(err => { 20 | console.group(`Can't load Podlove Webplayer`) 21 | console.error('config', urlParameters.episode) 22 | console.error(err) 23 | console.groupEnd() 24 | }) 25 | -------------------------------------------------------------------------------- /src/embed/window.js: -------------------------------------------------------------------------------- 1 | import app from '../app' 2 | 3 | app(window.PODLOVE) 4 | -------------------------------------------------------------------------------- /src/extensions/external-events.js: -------------------------------------------------------------------------------- 1 | import { compose, get } from 'lodash/fp' 2 | import { toPlayerTime } from 'utils/time' 3 | import actions from 'store/actions' 4 | 5 | const isPlayerInstance = id => (data = {}) => data.ref === id ? data : {} 6 | 7 | const handleAction = store => (data = {}) => { 8 | const action = actions[data.action] 9 | action && store.dispatch(action()) 10 | return data 11 | } 12 | 13 | const handleTab = store => (data = {}) => { 14 | data.tab && store.dispatch(actions.toggleTab(data.tab)) 15 | return data 16 | } 17 | 18 | const handleTime = store => (data = {}) => { 19 | data.time && store.dispatch(actions.setPlaytime(toPlayerTime(data.time))) 20 | return data 21 | } 22 | 23 | const eventHandler = (id, store) => 24 | compose( 25 | handleTime(store), 26 | handleTab(store), 27 | handleAction(store), 28 | isPlayerInstance(id), 29 | get('target.dataset') 30 | ) 31 | 32 | /** 33 | * External Events registry 34 | * 35 | * rel="podlove-web-player" 36 | * data-ref="web-player-id" 37 | * data-action="play|pause" 38 | * data-tab="info" 39 | * data-time="00:10:12" 40 | */ 41 | window.registerExternalEvents = id => store => { 42 | const references = [...document.querySelectorAll('[rel="podlove-web-player"]')] 43 | references.forEach(ref => ref.addEventListener('click', eventHandler(id, store))) 44 | 45 | return store 46 | } 47 | -------------------------------------------------------------------------------- /src/media/index.js: -------------------------------------------------------------------------------- 1 | import { audio, events as audioEvents, actions as audioActions } from '@podlove/html5-audio-driver' 2 | import { attatchStream } from '@podlove/html5-audio-driver/hls' 3 | 4 | export default (audioFiles) => { 5 | const audioElement = attatchStream(audio(audioFiles)) 6 | 7 | return { 8 | events: audioEvents(audioElement), 9 | actions: audioActions(audioElement) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/statics/example/files/poster.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/statics/example/files/poster.jpg -------------------------------------------------------------------------------- /src/statics/example/standalone.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/statics/share.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import * as buffer from './buffer/actions' 2 | import * as chapters from './chapters/actions' 3 | import * as components from './components/actions' 4 | import * as duration from './duration/actions' 5 | import * as error from './error/actions' 6 | import * as ghost from './ghost/actions' 7 | import * as muted from './muted/actions' 8 | import * as player from './player/actions' 9 | import * as playstate from './playstate/actions' 10 | import * as playtime from './playtime/actions' 11 | import * as quantiles from './quantiles/actions' 12 | import * as rate from './rate/actions' 13 | import * as runtime from './runtime/actions' 14 | import * as share from './share/actions' 15 | import * as tabs from './tabs/actions' 16 | import * as theme from './theme/actions' 17 | import * as transcripts from './transcripts/actions' 18 | import * as volume from './volume/actions' 19 | import * as playback from './playback/actions' 20 | 21 | export default { 22 | ...buffer, 23 | ...chapters, 24 | ...components, 25 | ...duration, 26 | ...error, 27 | ...ghost, 28 | ...muted, 29 | ...player, 30 | ...playstate, 31 | ...playtime, 32 | ...quantiles, 33 | ...rate, 34 | ...runtime, 35 | ...share, 36 | ...tabs, 37 | ...theme, 38 | ...transcripts, 39 | ...volume, 40 | ...playback 41 | } 42 | -------------------------------------------------------------------------------- /src/store/buffer/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_BUFFER } from '../types' 4 | 5 | export const setBuffer = createAction(SET_BUFFER) 6 | -------------------------------------------------------------------------------- /src/store/buffer/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { setBuffer } from './actions' 3 | 4 | test(`setBufferAction: creates the SET_BUFFER action`, t => { 5 | t.deepEqual(setBuffer(10), { 6 | type: 'SET_BUFFER', 7 | payload: 10 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/store/buffer/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/buffer/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { SET_BUFFER } from '../types' 4 | 5 | export const INITIAL_STATE = 0 6 | 7 | export const reducer = handleActions({ 8 | [SET_BUFFER]: (state, { payload }) => payload 9 | }, INITIAL_STATE) 10 | -------------------------------------------------------------------------------- /src/store/buffer/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as buffer } from './reducer' 3 | 4 | // BUFFER TESTS 5 | test(`buffer: is a reducer function`, t => { 6 | t.is(typeof buffer, 'function') 7 | }) 8 | 9 | test(`buffer: parses the buffer on SET_BUFFER`, t => { 10 | let result = buffer(undefined, { 11 | type: 'SET_BUFFER', 12 | payload: 60 13 | }) 14 | 15 | t.is(result, 60) 16 | }) 17 | 18 | test(`buffer: it does nothing if a unknown action is dispatched`, t => { 19 | const result = buffer(10, { 20 | type: 'NOT_A_REAL_TYPE' 21 | }) 22 | t.is(result, 10) 23 | }) 24 | -------------------------------------------------------------------------------- /src/store/chapters/actions.js: -------------------------------------------------------------------------------- 1 | import { NEXT_CHAPTER, SET_NEXT_CHAPTER, PREVIOUS_CHAPTER, SET_PREVIOUS_CHAPTER, SET_CHAPTER, UPDATE_CHAPTER, INIT_CHAPTERS } from '../types' 2 | 3 | import { createAction } from 'redux-actions' 4 | 5 | export const nextChapter = createAction(NEXT_CHAPTER) 6 | export const setNextChapter = createAction(SET_NEXT_CHAPTER) 7 | export const previousChapter = createAction(PREVIOUS_CHAPTER) 8 | export const setPreviousChapter = createAction(SET_PREVIOUS_CHAPTER) 9 | export const setChapter = createAction(SET_CHAPTER) 10 | export const updateChapter = createAction(UPDATE_CHAPTER) 11 | export const initChapters = createAction(INIT_CHAPTERS) 12 | -------------------------------------------------------------------------------- /src/store/chapters/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { setChapter, nextChapter, previousChapter, updateChapter } from './actions' 3 | 4 | test(`nextChapter: creates the NEXT_CHAPTER action`, t => { 5 | t.deepEqual(nextChapter(), { 6 | type: 'NEXT_CHAPTER' 7 | }) 8 | }) 9 | 10 | test(`previousChapter: creates the PREVIOUS_CHAPTER action`, t => { 11 | t.deepEqual(previousChapter(), { 12 | type: 'PREVIOUS_CHAPTER' 13 | }) 14 | }) 15 | 16 | test(`setChapter: creates the SET_CHAPTER action`, t => { 17 | t.deepEqual(setChapter(1), { 18 | type: 'SET_CHAPTER', 19 | payload: 1 20 | }) 21 | }) 22 | 23 | test(`setChapter: creates the UPDATE_CHAPTER action`, t => { 24 | t.deepEqual(updateChapter(1), { 25 | type: 'UPDATE_CHAPTER', 26 | payload: 1 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/store/chapters/index.js: -------------------------------------------------------------------------------- 1 | import * as selectors from './selectors' 2 | 3 | export * from './actions' 4 | export * from './reducer' 5 | 6 | export { 7 | selectors 8 | } 9 | -------------------------------------------------------------------------------- /src/store/chapters/selectors.js: -------------------------------------------------------------------------------- 1 | import { get, compose } from 'lodash/fp' 2 | 3 | export const selectChapters = get('list') 4 | export const selectNextChapters = get('next') 5 | export const selectPreviousChapter = get('previous') 6 | export const selectCurrentChapter = get('current') 7 | export const selectCurrentChapterTitle = compose(get('title'), selectCurrentChapter) 8 | export const selectCurrentChapterImage = compose(get('image'), selectCurrentChapter) 9 | -------------------------------------------------------------------------------- /src/store/components/effects.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/store/components/effects.js -------------------------------------------------------------------------------- /src/store/components/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/display/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/display/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { get } from 'lodash' 3 | 4 | import { INIT } from '../types' 5 | 6 | export const INITIAL_STATE = 'native' 7 | 8 | export const reducer = handleActions({ 9 | [INIT]: (state, { payload }) => get(payload, 'display', INITIAL_STATE) 10 | }, INITIAL_STATE) 11 | -------------------------------------------------------------------------------- /src/store/display/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as display } from './reducer' 3 | 4 | let testAction 5 | 6 | test.beforeEach(t => { 7 | testAction = { 8 | type: 'INIT', 9 | payload: {} 10 | } 11 | }) 12 | 13 | // display TESTS 14 | test(`display: it is a reducer function`, t => { 15 | t.is(typeof display, 'function') 16 | }) 17 | 18 | test(`display: it extracts the display`, t => { 19 | testAction.payload.display = 'embed' 20 | const result = display('', testAction) 21 | t.is(result, 'embed') 22 | }) 23 | 24 | test(`display: it returns native if a display is not available`, t => { 25 | let result = display(undefined, testAction) 26 | t.is(result, 'native') 27 | }) 28 | 29 | test(`display: it does nothing if not the init action is dispatched`, t => { 30 | const result = display('foobar', { 31 | type: 'NOT_A_REAL_TYPE' 32 | }) 33 | t.is(result, 'foobar') 34 | }) 35 | -------------------------------------------------------------------------------- /src/store/duration/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_DURATION } from '../types' 4 | 5 | export const setDuration = createAction(SET_DURATION) 6 | -------------------------------------------------------------------------------- /src/store/duration/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { setDuration } from './actions' 3 | 4 | test(`setDurationAction: creates the SET_DURATION action`, t => { 5 | t.deepEqual(setDuration(10), { 6 | type: 'SET_DURATION', 7 | payload: 10 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/store/duration/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/duration/reducer.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | import { handleActions } from 'redux-actions' 3 | 4 | import { toPlayerTime } from 'utils/time' 5 | import { toInt } from 'utils/helper' 6 | 7 | import { INIT, SET_DURATION } from '../types' 8 | 9 | export const INITIAL_STATE = 0 10 | 11 | export const reducer = handleActions({ 12 | [INIT]: (state, { payload }) => toPlayerTime(get(payload, 'duration', state)), 13 | [SET_DURATION]: (state, { payload }) => toInt(payload || state) 14 | }, INITIAL_STATE) 15 | -------------------------------------------------------------------------------- /src/store/duration/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as duration } from './reducer' 3 | 4 | test(`duration: is a reducer function`, t => { 5 | t.is(typeof duration, 'function') 6 | }) 7 | 8 | test(`duration: parses the duration on INIT`, t => { 9 | let result = duration(undefined, { 10 | type: 'INIT', 11 | payload: { 12 | duration: '01:00' 13 | } 14 | }) 15 | 16 | t.is(result, 60000) 17 | 18 | result = duration(10, { 19 | type: 'INIT', 20 | payload: {} 21 | }) 22 | 23 | t.is(result, 10) 24 | }) 25 | 26 | test(`duration: parses duration on SET_DURATION`, t => { 27 | let result = duration(undefined, { 28 | type: 'SET_DURATION', 29 | payload: 60 30 | }) 31 | 32 | t.is(result, 60) 33 | }) 34 | 35 | test(`duration: sets state if duration is undefined on SET_DURATION`, t => { 36 | let result = duration(30, { 37 | type: 'SET_DURATION' 38 | }) 39 | 40 | t.is(result, 30) 41 | }) 42 | 43 | test(`duration: it does nothing if a unknown action is dispatched`, t => { 44 | const result = duration(10, { 45 | type: 'NOT_A_REAL_TYPE' 46 | }) 47 | t.is(result, 10) 48 | }) 49 | -------------------------------------------------------------------------------- /src/store/episode/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/episode/reducer.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | import { handleActions } from 'redux-actions' 3 | 4 | import { parseDate } from 'utils/time' 5 | import { sanitize } from 'utils/dom' 6 | 7 | import { INIT } from '../types' 8 | 9 | export const INIT_STATE = { 10 | title: null, 11 | subtitle: null, 12 | summary: null, 13 | poster: null, 14 | link: null, 15 | publicationDate: null 16 | } 17 | 18 | export const reducer = handleActions({ 19 | [INIT]: (state, { payload }) => ({ 20 | ...state, 21 | title: get(payload, ['title'], null), 22 | subtitle: get(payload, ['subtitle'], null), 23 | summary: sanitize(get(payload, ['summary'], null)), 24 | link: get(payload, ['link'], null), 25 | poster: get(payload, ['poster'], null), 26 | publicationDate: parseDate(get(payload, ['publicationDate'], null)) 27 | }) 28 | }, INIT_STATE) 29 | -------------------------------------------------------------------------------- /src/store/error/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { ERROR_MISSING_AUDIO_FILES } from '../types' 4 | 5 | export const errorLoad = error => ({ 6 | type: error 7 | }) 8 | 9 | export const errorMissingAudioFiles = createAction(ERROR_MISSING_AUDIO_FILES) 10 | -------------------------------------------------------------------------------- /src/store/error/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { errorLoad, errorMissingAudioFiles } from './actions' 3 | 4 | test(`errorLoad: creates the NETWORK_NO_SOURCE action`, t => { 5 | t.deepEqual(errorLoad('NETWORK_NO_SOURCE'), { 6 | type: 'NETWORK_NO_SOURCE' 7 | }) 8 | }) 9 | 10 | test(`errorMissingAudioFiles: creates the ERROR_MISSING_AUDIO_FILES action`, t => { 11 | t.deepEqual(errorMissingAudioFiles(), { 12 | type: 'ERROR_MISSING_AUDIO_FILES' 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/store/error/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/error/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { NETWORK_EMPTY, NETWORK_NO_SOURCE, ERROR_MISSING_AUDIO_FILES } from '../types' 4 | 5 | export const INITIAL_STATE = { 6 | message: null, 7 | title: null 8 | } 9 | 10 | const loadingError = { 11 | title: 'ERROR.LOADING.TITLE', 12 | message: 'ERROR.LOADING.MESSAGE' 13 | } 14 | 15 | export const reducer = handleActions({ 16 | [NETWORK_EMPTY]: () => loadingError, 17 | [NETWORK_NO_SOURCE]: () => loadingError, 18 | 19 | [ERROR_MISSING_AUDIO_FILES]: () => ({ 20 | title: 'ERROR.MISSING_FILES.TITLE', 21 | message: 'ERROR.MISSING_FILES.MESSAGE' 22 | }) 23 | }, INITIAL_STATE) 24 | -------------------------------------------------------------------------------- /src/store/error/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as error } from './reducer' 3 | 4 | test(`error: it exports a reducer function`, t => { 5 | t.truthy(typeof error === 'function') 6 | }) 7 | 8 | test(`error: it returns the initial state on default`, t => { 9 | const result = error(undefined, { 10 | action: 'FOO' 11 | }) 12 | 13 | t.deepEqual(result, { 14 | message: null, 15 | title: null 16 | }) 17 | }) 18 | 19 | const types = ['NETWORK_EMPTY', 'NETWORK_NO_SOURCE'] 20 | 21 | types.forEach(type => { 22 | test(`error: it sets the message and title on ${type}`, t => { 23 | const result = error(undefined, { type }) 24 | 25 | t.deepEqual(result, { 26 | title: 'ERROR.LOADING.TITLE', 27 | message: 'ERROR.LOADING.MESSAGE' 28 | }) 29 | }) 30 | }) 31 | 32 | test(`error: it sets the message and title on ERROR_MISSING_AUDIO_FILES`, t => { 33 | const result = error(undefined, { 34 | type: 'ERROR_MISSING_AUDIO_FILES' 35 | }) 36 | 37 | t.deepEqual(result, { 38 | title: 'ERROR.MISSING_FILES.TITLE', 39 | message: 'ERROR.MISSING_FILES.MESSAGE' 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/store/files/index.js: -------------------------------------------------------------------------------- 1 | import * as selectors from './selectors' 2 | 3 | export * from './reducer' 4 | 5 | export { 6 | selectors 7 | } 8 | -------------------------------------------------------------------------------- /src/store/files/reducer.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | import { handleActions } from 'redux-actions' 3 | 4 | import { INIT } from '../types' 5 | 6 | export const INITIAL_STATE = { 7 | audio: [] 8 | } 9 | 10 | export const reducer = handleActions({ 11 | [INIT]: (_, { payload }) => ({ 12 | audio: get(payload, 'audio', []) 13 | }) 14 | 15 | }, INITIAL_STATE) 16 | -------------------------------------------------------------------------------- /src/store/files/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as files } from './reducer' 3 | 4 | let testAction 5 | 6 | test.beforeEach(t => { 7 | testAction = { 8 | type: 'INIT', 9 | payload: { 10 | audio: [{ 11 | url: 'http://foo.bar' 12 | }, { 13 | url: 'http://foo.baz' 14 | }] 15 | } 16 | } 17 | }) 18 | 19 | test(`files: it is a reducer function`, t => { 20 | t.is(typeof files, 'function') 21 | }) 22 | 23 | test(`files: it extracts the audio meta information`, t => { 24 | const result = files({}, testAction) 25 | 26 | t.deepEqual(result, { 27 | audio: [{ 28 | url: 'http://foo.bar' 29 | }, { 30 | url: 'http://foo.baz' 31 | }] 32 | }) 33 | }) 34 | 35 | test(`files: it does nothing if not a registered action is dispatched`, t => { 36 | const result = files('foobar', { 37 | type: 'NOT_A_REAL_TYPE' 38 | }) 39 | t.is(result, 'foobar') 40 | }) 41 | -------------------------------------------------------------------------------- /src/store/files/selectors.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash/fp' 2 | 3 | export const selectAudio = get('audio') 4 | -------------------------------------------------------------------------------- /src/store/ghost/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SIMULATE_PLAYTIME, ENABLE_GHOST_MODE, DISABLE_GHOST_MODE } from '../types' 4 | 5 | export const simulatePlaytime = createAction(SIMULATE_PLAYTIME) 6 | export const enableGhostMode = createAction(ENABLE_GHOST_MODE) 7 | export const disableGhostMode = createAction(DISABLE_GHOST_MODE) 8 | -------------------------------------------------------------------------------- /src/store/ghost/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { simulatePlaytime, enableGhostMode, disableGhostMode } from './actions' 3 | 4 | test(`simulatePlaytime: creates the SIMULATE_PLAYTIME action`, t => { 5 | t.deepEqual(simulatePlaytime(100), { 6 | type: 'SIMULATE_PLAYTIME', 7 | payload: 100 8 | }) 9 | }) 10 | 11 | test(`enableGhostMode: creates the ENABLE_GHOST_MODE action`, t => { 12 | t.deepEqual(enableGhostMode(), { 13 | type: 'ENABLE_GHOST_MODE' 14 | }) 15 | }) 16 | 17 | test(`disableGhostMode: creates the DISABLE_GHOST_MODE action`, t => { 18 | t.deepEqual(disableGhostMode(), { 19 | type: 'DISABLE_GHOST_MODE' 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/store/ghost/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/ghost/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { toInt } from 'utils/helper' 3 | 4 | import { SIMULATE_PLAYTIME, ENABLE_GHOST_MODE, DISABLE_GHOST_MODE } from '../types' 5 | 6 | export const INITIAL_STATE = { 7 | time: 0, 8 | active: false 9 | } 10 | 11 | export const reducer = handleActions({ 12 | [SIMULATE_PLAYTIME]: (state, { payload }) => ({ 13 | ...state, 14 | time: toInt(payload) 15 | }), 16 | 17 | [ENABLE_GHOST_MODE]: (state) => ({ 18 | ...state, 19 | active: true 20 | }), 21 | 22 | [DISABLE_GHOST_MODE]: (state) => ({ 23 | ...state, 24 | active: false 25 | }) 26 | }, INITIAL_STATE) 27 | -------------------------------------------------------------------------------- /src/store/ghost/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as ghost } from './reducer' 3 | 4 | test(`ghost: it exports a reducer function`, t => { 5 | t.truthy(typeof ghost === 'function') 6 | }) 7 | 8 | test(`ghost: it returns the initial state on default`, t => { 9 | const result = ghost(undefined, { 10 | type: 'FOO' 11 | }) 12 | 13 | t.deepEqual(result, { 14 | time: 0, 15 | active: false 16 | }) 17 | }) 18 | 19 | test(`ghost: it sets the ghost time on SIMULATE_PLAYTIME`, t => { 20 | const result = ghost(undefined, { 21 | type: 'SIMULATE_PLAYTIME', 22 | payload: 100 23 | }) 24 | 25 | t.deepEqual(result, { 26 | time: 100, 27 | active: false 28 | }) 29 | }) 30 | 31 | test(`ghost: it activates the ghost mode on ENABLE_GHOST_MODE`, t => { 32 | const result = ghost(undefined, { 33 | type: 'ENABLE_GHOST_MODE' 34 | }) 35 | 36 | t.deepEqual(result, { 37 | time: 0, 38 | active: true 39 | }) 40 | }) 41 | 42 | test(`ghost: it disables the ghost mode on DISABLE_GHOST_MODE`, t => { 43 | const result = ghost(undefined, { 44 | type: 'DISABLE_GHOST_MODE' 45 | }) 46 | 47 | t.deepEqual(result, { 48 | time: 0, 49 | active: false 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { createStore, applyMiddleware, compose } from 'redux' 3 | import { connect } from 'redux-vuex' 4 | 5 | import effects from '../effects' 6 | import reducers from './reducers' 7 | import actions from './actions' 8 | 9 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose 10 | 11 | const store = createStore(reducers, composeEnhancers(applyMiddleware(effects))) 12 | 13 | connect({ Vue, store, actions }) 14 | 15 | export default store 16 | -------------------------------------------------------------------------------- /src/store/last-action/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/last-action/reducer.js: -------------------------------------------------------------------------------- 1 | export const INITIAL_STATE = null 2 | export const reducer = (state = null, action) => action 3 | -------------------------------------------------------------------------------- /src/store/last-action/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import { reducer as lastAction } from './reducer' 4 | 5 | test(`lastAction: returns the last action`, t => { 6 | t.is(lastAction('foo', 'bar'), 'bar') 7 | }) 8 | 9 | test(`lastAction: returns the last action if state is undefined`, t => { 10 | t.is(lastAction(undefined, 'bar'), 'bar') 11 | }) 12 | -------------------------------------------------------------------------------- /src/store/mode/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/mode/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { INIT } from '../types' 4 | 5 | export const INITIAL_STATE = 'episode' 6 | 7 | export const reducer = handleActions({ 8 | [INIT]: (state, { payload }) => payload.mode === 'live' ? 'live' : INITIAL_STATE 9 | }, INITIAL_STATE) 10 | -------------------------------------------------------------------------------- /src/store/mode/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as mode } from './reducer' 3 | 4 | test(`mode: it falls back to default mode if not live`, t => { 5 | const result = mode(undefined, { 6 | type: 'INIT', 7 | payload: { 8 | mode: 'foobar' 9 | } 10 | }) 11 | t.deepEqual(result, 'episode') 12 | }) 13 | 14 | test(`mode: it sets the provided mode to live`, t => { 15 | const result = mode(undefined, { 16 | type: 'INIT', 17 | payload: { 18 | mode: 'live' 19 | } 20 | }) 21 | t.deepEqual(result, 'live') 22 | }) 23 | 24 | test(`mode: it does nothing if not the init action is dispatched`, t => { 25 | const result = mode('foo', { 26 | type: 'NOT_A_REAL_TYPE' 27 | }) 28 | t.deepEqual(result, 'foo') 29 | }) 30 | 31 | test(`mode: it has a default fallback if a missing state is provided`, t => { 32 | const result = mode(undefined, { 33 | type: 'NOT_A_REAL_TYPE' 34 | }) 35 | t.deepEqual(result, 'episode') 36 | }) 37 | -------------------------------------------------------------------------------- /src/store/muted/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { MUTE, UNMUTE } from '../types' 4 | 5 | export const mute = createAction(MUTE) 6 | export const unmute = createAction(UNMUTE) 7 | -------------------------------------------------------------------------------- /src/store/muted/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { mute, unmute } from './actions' 3 | 4 | test(`muteAction: creates the MUTE action`, t => { 5 | t.deepEqual(mute(), { 6 | type: 'MUTE' 7 | }) 8 | }) 9 | 10 | test(`unmuteAction: creates the UNMUTE action`, t => { 11 | t.deepEqual(unmute(), { 12 | type: 'UNMUTE' 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/store/muted/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/muted/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { MUTE, UNMUTE } from '../types' 4 | 5 | export const INITIAL_STATE = false 6 | 7 | export const reducer = handleActions({ 8 | [MUTE]: () => true, 9 | [UNMUTE]: () => false 10 | }, INITIAL_STATE) 11 | -------------------------------------------------------------------------------- /src/store/muted/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import { reducer as muted } from './reducer' 4 | 5 | // MUTED 6 | test(`muted: is a reducer function`, t => { 7 | t.is(typeof muted, 'function') 8 | }) 9 | 10 | test(`muted: it does nothing if a unknown action is dispatched`, t => { 11 | const result = muted('CUSTOM', { 12 | type: 'NOT_A_REAL_TYPE' 13 | }) 14 | t.is(result, 'CUSTOM') 15 | }) 16 | 17 | test(`muted: it returns the correct rate`, t => { 18 | t.is(muted(undefined, { 19 | type: 'MUTE' 20 | }), true) 21 | 22 | t.is(muted(undefined, { 23 | type: 'UNMUTE' 24 | }), false) 25 | }) 26 | -------------------------------------------------------------------------------- /src/store/playback/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_PLAYBACK_PARAMS } from '../types' 4 | 5 | export const setUrlParams = createAction(SET_PLAYBACK_PARAMS) 6 | -------------------------------------------------------------------------------- /src/store/playback/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/playback/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { SET_PLAYBACK_PARAMS } from '../types' 4 | 5 | export const INITIAL_STATE = { 6 | starttime: null, 7 | endtime: null, 8 | autoplay: false 9 | } 10 | 11 | export const reducer = handleActions({ 12 | [SET_PLAYBACK_PARAMS]: (state, { payload }) => payload 13 | }, INITIAL_STATE) 14 | -------------------------------------------------------------------------------- /src/store/player/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { INIT, LOAD, LOADING, LOADED, IDLE, PLAY, PAUSE, END } from '../types' 4 | 5 | export const init = createAction(INIT) 6 | 7 | export const idle = createAction(IDLE) 8 | export const load = createAction(LOAD) 9 | export const loading = createAction(LOADING) 10 | export const loaded = createAction(LOADED) 11 | 12 | export const playEvent = createAction(PLAY) 13 | export const pauseEvent = createAction(PAUSE) 14 | export const endEvent = createAction(END) 15 | -------------------------------------------------------------------------------- /src/store/player/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import { init, idle, load, loading, loaded, playEvent, pauseEvent, endEvent } from './actions' 4 | 5 | test(`init: creates the INIT action`, t => { 6 | t.deepEqual(init({ foo: 'bar' }), { 7 | type: 'INIT', 8 | payload: { foo: 'bar' } 9 | }) 10 | }) 11 | 12 | test(`playEventAction: creates the PLAY action`, t => { 13 | t.deepEqual(playEvent(), { 14 | type: 'PLAY' 15 | }) 16 | }) 17 | 18 | test(`pauseEventAction: creates the PAUSE action`, t => { 19 | t.deepEqual(pauseEvent(), { 20 | type: 'PAUSE' 21 | }) 22 | }) 23 | 24 | test(`endEvent: creates the STOP action`, t => { 25 | t.deepEqual(endEvent(), { 26 | type: 'END' 27 | }) 28 | }) 29 | 30 | test(`idleAction: creates the IDLE action`, t => { 31 | t.deepEqual(idle(), { 32 | type: 'IDLE' 33 | }) 34 | }) 35 | 36 | test(`loadingAction: creates the LOADING action`, t => { 37 | t.deepEqual(loading('foo'), { 38 | type: 'LOADING', 39 | payload: 'foo' 40 | }) 41 | }) 42 | 43 | test(`loadAction: creates the LOAD action`, t => { 44 | t.deepEqual(load(), { 45 | type: 'LOAD' 46 | }) 47 | }) 48 | 49 | test(`loadedAction: creates the LOAD action`, t => { 50 | t.deepEqual(loaded('foo'), { 51 | type: 'LOADED', 52 | payload: 'foo' 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/store/playstate/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { IDLE, PLAY, PAUSE, END, STOP, UI_PLAY, UI_PAUSE, UI_RESTART } from '../types' 4 | 5 | export const idle = createAction(IDLE) 6 | export const playEvent = createAction(PLAY) 7 | export const pauseEvent = createAction(PAUSE) 8 | export const endEvent = createAction(END) 9 | export const play = createAction(UI_PLAY) 10 | export const pause = createAction(UI_PAUSE) 11 | export const restart = createAction(UI_RESTART) 12 | export const stop = createAction(STOP) 13 | -------------------------------------------------------------------------------- /src/store/playstate/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import { play, playEvent, pause, pauseEvent, endEvent, restart, idle } from './actions' 4 | 5 | test(`playAction: creates the UI_PLAY action`, t => { 6 | t.deepEqual(play(), { 7 | type: 'UI_PLAY' 8 | }) 9 | }) 10 | 11 | test(`playEventAction: creates the PLAY action`, t => { 12 | t.deepEqual(playEvent(), { 13 | type: 'PLAY' 14 | }) 15 | }) 16 | 17 | test(`pauseAction: creates the UI_PAUSE action`, t => { 18 | t.deepEqual(pause(), { 19 | type: 'UI_PAUSE' 20 | }) 21 | }) 22 | 23 | test(`pauseEventAction: creates the PAUSE action`, t => { 24 | t.deepEqual(pauseEvent(), { 25 | type: 'PAUSE' 26 | }) 27 | }) 28 | 29 | test(`endEvent: creates the STOP action`, t => { 30 | t.deepEqual(endEvent(), { 31 | type: 'END' 32 | }) 33 | }) 34 | 35 | test(`restartAction: creates the RESTART action`, t => { 36 | t.deepEqual(restart(), { 37 | type: 'UI_RESTART' 38 | }) 39 | }) 40 | 41 | test(`idleAction: creates the IDLE action`, t => { 42 | t.deepEqual(idle(), { 43 | type: 'IDLE' 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/store/playstate/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/playstate/reducer.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | import { handleActions } from 'redux-actions' 3 | 4 | import { INIT, UPDATE_PLAYTIME, PLAY, PAUSE, STOP, IDLE, LOADING, ERROR_LOAD } from '../types' 5 | 6 | export const INITIAL_STATE = 'start' 7 | 8 | export const reducer = handleActions({ 9 | [INIT]: (state, { payload }) => get(payload, 'playstate', state), 10 | [UPDATE_PLAYTIME]: (state) => state === 'end' ? 'pause' : state, 11 | [PLAY]: () => 'playing', 12 | [PAUSE]: () => 'pause', 13 | [STOP]: () => 'end', 14 | [IDLE]: () => 'idle', 15 | [LOADING]: () => 'loading', 16 | [ERROR_LOAD]: () => 'error' 17 | }, INITIAL_STATE) 18 | -------------------------------------------------------------------------------- /src/store/playtime/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_PLAYTIME, UPDATE_PLAYTIME } from '../types' 4 | 5 | export const setPlaytime = createAction(SET_PLAYTIME) 6 | export const updatePlaytime = createAction(UPDATE_PLAYTIME) 7 | -------------------------------------------------------------------------------- /src/store/playtime/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { setPlaytime, updatePlaytime } from './actions' 3 | 4 | test(`setPlaytimeAction: creates the SET_PLAYTIME action`, t => { 5 | t.deepEqual(setPlaytime(10), { 6 | type: 'SET_PLAYTIME', 7 | payload: 10 8 | }) 9 | }) 10 | 11 | test(`updatePlaytimeAction: creates the UPDATE_PLAYTIME action`, t => { 12 | t.deepEqual(updatePlaytime(10), { 13 | type: 'UPDATE_PLAYTIME', 14 | payload: 10 15 | }) 16 | }) 17 | 18 | test(`updatePlaytimeAction: creates the UPDATE_PLAYTIME action`, t => { 19 | t.deepEqual(updatePlaytime(10), { 20 | type: 'UPDATE_PLAYTIME', 21 | payload: 10 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/store/playtime/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/playtime/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { get } from 'lodash' 4 | import { toPlayerTime } from 'utils/time' 5 | import { toInt } from 'utils/helper' 6 | 7 | import { INIT, UPDATE_PLAYTIME, SET_PLAYTIME } from '../types' 8 | 9 | export const INITIAL_STATE = 0 10 | 11 | export const reducer = handleActions({ 12 | [INIT]: (state, { payload }) => toPlayerTime(get(payload, 'playtime', state)), 13 | [UPDATE_PLAYTIME]: (state, { payload }) => toInt(payload), 14 | [SET_PLAYTIME]: (state, { payload }) => toInt(payload) 15 | }, INITIAL_STATE) 16 | -------------------------------------------------------------------------------- /src/store/playtime/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as playtime } from './reducer' 3 | 4 | // PLAYTIME TESTS 5 | test(`playtime: is a reducer function`, t => { 6 | t.is(typeof playtime, 'function') 7 | }) 8 | 9 | test(`playtime: parses the playtime on INIT`, t => { 10 | let result = playtime(undefined, { 11 | type: 'INIT', 12 | payload: { 13 | playtime: '01:00' 14 | } 15 | }) 16 | 17 | t.is(result, 60000) 18 | }) 19 | 20 | test(`playtime: parses playtime on UPDATE_PLAYTIME`, t => { 21 | let result = playtime(undefined, { 22 | type: 'UPDATE_PLAYTIME', 23 | payload: '60' 24 | }) 25 | 26 | t.is(result, 60) 27 | }) 28 | 29 | test(`playtime: parses playtime on SET_PLAYTIME`, t => { 30 | let result = playtime(undefined, { 31 | type: 'SET_PLAYTIME', 32 | payload: 60 33 | }) 34 | 35 | t.is(result, 60) 36 | }) 37 | 38 | test(`playtime: it does nothing if a unknown action is dispatched`, t => { 39 | const result = playtime(10, { 40 | type: 'NOT_A_REAL_TYPE' 41 | }) 42 | t.is(result, 10) 43 | }) 44 | -------------------------------------------------------------------------------- /src/store/quantiles/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { LOAD_QUANTILES, SET_QUANTILE } from '../types' 4 | 5 | export const loadQuantiles = createAction(LOAD_QUANTILES, (quantiles = []) => quantiles) 6 | export const setQuantile = createAction(SET_QUANTILE, (start, end) => ({ start, end })) 7 | -------------------------------------------------------------------------------- /src/store/quantiles/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { loadQuantiles, setQuantile } from './actions' 3 | 4 | test(`loadQuantiles: creates the LOAD_QUANTILES action`, t => { 5 | t.deepEqual(loadQuantiles([[0, 20]]), { 6 | type: 'LOAD_QUANTILES', 7 | payload: [[0, 20]] 8 | }) 9 | 10 | t.deepEqual(loadQuantiles(), { 11 | type: 'LOAD_QUANTILES', 12 | payload: [] 13 | }) 14 | }) 15 | 16 | test(`setQuantile: creates the SET_QUANTILE action`, t => { 17 | t.deepEqual(setQuantile(10, 20), { 18 | type: 'SET_QUANTILE', 19 | payload: { 20 | start: 10, 21 | end: 20 22 | } 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/store/quantiles/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/quantiles/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { head, findIndex } from 'lodash' 3 | 4 | import { LOAD_QUANTILES, SET_QUANTILE } from '../types' 5 | 6 | const findQuantile = (quantiles = [], start) => 7 | findIndex(quantiles, quantile => head(quantile) === start) 8 | 9 | const newQuantile = (quantiles = [], quantile) => 10 | [...quantiles, quantile] 11 | 12 | const updateQuantile = (quantiles = [], index, quantile) => [ 13 | ...quantiles.slice(0, index), 14 | quantile, 15 | ...quantiles.slice(index + 1) 16 | ] 17 | 18 | export const INITIAL_STATE = [] 19 | 20 | export const reducer = handleActions({ 21 | [LOAD_QUANTILES]: (state, { payload }) => payload, 22 | [SET_QUANTILE]: (state, { payload }) => { 23 | const index = findQuantile(state, payload.start) 24 | const currentQuantile = [payload.start, payload.end] 25 | 26 | if (index < 0) { 27 | return newQuantile(state, currentQuantile) 28 | } 29 | 30 | return updateQuantile(state, index, currentQuantile) 31 | } 32 | }, INITIAL_STATE) 33 | -------------------------------------------------------------------------------- /src/store/quantiles/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as quantiles } from './reducer' 3 | 4 | // QUANTILES TESTS 5 | test(`quantiles: is a reducer function`, t => { 6 | t.is(typeof quantiles, 'function') 7 | }) 8 | 9 | test(`quantiles: it loads all quantiles`, t => { 10 | const testAction = { 11 | type: 'LOAD_QUANTILES', 12 | payload: [[0, 20]] 13 | } 14 | 15 | t.deepEqual(quantiles([], testAction), [[0, 20]]) 16 | }) 17 | 18 | test(`quantiles: it adds a new quantile if currently not exists`, t => { 19 | const testAction = { 20 | type: 'SET_QUANTILE', 21 | payload: { 22 | start: 0, 23 | end: 50 24 | } 25 | } 26 | 27 | t.deepEqual(quantiles([], testAction), [[0, 50]]) 28 | }) 29 | 30 | test(`quantiles: it updates an existing quantile if it's exists`, t => { 31 | const testAction = { 32 | type: 'SET_QUANTILE', 33 | payload: { 34 | start: 0, 35 | end: 60 36 | } 37 | } 38 | 39 | t.deepEqual(quantiles([[0, 20]], testAction), [[0, 60]]) 40 | }) 41 | 42 | test(`quantiles: it returns the state if not a matching action is called`, t => { 43 | const testAction = { 44 | type: 'INVALID_ACTION' 45 | } 46 | 47 | t.deepEqual(quantiles([[0, 20]], testAction), [[0, 20]]) 48 | }) 49 | -------------------------------------------------------------------------------- /src/store/rate/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_RATE } from '../types' 4 | 5 | export const setRate = createAction(SET_RATE) 6 | -------------------------------------------------------------------------------- /src/store/rate/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import { setRate } from './actions' 4 | 5 | test(`rateAction: creates the SET_RATE action`, t => { 6 | t.deepEqual(setRate(1), { 7 | type: 'SET_RATE', 8 | payload: 1 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/store/rate/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/rate/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { compose } from 'lodash/fp' 3 | 4 | import { toFloat } from 'utils/helper' 5 | import { inRange } from 'utils/math' 6 | 7 | import { SET_RATE } from '../types' 8 | 9 | const inRateRange = compose(inRange(0.5, 4), toFloat) 10 | 11 | export const INITIAL_STATE = 1 12 | 13 | export const reducer = handleActions({ 14 | [SET_RATE]: (state, { payload }) => inRateRange(payload) 15 | }, INITIAL_STATE) 16 | -------------------------------------------------------------------------------- /src/store/rate/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as rate } from './reducer' 3 | 4 | // RATE 5 | test(`rate: is a reducer function`, t => { 6 | t.is(typeof rate, 'function') 7 | }) 8 | 9 | test(`rate: it does nothing if a unknown action is dispatched`, t => { 10 | const result = rate('CUSTOM', { 11 | type: 'NOT_A_REAL_TYPE' 12 | }) 13 | t.is(result, 'CUSTOM') 14 | }) 15 | 16 | test(`rate: it returns the correct rate`, t => { 17 | t.is(rate(undefined, { 18 | type: 'SET_RATE', 19 | payload: 1 20 | }), 1) 21 | 22 | t.is(rate(1, { 23 | type: 'SET_RATE', 24 | payload: 0.2 25 | }), 0.5) 26 | 27 | t.is(rate(1, { 28 | type: 'SET_RATE', 29 | payload: 5 30 | }), 4) 31 | }) 32 | -------------------------------------------------------------------------------- /src/store/reference/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/reference/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { get } from 'lodash' 3 | 4 | import { INIT } from '../types' 5 | 6 | export const INITIAL_STATE = {} 7 | 8 | export const reducer = handleActions({ 9 | [INIT]: (state, { payload }) => ({ 10 | ...state, 11 | config: get(payload, ['reference', 'config'], null), 12 | share: get(payload, ['reference', 'share'], null), 13 | origin: get(payload, ['reference', 'origin'], null) 14 | }) 15 | }, INITIAL_STATE) 16 | -------------------------------------------------------------------------------- /src/store/runtime/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_LANGUAGE, SET_RUNTIME } from '../types' 4 | 5 | export const setLanguage = createAction(SET_LANGUAGE) 6 | export const setRuntime = createAction(SET_RUNTIME) 7 | -------------------------------------------------------------------------------- /src/store/runtime/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { setLanguage, setRuntime } from './actions' 3 | 4 | test(`setLanguage: creates the SET_LANGUAGE action`, t => { 5 | t.deepEqual(setLanguage('de'), { 6 | type: 'SET_LANGUAGE', 7 | payload: 'de' 8 | }) 9 | }) 10 | 11 | test(`setLanguage: creates the SET_LANGUAGE action`, t => { 12 | t.deepEqual(setRuntime({ language: 'de' }), { 13 | type: 'SET_RUNTIME', 14 | payload: { language: 'de' } 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/store/runtime/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/runtime/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { SET_RUNTIME, SET_LANGUAGE } from '../types' 4 | 5 | export const INITIAL_STATE = {} 6 | 7 | export const reducer = handleActions({ 8 | [SET_RUNTIME]: (state, { payload }) => ({ 9 | ...state, 10 | ...payload 11 | }), 12 | [SET_LANGUAGE]: (state, { payload }) => ({ 13 | ...state, 14 | language: payload 15 | }) 16 | }, INITIAL_STATE) 17 | -------------------------------------------------------------------------------- /src/store/runtime/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as runtime } from './reducer' 3 | 4 | let testAction 5 | 6 | test.beforeEach(t => { 7 | testAction = { 8 | type: 'SET_RUNTIME', 9 | payload: { 10 | language: 'en', 11 | platform: 'desktop' 12 | } 13 | } 14 | }) 15 | 16 | test(`runtime: it is a reducer function`, t => { 17 | t.is(typeof runtime, 'function') 18 | }) 19 | 20 | test(`runtime: it extracts the runtime on SET_RUNTIME`, t => { 21 | const result = runtime('', testAction) 22 | t.deepEqual(result, { 23 | language: 'en', 24 | platform: 'desktop' 25 | }) 26 | }) 27 | 28 | test(`runtime: it returns an empty object if a runtime is not available`, t => { 29 | let result = runtime(undefined, { 30 | type: 'NOT_A_REAL_TYPE' 31 | }) 32 | t.deepEqual(result, {}) 33 | }) 34 | 35 | test(`runtime: it sets the language in SET_LANGUAGE`, t => { 36 | let result = runtime({ platform: 'mobile', language: 'en' }, { type: 'SET_LANGUAGE', payload: 'de' }) 37 | 38 | t.deepEqual(result, { platform: 'mobile', language: 'de' }) 39 | }) 40 | 41 | test(`runtime: it does nothing if not the init action is dispatched`, t => { 42 | const result = runtime('foobar', { 43 | type: 'NOT_A_REAL_TYPE' 44 | }) 45 | t.is(result, 'foobar') 46 | }) 47 | -------------------------------------------------------------------------------- /src/store/selectors.js: -------------------------------------------------------------------------------- 1 | import { compose, get } from 'lodash/fp' 2 | 3 | import { selectors as chapters } from './chapters' 4 | import { selectors as share } from './share' 5 | import { selectors as files } from './files' 6 | 7 | // Chapters Tab 8 | const chaptersSlice = get('chapters') 9 | export const selectChapters = compose(chapters.selectChapters, chaptersSlice) 10 | export const selectNextChapters = compose(chapters.selectNextChapters, chaptersSlice) 11 | export const selectPreviousChapter = compose(chapters.selectPreviousChapter, chaptersSlice) 12 | export const selectCurrentChapter = compose(chapters.selectCurrentChapter, chaptersSlice) 13 | export const selectCurrentChapterTitle = compose(chapters.selectCurrentChapterTitle, chaptersSlice) 14 | export const selectCurrentChapterImage = compose(chapters.selectCurrentChapterImage, chaptersSlice) 15 | 16 | // Share Tab 17 | const shareSlice = get('share') 18 | export const selectShareContent = compose(share.selectShareContent, shareSlice) 19 | 20 | // Files Tab 21 | const filesSlice = get('files') 22 | export const selectAudioFiles = compose(files.selectAudio, filesSlice) 23 | -------------------------------------------------------------------------------- /src/store/share/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_SHARE_CONTENT, SET_SHARE_EMBED_SIZE } from '../types' 4 | 5 | export const setShareContent = createAction(SET_SHARE_CONTENT) 6 | export const setShareEmbedSize = createAction(SET_SHARE_EMBED_SIZE) 7 | -------------------------------------------------------------------------------- /src/store/share/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { 3 | setShareContent, 4 | setShareEmbedSize 5 | } from './actions' 6 | 7 | test(`setShareContentAction: creates the SET_SHARE_CONTENT action`, t => { 8 | t.deepEqual(setShareContent('episode'), { 9 | type: 'SET_SHARE_CONTENT', 10 | payload: 'episode' 11 | }) 12 | }) 13 | 14 | test(`setShareEmbedSizeAction: creates the SET_SHARE_EMBED_SIZE action`, t => { 15 | t.deepEqual(setShareEmbedSize('250x400'), { 16 | type: 'SET_SHARE_EMBED_SIZE', 17 | payload: '250x400' 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/store/share/index.js: -------------------------------------------------------------------------------- 1 | import * as selectors from './selectors' 2 | 3 | export * from './actions' 4 | export * from './reducer' 5 | 6 | export { 7 | selectors 8 | } 9 | -------------------------------------------------------------------------------- /src/store/share/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | 3 | import { SET_SHARE_CONTENT, SET_SHARE_EMBED_SIZE } from '../types' 4 | 5 | export const INITIAL_STATE = { 6 | content: 'episode', 7 | embed: { 8 | available: ['250x400', '320x400', '375x400', '600x290', '768x290'], 9 | size: '320x400' 10 | } 11 | } 12 | 13 | export const reducer = handleActions({ 14 | [SET_SHARE_CONTENT]: (state, { payload }) => ({ 15 | ...state, 16 | content: payload 17 | }), 18 | 19 | [SET_SHARE_EMBED_SIZE]: (state, { payload }) => ({ 20 | ...state, 21 | embed: { 22 | ...state.embed, 23 | size: payload 24 | } 25 | }) 26 | }, INITIAL_STATE) 27 | -------------------------------------------------------------------------------- /src/store/share/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as share } from './reducer' 3 | 4 | let expected 5 | 6 | test.beforeEach(t => { 7 | expected = { 8 | content: 'episode', 9 | embed: { 10 | available: ['250x400', '320x400', '375x400', '600x290', '768x290'], 11 | size: '320x400' 12 | } 13 | } 14 | }) 15 | 16 | test(`share: is a reducer function`, t => { 17 | t.is(typeof share, 'function') 18 | }) 19 | 20 | test(`share: it returns the state on default`, t => { 21 | let result = share(undefined, { 22 | type: 'FOO_BAR' 23 | }) 24 | 25 | t.deepEqual(result, expected) 26 | }) 27 | 28 | test(`share: it sets the share content on SET_SHARE_CONTENT`, t => { 29 | let result = share(undefined, { 30 | type: 'SET_SHARE_CONTENT', 31 | payload: 'episode' 32 | }) 33 | 34 | expected.content = 'episode' 35 | t.deepEqual(result, expected) 36 | }) 37 | 38 | test(`share: it sets the embed size on SET_SHARE_EMBED_SIZE`, t => { 39 | let result = share(undefined, { 40 | type: 'SET_SHARE_EMBED_SIZE', 41 | payload: '250x400' 42 | }) 43 | 44 | expected.embed.size = '250x400' 45 | t.deepEqual(result, expected) 46 | }) 47 | -------------------------------------------------------------------------------- /src/store/share/selectors.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash/fp' 2 | 3 | export const selectShareEmbed = get('embed') 4 | export const selectShareContent = get('content') 5 | -------------------------------------------------------------------------------- /src/store/show/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/show/reducer.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | import { handleActions } from 'redux-actions' 3 | import { sanitize } from 'utils/dom' 4 | 5 | import { INIT } from '../types' 6 | 7 | export const INITIAL_STATE = { 8 | title: null, 9 | subtitle: null, 10 | summary: null, 11 | poster: null, 12 | link: null 13 | } 14 | 15 | export const reducer = handleActions({ 16 | [INIT]: (state, { payload }) => ({ 17 | ...state, 18 | title: get(payload, ['show', 'title'], null), 19 | subtitle: get(payload, ['show', 'subtitle'], null), 20 | summary: sanitize(get(payload, ['show', 'summary'], null)), 21 | link: get(payload, ['show', 'link'], null), 22 | poster: get(payload, ['show', 'poster'], null) 23 | }) 24 | }, INITIAL_STATE) 25 | -------------------------------------------------------------------------------- /src/store/speakers/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/speakers/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { get } from 'lodash' 3 | 4 | import { INIT } from '../types' 5 | 6 | export const INITIAL_STATE = [] 7 | 8 | export const reducer = handleActions({ 9 | [INIT]: (state, { payload }) => get(payload, 'contributors', []) 10 | }, INITIAL_STATE) 11 | -------------------------------------------------------------------------------- /src/store/speakers/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as speakers } from './reducer' 3 | 4 | let testAction 5 | 6 | test.beforeEach(t => { 7 | testAction = { 8 | type: 'INIT', 9 | payload: { 10 | contributors: [{ 11 | name: 'foo', 12 | group: { slug: 'onair' } 13 | }, { 14 | name: 'bar', 15 | group: { slug: 'team' } 16 | }] 17 | } 18 | } 19 | }) 20 | 21 | test(`speakers: it is a reducer function`, t => { 22 | t.is(typeof speakers, 'function') 23 | }) 24 | 25 | test(`speakers: it sets the onair speakers on INIT`, t => { 26 | const result = speakers(undefined, testAction) 27 | 28 | t.deepEqual(result, testAction.payload.contributors) 29 | }) 30 | 31 | test(`speakers: it does nothing if not a registered action is dispatched`, t => { 32 | const result = speakers('foobar', { 33 | type: 'NOT_A_REAL_TYPE' 34 | }) 35 | t.is(result, 'foobar') 36 | }) 37 | -------------------------------------------------------------------------------- /src/store/tabs/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { TOGGLE_TAB, SET_TABS } from '../types' 4 | 5 | export const toggleTab = createAction(TOGGLE_TAB) 6 | export const setTabs = createAction(SET_TABS) 7 | -------------------------------------------------------------------------------- /src/store/tabs/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { toggleTab, setTabs } from './actions' 3 | 4 | test(`toggleTab: creates the TOGGLE_TAB action`, t => { 5 | t.deepEqual(toggleTab('settings'), { 6 | type: 'TOGGLE_TAB', 7 | payload: 'settings' 8 | }) 9 | }) 10 | 11 | test(`setTabs: creates the SET_TABS action`, t => { 12 | t.deepEqual(setTabs({ settings: false }), { 13 | type: 'SET_TABS', 14 | payload: { settings: false } 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/store/tabs/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/tabs/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { get } from 'lodash' 3 | 4 | import { INIT, TOGGLE_TAB, SET_TABS } from '../types' 5 | 6 | export const INITIAL_STATE = { 7 | chapters: false, 8 | audio: false, 9 | share: false, 10 | files: false, 11 | info: false, 12 | transcripts: false 13 | } 14 | 15 | export const reducer = handleActions({ 16 | [INIT]: (state, { payload }) => ({ 17 | ...INITIAL_STATE, 18 | ...get(payload, 'tabs', null) 19 | }), 20 | 21 | [TOGGLE_TAB]: (state, { payload }) => ({ 22 | ...INITIAL_STATE, 23 | [payload]: !get(state, payload, false) 24 | }), 25 | 26 | [SET_TABS]: (state, { payload }) => payload 27 | }, INITIAL_STATE) 28 | -------------------------------------------------------------------------------- /src/store/theme/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_THEME } from '../types' 4 | 5 | export const setTheme = createAction(SET_THEME) 6 | -------------------------------------------------------------------------------- /src/store/theme/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { setTheme } from './actions' 3 | 4 | test(`setThemeAction: creates the SET_THEME action`, t => { 5 | t.deepEqual(setTheme('theme'), { 6 | type: 'SET_THEME', 7 | payload: 'theme' 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/store/theme/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/theme/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as theme } from './reducer' 3 | 4 | test(`theme: is a reducer function`, t => { 5 | t.is(typeof theme, 'function') 6 | }) 7 | 8 | test(`theme: it sets the theme on INIT`, t => { 9 | let result = theme(undefined, { 10 | type: 'INIT', 11 | paylaod: { 12 | theme: { 13 | main: '#fff', 14 | highlight: '#000' 15 | } 16 | } 17 | }) 18 | 19 | t.is(typeof result, 'object') 20 | }) 21 | 22 | test(`theme: it has a default fallback if no theme is provided`, t => { 23 | let result = theme(undefined, { 24 | type: 'INIT' 25 | }) 26 | 27 | t.is(typeof result, 'object') 28 | }) 29 | 30 | test(`theme: it sets the theme on SET_THEME`, t => { 31 | let result = theme(undefined, { 32 | type: 'SET_THEME', 33 | paylaod: { 34 | theme: { 35 | main: '#fff' 36 | } 37 | } 38 | }) 39 | 40 | t.is(typeof result, 'object') 41 | }) 42 | 43 | test(`theme: it does nothing if a unknown action is dispatched`, t => { 44 | const result = theme('CUSTOM', { 45 | type: 'NOT_A_REAL_TYPE' 46 | }) 47 | t.is(result, 'CUSTOM') 48 | }) 49 | -------------------------------------------------------------------------------- /src/store/transcripts/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { 4 | INIT_TRANSCRIPTS, 5 | SET_TRANSCRIPTS_TIMELINE, 6 | SET_TRANSCRIPTS_CHAPTERS, 7 | UPDATE_TRANSCRIPTS, 8 | TOGGLE_FOLLOW_TRANSCRIPTS, 9 | SEARCH_TRANSCRIPTS, 10 | RESET_SEARCH_TRANSCRIPTS, 11 | SET_SEARCH_TRANSCRIPTS_RESULTS, 12 | NEXT_SEARCH_RESULT, 13 | PREVIOUS_SEARCH_RESULT 14 | } from '../types' 15 | 16 | export const initTranscripts = createAction(INIT_TRANSCRIPTS) 17 | export const setTranscriptsChapters = createAction(SET_TRANSCRIPTS_CHAPTERS, (chapters = []) => chapters) 18 | export const setTranscriptsTimeline = createAction(SET_TRANSCRIPTS_TIMELINE, (transcripts = []) => transcripts) 19 | export const updateTranscripts = createAction(UPDATE_TRANSCRIPTS, (playtime = 0) => playtime) 20 | export const followTranscripts = createAction(TOGGLE_FOLLOW_TRANSCRIPTS, (follow = true) => follow) 21 | export const searchTranscripts = createAction(SEARCH_TRANSCRIPTS) 22 | export const resetSearchTranscription = createAction(RESET_SEARCH_TRANSCRIPTS) 23 | export const setTranscriptsSearchResults = createAction(SET_SEARCH_TRANSCRIPTS_RESULTS, (results = []) => results) 24 | export const nextTranscriptsSearchResult = createAction(NEXT_SEARCH_RESULT) 25 | export const previousTranscriptsSearchResult = createAction(PREVIOUS_SEARCH_RESULT) 26 | -------------------------------------------------------------------------------- /src/store/transcripts/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/visible-components/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/store/visible-components/reducer.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | import { handleActions } from 'redux-actions' 3 | 4 | import { INIT } from '../types' 5 | 6 | export const INITIAL_STATE = [ 7 | 'tabInfo', 8 | 'tabChapters', 9 | 'tabFiles', 10 | 'tabAudio', 11 | 'tabShare', 12 | 'tabTranscripts', 13 | 'poster', 14 | 'showTitle', 15 | 'episodeTitle', 16 | 'subtitle', 17 | 'progressbar', 18 | 'controlSteppers', 19 | 'controlChapters' 20 | ] 21 | 22 | const toVisibleComponentState = (components = []) => 23 | components.reduce((result, component) => ({ 24 | ...result, 25 | [component]: true 26 | }), {}) 27 | 28 | export const reducer = handleActions({ 29 | [INIT]: (_, { payload }) => toVisibleComponentState(get(payload, 'visibleComponents', INITIAL_STATE)) 30 | }, toVisibleComponentState(INITIAL_STATE)) 31 | -------------------------------------------------------------------------------- /src/store/volume/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions' 2 | 3 | import { SET_VOLUME } from '../types' 4 | 5 | export const setVolume = createAction(SET_VOLUME) 6 | -------------------------------------------------------------------------------- /src/store/volume/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import { setVolume } from './actions' 4 | 5 | test(`volumeAction: creates the SET_VOLUME action`, t => { 6 | t.deepEqual(setVolume(0.2), { 7 | type: 'SET_VOLUME', 8 | payload: 0.2 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/store/volume/index.js: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/store/volume/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions' 2 | import { compose } from 'lodash/fp' 3 | 4 | import { inRange } from 'utils/math' 5 | import { toFloat } from 'utils/helper' 6 | 7 | import { SET_VOLUME } from '../types' 8 | 9 | export const INITIAL_STATE = 1 10 | 11 | const inVolumeRange = compose(inRange(0, INITIAL_STATE), toFloat) 12 | 13 | export const reducer = handleActions({ 14 | [SET_VOLUME]: (state, { payload }) => inVolumeRange(payload) 15 | }, INITIAL_STATE) 16 | -------------------------------------------------------------------------------- /src/store/volume/reducer.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { reducer as volume } from './reducer' 3 | 4 | test(`volume: is a reducer function`, t => { 5 | t.is(typeof volume, 'function') 6 | }) 7 | 8 | test(`volume: it returns the correct volume`, t => { 9 | t.is(volume(undefined, { 10 | type: 'SET_RATE', 11 | payload: 1 12 | }), 1) 13 | 14 | t.is(volume(1, { 15 | type: 'SET_VOLUME', 16 | payload: -1 17 | }), 0) 18 | 19 | t.is(volume(1, { 20 | type: 'SET_VOLUME', 21 | payload: 2 22 | }), 1) 23 | 24 | t.is(volume(1, { 25 | type: 'SET_VOLUME', 26 | payload: 0.2 27 | }), 0.2) 28 | }) 29 | -------------------------------------------------------------------------------- /src/styles/_animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes bounceInDown { 2 | from, 60%, 75%, 90%, to { 3 | animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); 4 | } 5 | 6 | 0% { 7 | opacity: 0; 8 | transform: translate3d(0, -3000px, 0); 9 | } 10 | 11 | 60% { 12 | opacity: 1; 13 | transform: translate3d(0, 25px, 0); 14 | } 15 | 16 | 75% { 17 | transform: translate3d(0, -10px, 0); 18 | } 19 | 20 | 90% { 21 | transform: translate3d(0, 5px, 0); 22 | } 23 | 24 | to { 25 | transform: none; 26 | } 27 | } 28 | 29 | @keyframes bounceOutUp { 30 | 20% { 31 | transform: translate3d(0, -10px, 0); 32 | } 33 | 34 | 40%, 45% { 35 | opacity: 1; 36 | transform: translate3d(0, 20px, 0); 37 | } 38 | 39 | to { 40 | opacity: 0; 41 | transform: translate3d(0, -2000px, 0); 42 | } 43 | } 44 | 45 | @keyframes stop-and-scroll { 46 | 0% { 47 | transform: translateX(0); 48 | } 49 | 30% { 50 | transform: translateX(0); 51 | } 52 | 100%{ 53 | transform: translateX(-100%); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/styles/_embed.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | .podlove { 4 | &.embed { 5 | height: 100%; 6 | 7 | .header { 8 | height: calc(100% - #{$player-height} - #{$tabs-header-height}) 9 | } 10 | 11 | .player { 12 | height: $player-height; 13 | } 14 | 15 | // Share mode 16 | .tabs { 17 | .tab-header-item.active { 18 | position: fixed; 19 | top: 0; 20 | border-color: rgba(0,0,0, 0.1); 21 | border-style: solid; 22 | border-width: 1px 1px 0 1px; 23 | 24 | .title { 25 | display: block; 26 | } 27 | 28 | .close { 29 | display: block; 30 | position: fixed; 31 | top: $padding / 1.5; 32 | right: $padding / 1.5; 33 | } 34 | } 35 | 36 | .tab-body { 37 | top: 100%; 38 | position: fixed; 39 | transition: top $animation-duration; 40 | } 41 | 42 | .tab-body.active { 43 | top: $tabs-header-height; 44 | width: 100%; 45 | height: calc(100% - #{$tabs-header-height}); 46 | background: $background-color; 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/styles/_font.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Fira Sans; 3 | src: url('~styles/fonts/FiraSans-Light.eot') format('eot'), 4 | url('~styles/fonts/FiraSans-Light.woff') format('woff'), 5 | url('~styles/fonts/FiraSans-Light.woff2') format('woff2'); 6 | font-weight: 300; 7 | } 8 | 9 | @font-face { 10 | font-family: Fira Sans; 11 | src: url('~styles/fonts/FiraSans-Regular.eot') format('eot'), 12 | url('~styles/fonts/FiraSans-Regular.woff') format('woff'), 13 | url('~styles/fonts/FiraSans-Regular.woff2') format('woff2'); 14 | font-weight: 500; 15 | } 16 | 17 | @font-face { 18 | font-family: Fira Sans; 19 | src: url('~styles/fonts/FiraSans-Bold.eot') format('eot'), 20 | url('~styles/fonts/FiraSans-Bold.woff') format('woff'), 21 | url('~styles/fonts/FiraSans-Bold.woff2') format('woff2'); 22 | font-weight: 700; 23 | } 24 | 25 | @font-face { 26 | font-family: Fira Mono; 27 | src: url('~styles/fonts/FiraMono-Regular.eot') format('eot'), 28 | url('~styles/fonts/FiraMono-Regular.woff') format('woff'), 29 | url('~styles/fonts/FiraMono-Regular.woff2') format('woff2'); 30 | font-weight: 500; 31 | } 32 | 33 | @mixin font() { 34 | font-family: 'Fira Sans', sans-serif; 35 | } 36 | 37 | @mixin font-monospace() { 38 | font-family: 'Fira Mono', monospace; 39 | } 40 | 41 | .podlove { 42 | font-size: 14px; 43 | font-weight: 300; 44 | } 45 | -------------------------------------------------------------------------------- /src/styles/_global.scss: -------------------------------------------------------------------------------- 1 | @import '~normalize.css/normalize.css'; 2 | 3 | html { 4 | box-sizing: border-box; 5 | } 6 | 7 | *, 8 | *::before, 9 | *::after { 10 | box-sizing: inherit; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | padding: 0; 16 | line-height: 1.5 17 | } 18 | 19 | a { 20 | color: inherit; 21 | text-decoration: none; 22 | 23 | &:hover { 24 | text-decoration: none; 25 | } 26 | } 27 | 28 | img { 29 | max-width: 100%; 30 | width: auto; 31 | } 32 | 33 | // Removes outline only for mouse users 34 | :focus:not(:focus-visible) { outline: none } 35 | -------------------------------------------------------------------------------- /src/styles/_loader.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | width: 100%; 3 | height: 100%; 4 | position: fixed; 5 | left: 0; 6 | top: 0; 7 | z-index: 999; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | opacity: 1; 12 | transition: opacity linear 300ms; 13 | 14 | .dot { 15 | width: 20px; 16 | height: 20px; 17 | margin: 3px; 18 | border-radius: 100%; 19 | display: inline-block; 20 | animation: loader 1.4s ease-in-out 0s infinite both; 21 | } 22 | 23 | .bounce1 { 24 | animation-delay: -0.32s; 25 | } 26 | 27 | .bounce2 { 28 | animation-delay: -0.16s; 29 | } 30 | 31 | &.done { 32 | opacity: 0; 33 | } 34 | } 35 | 36 | @-webkit-keyframes loader { 37 | 0%, 38 | 80%, 39 | 100% { 40 | transform: scale(0); 41 | } 42 | 40% { 43 | transform: scale(1); 44 | } 45 | } 46 | 47 | @keyframes loader { 48 | 0%, 49 | 80%, 50 | 100% { 51 | transform: scale(0); 52 | } 53 | 40% { 54 | transform: scale(1); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/styles/_marquee.scss: -------------------------------------------------------------------------------- 1 | .marquee-container { 2 | overflow: hidden; 3 | position: relative; 4 | 5 | .marquee { 6 | position: absolute; 7 | width: 100%; 8 | height: 100%; 9 | margin: 0; 10 | text-align: left; 11 | 12 | /* Starting position */ 13 | transform: translateX(0%); 14 | 15 | /* Apply animation to this element */ 16 | animation: scroll 10s linear infinite; 17 | } 18 | 19 | /* Move it (define the animation) */ 20 | @keyframes scroll { 21 | 0% { 22 | opacity: 1; 23 | transform: translateX(0%); 24 | } 25 | 20% { 26 | opacity: 1; 27 | transform: translateX(0%); 28 | } 29 | 90% { 30 | opacity: 1; 31 | } 32 | 100% { 33 | opacity: 0; 34 | transform: translateX(-100%); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/styles/_share.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | $button-size: 60px; 3 | 4 | .channel-link { 5 | display: flex; 6 | align-items: center; 7 | flex-direction: column; 8 | margin: $margin / 2; 9 | cursor: pointer; 10 | opacity: 1; 11 | 12 | .channel-icon { 13 | width: $button-size; 14 | height: $button-size; 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | border-radius: 4px; 19 | border: 2px solid transparent; 20 | } 21 | 22 | &.active .channel-icon { 23 | border-color: currentColor; 24 | } 25 | 26 | &:hover { 27 | opacity: 0.8; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/styles/_text.scss: -------------------------------------------------------------------------------- 1 | h1, h2, h3, h4 { 2 | &.title { 3 | margin: 0 0 ($margin / 2) 0; 4 | } 5 | } 6 | 7 | p { 8 | margin: 0 0 $margin 0; 9 | } 10 | -------------------------------------------------------------------------------- /src/styles/_transitions.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | // Fade Transitions 4 | .button-enter-active, .button-leave-active { 5 | transition: opacity $animation-duration * 2; 6 | } 7 | 8 | .button-enter, .button-leave-to { 9 | opacity: 0 10 | } 11 | 12 | // Height Transitions 13 | .progressbar-enter-active, .progressbar-leave-active { 14 | height: $progress-bar-height; 15 | transition: height $animation-duration * 2; 16 | } 17 | 18 | .progressbar-enter, .progressbar-leave-to { 19 | height: 0; 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/_utils.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | .truncate { 4 | white-space: nowrap; 5 | overflow: hidden; 6 | text-overflow: ellipsis; 7 | } 8 | 9 | .text-left { 10 | text-align: left; 11 | } 12 | 13 | .text-right { 14 | text-align: right; 15 | } 16 | 17 | .text-center { 18 | text-align: center; 19 | } 20 | 21 | .seperator { 22 | border-bottom: 1px solid $subtile-color; 23 | padding-bottom: $padding * 2; 24 | } 25 | 26 | .centered { 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | 31 | &.column { 32 | flex-direction: column; 33 | } 34 | } 35 | 36 | .shadowed { 37 | box-shadow: 0px 1px 5px rgba(0, 0 ,0 , 0.1); 38 | } 39 | 40 | .spaced { 41 | display: flex; 42 | justify-content: space-between; 43 | } 44 | 45 | .visually-hidden { 46 | position: absolute !important; 47 | height: 1px; 48 | width: 1px; 49 | overflow: hidden; 50 | clip: rect(1px, 1px, 1px, 1px); 51 | } 52 | -------------------------------------------------------------------------------- /src/styles/fonts/FiraMono-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraMono-Regular.eot -------------------------------------------------------------------------------- /src/styles/fonts/FiraMono-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraMono-Regular.woff -------------------------------------------------------------------------------- /src/styles/fonts/FiraMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraMono-Regular.woff2 -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Bold.eot -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Bold.woff -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Bold.woff2 -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Light.eot -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Light.woff -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Light.woff2 -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Regular.eot -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Regular.woff -------------------------------------------------------------------------------- /src/styles/fonts/FiraSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podlove/podlove-web-player/d05afaf24cc0f18ed572ae2c99579f8d87abdc21/src/styles/fonts/FiraSans-Regular.woff2 -------------------------------------------------------------------------------- /src/styles/resets/_button.scss: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | [role="button"], 3 | input[type="submit"], 4 | input[type="reset"], 5 | input[type="button"], 6 | button { 7 | box-sizing: border-box; 8 | } 9 | 10 | /* Reset `button` and button-style `input` default styles */ 11 | input[type="submit"], 12 | input[type="reset"], 13 | input[type="button"], 14 | button { 15 | background: none; 16 | border: 0; 17 | color: inherit; 18 | cursor: pointer; 19 | font: inherit; 20 | line-height: normal; 21 | overflow: visible; 22 | padding: 0; 23 | appearance: button; /* for input */ 24 | user-select: none; /* for button */ 25 | } 26 | input::-moz-focus-inner, 27 | button::-moz-focus-inner { 28 | border: 0; 29 | padding: 0; 30 | } 31 | button:focus { 32 | outline:0; 33 | } 34 | /* Make `a` like a button */ 35 | [role="button"] { 36 | color: inherit; 37 | cursor: default; 38 | display: inline-block; 39 | text-align: center; 40 | text-decoration: none; 41 | white-space: pre; 42 | user-select: none; 43 | } 44 | -------------------------------------------------------------------------------- /src/styles/resets/_input.scss: -------------------------------------------------------------------------------- 1 | input::-webkit-outer-spin-button, 2 | input::-webkit-inner-spin-button { 3 | display: none; 4 | -webkit-appearance: none; 5 | margin: 0; 6 | } 7 | 8 | input[type=number] { 9 | -moz-appearance:textfield; 10 | } 11 | -------------------------------------------------------------------------------- /src/styles/resets/_lists.scss: -------------------------------------------------------------------------------- 1 | ul, ol { 2 | list-style: none; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/resets/_resets.scss: -------------------------------------------------------------------------------- 1 | @import './range'; 2 | @import './input'; 3 | @import './button'; 4 | @import './lists'; 5 | -------------------------------------------------------------------------------- /src/utils/binary-search.js: -------------------------------------------------------------------------------- 1 | export const binarySearch = (list = []) => search => { 2 | let minIndex = 0 3 | let maxIndex = list.length - 1 4 | let currentIndex 5 | let currentElement 6 | 7 | while (minIndex <= maxIndex) { 8 | currentIndex = (minIndex + maxIndex) / 2 | 0 9 | currentElement = list[currentIndex] 10 | 11 | if (currentElement < search) { 12 | minIndex = currentIndex + 1 13 | } else if (currentElement > search) { 14 | maxIndex = currentIndex - 1 15 | } else { 16 | return currentIndex 17 | } 18 | } 19 | 20 | return maxIndex 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/binary-search.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { binarySearch } from './binary-search' 3 | 4 | const search = binarySearch([ 5 | 0, 6 | 1, 7 | 2, 8 | 3, 9 | 4, 10 | 5, 11 | 10 12 | ]) 13 | 14 | test(`it finds an item in a list`, t => { 15 | t.is(search(3), 3) 16 | }) 17 | 18 | test(`it finds the nearest index`, t => { 19 | t.is(search(9), 5) 20 | }) 21 | 22 | test(`it falls back to -1 if no lower index was found`, t => { 23 | t.is(search(-5), -1) 24 | }) 25 | -------------------------------------------------------------------------------- /src/utils/effects.js: -------------------------------------------------------------------------------- 1 | import { noop, isArray, isString } from 'lodash' 2 | import { compose, get, isFunction } from 'lodash/fp' 3 | 4 | import { isDefinedAndNotNull } from './predicates' 5 | 6 | const truthy = input => input === true 7 | 8 | const isArrayProperty = input => isArray(input) ? input.length > 0 : input 9 | const isStringProperty = input => isString(input) ? input.length > 0 : input 10 | 11 | // Check if payload has property 12 | export const hasProperty = property => compose(truthy, isArrayProperty, isStringProperty, get(property)) 13 | 14 | // Dispatches only if the data is defined 15 | export const prohibitiveDispatch = (dispatch, action) => data => isDefinedAndNotNull(data) ? dispatch(action(data)) : null 16 | 17 | // effect handler 18 | export const handleActions = effectFunc => (store, action) => { 19 | const effect = get(action.type)(effectFunc) 20 | 21 | return isFunction(effect) ? effect(store, action, store.getState()) : noop 22 | } 23 | 24 | export const conditionalEffect = effectFunc => (precondition = true) => precondition ? effectFunc : noop 25 | -------------------------------------------------------------------------------- /src/utils/helper.js: -------------------------------------------------------------------------------- 1 | import { isUndefinedOrNull } from './predicates' 2 | 3 | /** 4 | * Collection of functional helpers 5 | */ 6 | 7 | export const inAnimationFrame = func => (...args) => window.requestAnimationFrame(() => func.apply(null, args)) 8 | export const asyncAnimation = func => (...args) => new Promise(resolve => { 9 | window.requestAnimationFrame(resolve(func.apply(null, args))) 10 | }) 11 | 12 | export const callWith = (...args) => func => func.apply(null, args) 13 | 14 | // Math helpers 15 | export const toInt = (input = 0) => isNaN(parseInt(input, 10)) ? 0 : parseInt(input, 10) 16 | export const toFloat = (input = 0) => isNaN(parseFloat(input)) ? 0 : parseFloat(input) 17 | 18 | // Functional Helper 19 | 20 | export const fallbackTo = fallback => value => isUndefinedOrNull(value) ? fallback : value 21 | -------------------------------------------------------------------------------- /src/utils/keyboard.js: -------------------------------------------------------------------------------- 1 | import keyboard from 'keyboardjs' 2 | 3 | export default (key, onPress, onRelease) => { 4 | keyboard.bind(key, event => { 5 | // Scope is in input or other dom element 6 | if (event.target.nodeName !== 'BODY') { 7 | return 8 | } 9 | 10 | event.preventDefault() 11 | onPress && onPress() 12 | }, event => { 13 | // Scope is in input or other dom element 14 | if (event.target.nodeName !== 'BODY') { 15 | return 16 | } 17 | 18 | event.preventDefault() 19 | onRelease && onRelease() 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/math.js: -------------------------------------------------------------------------------- 1 | import { curry } from 'lodash/fp' 2 | 3 | export const toPercent = (input = 0) => { 4 | input = parseFloat(input) * 100 5 | return Math.round(input) 6 | } 7 | 8 | export const round = (input = 0) => Math.ceil(input * 100) / 100 9 | 10 | export const interpolate = (num = 0) => Math.round(num * 100) / 100 11 | 12 | export const roundUp = curry((base, number) => { 13 | number = Math.ceil(number * 100) 14 | 15 | if (number % base === 0) { 16 | return (number + base) / 100 17 | } 18 | 19 | return (number + (base - number % base)) / 100 20 | }) 21 | 22 | export const relativePosition = (current = 0, maximum = 0) => 23 | ((current * 100) / maximum) + '%' 24 | 25 | export const inRange = (lower = 0, upper = 0) => (value = 0) => { 26 | if (value < lower) { 27 | return lower 28 | } 29 | 30 | if (value > upper) { 31 | return upper 32 | } 33 | 34 | return value 35 | } 36 | 37 | export const toDecimal = (input = 0) => parseFloat(Math.round(input * 100) / 100).toFixed(2) 38 | -------------------------------------------------------------------------------- /src/utils/math.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { toPercent, roundUp, round, interpolate, relativePosition } from './math' 3 | 4 | test('exports a method called toPercent', t => { 5 | t.is(typeof toPercent, 'function') 6 | }) 7 | 8 | test('exports a method called roundUp', t => { 9 | t.is(typeof roundUp, 'function') 10 | }) 11 | 12 | test('exports a method called round', t => { 13 | t.is(typeof round, 'function') 14 | }) 15 | 16 | test(`toPrecent: transforms absolute value to percentage`, t => { 17 | t.is(toPercent(), 0) 18 | t.is(toPercent(1.5), 150) 19 | }) 20 | 21 | test(`round: rounds to floats with 2 nachkomma`, t => { 22 | t.is(round(), 0.00) 23 | t.is(round(1), 1.00) 24 | t.is(round(1.33777777), 1.34) 25 | }) 26 | 27 | test(`roundUp: rounds to next full float quantile`, t => { 28 | t.is(roundUp(5)(1), 1.05) 29 | t.is(roundUp(5)(1.05), 1.10) 30 | t.is(roundUp(5)(1.06), 1.10) 31 | }) 32 | 33 | test(`interpolate: interpolates a given number`, t => { 34 | t.is(interpolate(12.555), 12.56) 35 | t.is(interpolate(10), 10) 36 | }) 37 | 38 | test(`relativePosition: returns position in percent relative to a maximum value`, t => { 39 | t.is(relativePosition(50, 100), '50%') 40 | t.is(relativePosition(40, 160), '25%') 41 | }) 42 | -------------------------------------------------------------------------------- /src/utils/predicates.js: -------------------------------------------------------------------------------- 1 | import { isUndefined, isNull } from 'lodash/fp' 2 | 3 | const or = (p1, p2) => x => p1(x) || p2(x) 4 | // const and = (p1, p2) => x => p1(x) && p2(x) 5 | const not = p => x => !p(x) 6 | 7 | export const isUndefinedOrNull = or(isUndefined, isNull) 8 | export const isDefinedAndNotNull = not(isUndefinedOrNull) 9 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import request from 'superagent' 2 | 3 | export default url => 4 | (typeof url === 'string' 5 | ? request 6 | .get(url) 7 | .query({ format: 'json' }) 8 | .set('Accept', 'application/json') 9 | .then(res => res.body) 10 | : new Promise(resolve => resolve(url))) 11 | -------------------------------------------------------------------------------- /src/utils/request.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import superagent from 'superagent' 3 | import nocker from 'superagent-nock' 4 | 5 | import request from './request' 6 | 7 | let nock 8 | 9 | test.beforeEach(t => { 10 | nock = nocker(superagent) 11 | }) 12 | 13 | test(`request: exports a function`, t => { 14 | t.is(typeof request, 'function') 15 | }) 16 | 17 | test.cb(`request: should resolve an url`, t => { 18 | t.plan(1) 19 | 20 | nock('http://localhost') 21 | .get('/foo') 22 | .reply(200, { foo: 'bar' }) 23 | 24 | request('http://localhost/foo') 25 | .then(result => { 26 | t.deepEqual(result, { foo: 'bar' }) 27 | t.end() 28 | }) 29 | }) 30 | 31 | test.cb(`request: should return an object`, t => { 32 | t.plan(1) 33 | 34 | request({ foo: 'bar' }) 35 | .then(result => { 36 | t.deepEqual(result, { foo: 'bar' }) 37 | t.end() 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/utils/runtime.js: -------------------------------------------------------------------------------- 1 | import { head } from 'lodash' 2 | import browser from 'detect-browser' 3 | import MobileDetect from 'mobile-detect' 4 | 5 | import { version } from '../../package.json' 6 | 7 | const platform = new MobileDetect(window.navigator.userAgent) 8 | 9 | const locale = navigator.language || navigator.userLanguage || 'en-us' 10 | 11 | const currentLanguage = (() => { 12 | return head(locale.split('-')) 13 | })() 14 | 15 | export default { 16 | version, 17 | browser: `${browser.name}:${browser.version}`, 18 | platform: (platform.tablet() || platform.mobile()) ? 'mobile' : 'desktop', 19 | language: currentLanguage, 20 | locale 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/storage.js: -------------------------------------------------------------------------------- 1 | import curry from 'lodash/fp/curry' 2 | import get from 'lodash/get' 3 | import merge from 'lodash/merge' 4 | 5 | const PODLOVE_WEB_PLAYER_TOKEN = 'pwp' 6 | 7 | const getItem = curry((hash, key) => { 8 | try { 9 | const fromStore = window.localStorage.getItem(PODLOVE_WEB_PLAYER_TOKEN) || '' 10 | let obj = JSON.parse(fromStore) 11 | 12 | if (!hash) { 13 | return obj || {} 14 | } 15 | 16 | if (!key) { 17 | return get(obj, hash, {}) 18 | } 19 | 20 | return get(obj, [hash, key]) 21 | } catch (err) { 22 | return undefined 23 | } 24 | }) 25 | 26 | const setItem = hash => (...args) => { 27 | let data 28 | 29 | if (args.length > 1) { 30 | data = { [args[0]]: args[1] } 31 | } else { 32 | data = args[0] 33 | } 34 | 35 | try { 36 | const currentStore = getItem(null, null) 37 | const toStore = JSON.stringify(merge(currentStore, { [hash]: data })) 38 | 39 | return window.localStorage.setItem(PODLOVE_WEB_PLAYER_TOKEN, toStore) 40 | } catch (err) { 41 | return undefined 42 | } 43 | } 44 | 45 | export default hash => ({ 46 | set: setItem(hash), 47 | get: getItem(hash) 48 | }) 49 | -------------------------------------------------------------------------------- /src/utils/text-search.js: -------------------------------------------------------------------------------- 1 | export const textSearch = (input = []) => (query = '') => { 2 | const queryExpr = new RegExp(query, 'ig') 3 | 4 | return input.reduce((results, item, index) => { 5 | const searchHits = item.match(queryExpr) || [] 6 | 7 | // add n times the chunk index, for each hit one 8 | searchHits.forEach(() => { 9 | results.push(index) 10 | }) 11 | 12 | return results 13 | }, []) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/text-search.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { textSearch } from './text-search' 3 | 4 | const search = textSearch([ 5 | 'foo', 6 | 'bar', 7 | 'baz', 8 | 'bla bla bla', 9 | 'gnarz' 10 | ]) 11 | 12 | test(`it finds an item in a list`, t => { 13 | t.deepEqual(search('bar'), [1]) 14 | }) 15 | 16 | test(`it returns an empty array if nothing was found`, t => { 17 | t.deepEqual(search('bar1'), []) 18 | }) 19 | 20 | test(`it repeats an index if text contains multiple hits`, t => { 21 | t.deepEqual(search('bla'), [3, 3, 3]) 22 | }) 23 | -------------------------------------------------------------------------------- /src/utils/url.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string' 2 | import { isString } from 'lodash' 3 | 4 | import { toPlayerTime } from 'utils/time' 5 | 6 | export const locationParams = queryString.parse(window.location.search) 7 | 8 | const parseParameters = parameters => { 9 | const parsed = {} 10 | 11 | if (parameters.t) { 12 | const [start, stop] = parameters.t.split(',') 13 | parsed.starttime = isString(start) ? toPlayerTime(start) : undefined 14 | parsed.stoptime = isString(stop) ? toPlayerTime(stop) : undefined 15 | } 16 | 17 | if (parameters.episode) { 18 | parsed.episode = parameters.episode 19 | } 20 | 21 | if (parameters.autoplay) { 22 | parsed.autoplay = true 23 | } 24 | 25 | return parsed 26 | } 27 | 28 | export const urlParameters = { ...parseParameters(locationParams) } 29 | 30 | export const addQueryParameter = (url, additionalParameters = {}) => { 31 | const parser = document.createElement('a') 32 | parser.href = url 33 | 34 | const existingParameters = queryString.parse(parser.search) 35 | parser.search = queryString.stringify(Object.assign({}, existingParameters, additionalParameters), { encode: false }) 36 | 37 | return parser.href 38 | } 39 | --------------------------------------------------------------------------------