diff --git a/.babelrc b/.babelrc index e1a513c..d343506 100644 --- a/.babelrc +++ b/.babelrc @@ -4,6 +4,8 @@ ], "plugins": [ "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-object-rest-spread" + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-transform-async-to-generator", + "@babel/plugin-proposal-optional-chaining" ] } diff --git a/dist/index.html b/dist/index.html index 49b0e4b..fcaa474 100644 --- a/dist/index.html +++ b/dist/index.html @@ -13,9 +13,9 @@ - + @@ -38,11 +38,17 @@ +
+
play_arrow
+
Alright let's do this
+
+
search
- Whether you're looking for things that do what ... + Whether you're looking for + things that do what ...
@@ -61,10 +67,18 @@
Save
diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 0b6a358..360cd0a 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -82,7 +82,7 @@ gulp.task('watch:styles', () => { gulp.task('clean:scripts', () => del([`${buildDir}/*.{js,js.map}`])); function createBundler(options = {}) { - return browserify(Object.assign({}, browserifyOptions, options)) + return browserify({...browserifyOptions, ...options}) .transform(babelify) .transform(svgify); } diff --git a/package-lock.json b/package-lock.json index 64441fc..15eaf42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -442,6 +442,16 @@ "@babel/plugin-syntax-optional-catch-binding": "^7.0.0" } }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.2.0.tgz", + "integrity": "sha512-ea3Q6edZC/55wEBVZAEz42v528VulyO0eir+7uky/sT4XRcdkWJcFi1aPtitTlwUzGnECWJNExWww1SStt+yWw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-optional-chaining": "^7.2.0" + } + }, "@babel/plugin-proposal-unicode-property-regex": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.0.0.tgz", @@ -535,6 +545,15 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz", + "integrity": "sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-transform-arrow-functions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.0.0.tgz", @@ -545,9 +564,9 @@ } }, "@babel/plugin-transform-async-to-generator": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.1.0.tgz", - "integrity": "sha512-rNmcmoQ78IrvNCIt/R9U+cixUHeYAzgusTFgIAv+wQb9HJU4szhpDD6e5GCACmj/JP5KxuCwM96bX3L9v4ZN/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.2.0.tgz", + "integrity": "sha512-CEHzg4g5UraReozI9D4fblBYABs7IM6UerAVG7EJVrTLC5keh00aEuLUT+O40+mJCEzaXkYfTCUKIyeDfMOFFQ==", "dev": true, "requires": { "@babel/helper-module-imports": "^7.0.0", diff --git a/package.json b/package.json index 11f765d..ecf70c1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@babel/core": "^7.1.6", "@babel/plugin-proposal-class-properties": "^7.1.0", "@babel/plugin-proposal-object-rest-spread": "^7.0.0", + "@babel/plugin-proposal-optional-chaining": "^7.2.0", + "@babel/plugin-transform-async-to-generator": "^7.2.0", "@babel/polyfill": "^7.0.0", "@babel/preset-env": "^7.1.6", "@babel/register": "^7.0.0", diff --git a/src/scripts/components/Sample.js b/src/scripts/components/Sample.js index c0a55c0..1c011d8 100644 --- a/src/scripts/components/Sample.js +++ b/src/scripts/components/Sample.js @@ -72,8 +72,8 @@ class Sample { this.$sample.append(this.$progress); } - play(spam = false, loop = false) { - Player.instance.play(this.playerId, spam, loop); + async play(spam = false, loop = false) { + return Player.instance.play(this.playerId, spam, loop); } stop() { diff --git a/src/scripts/components/SampleContainer.js b/src/scripts/components/SampleContainer.js index 88a4c23..cef1866 100644 --- a/src/scripts/components/SampleContainer.js +++ b/src/scripts/components/SampleContainer.js @@ -2,23 +2,18 @@ import $ from 'jquery'; import 'jquery-contextmenu'; import copy from 'copy-to-clipboard'; -class SampleContainer { - +export default class SampleContainer { /** @type jQuery */ $sampleContainer; /** @type Sample[] */ samples = []; - /** @type string */ - sortType = 'recent'; - /** @type string */ query = ''; constructor() { this.$sampleContainer = $('.sample-container'); - this.$empty = $('.sample-container__empty'); // Play/stop on click this.$sampleContainer.on('click', '.sample', (e) => { @@ -101,6 +96,8 @@ class SampleContainer { } const $prev = this.$sampleContainer.prev(); + + // Updating the container in detached state is quicker this.$sampleContainer.detach(); let empty = true; @@ -114,21 +111,25 @@ class SampleContainer { } else { // Prepare regex const terms = this.query - .replace(/[^\w\s|]/g, '') // Strip non-alphanumeric characters (will be done in target as well) - .replace(/\s+\|\s+/g, '|') // Enable OR-searching when whitespace is around the pipe character "|" - .split(/[\s+&]+/g); // Split by any combination of whitespace characters + // Strip non-alphanumeric characters (will be done in target as well) + .replace(/[^\w\s|]/g, '') + // Enable OR-searching when whitespace is around the pipe character "|" + .replace(/\s+\|\s+/g, '|') + // Split by any combination of whitespace characters + .split(/[\s+&]+/g); const regex = new RegExp(`.*${terms.map(term => `(?=.*${term}.*)`).join('')}.*`, 'i'); // Filter samples this.samples.forEach((sample) => { - const visible = regex.test( - `${sample.name.replace(/[^\w\s|]/g, '') - } ${ - sample.categories.map(category => category.replace(/[^\w\s|]/g, '')).join(' ')}`, - ); - - sample.$sample.toggleClass('sample--filtered', !visible); - if (visible) { + let filterString = sample.name.replace(/[^\w\s|]/g, ''); + sample.categories.forEach((category) => { + filterString += ' ' + category.replace(/[^\w\s|]/g, ''); + }); + + const isVisible = regex.test(filterString); + sample.$sample.toggleClass('sample--filtered', !isVisible); + + if (isVisible) { empty = false; } }); @@ -136,13 +137,17 @@ class SampleContainer { this.$sampleContainer.toggleClass('sample-container--empty', empty); - this.$sampleContainer.insertAfter($prev); - if (!empty) { this.updateLines(); } + + this.$sampleContainer.insertAfter($prev); } + /** + * Updates line classes on visible samples so they can be made awesome by + * themes. + */ updateLines() { let row = -1; let lastTop = 0; @@ -159,64 +164,53 @@ class SampleContainer { .toggleClass('sample--line-1', row % 3 === 1) .toggleClass('sample--line-2', row % 3 === 2); }); - - const $prev = this.$sampleContainer.prev(); - this.$sampleContainer.detach(); - this.$sampleContainer.insertAfter($prev); } - // Returns the sample object that has been played, or null - // eslint-disable-next-line class-methods-use-this - playRandomWithId(id, spam = false, loop = false, scroll = false) { + /** + * Returns the sample object that has been played, or null. + * + * @param {string} id + * @param {boolean} [spam] + * @param {boolean} [loop] + * @param {boolean} [scroll] + * @returns {Promise} + */ + async playRandomWithId(id, spam = false, loop = false, scroll = false) { // Obtain a sample const $filteredSamples = $('.sample').filter(function() { return $(this).data('sample').id === id; }); if ($filteredSamples.length === 0) { - return null; + return false; } const index = Math.floor(Math.random() * $filteredSamples.length); const $sample = $filteredSamples.eq(index); - // Play the sample - $sample.data('sample').play(spam, loop); - - // Scroll - if (scroll) { - SampleContainer.scrollToSample($sample); - } - - return $sample; + return this.playSample($sample, spam, loop, scroll); } - static playRandomVisible(spam = false, loop = false, scroll = false) { + async playRandomVisible(spam = false, loop = false, scroll = false) { const $visibleSamples = $('.sample:not(.sample--filtered)'); if ($visibleSamples.length === 0) { - return null; + return false; } const index = Math.floor(Math.random() * $visibleSamples.length); const $sample = $visibleSamples.eq(index); - $sample.data('sample').play(spam, loop); + return this.playSample($sample, spam, loop, scroll); + } + async playSample($sample, spam, loop, scroll) { if (scroll) { - SampleContainer.scrollToSample($sample); + $('html, body').animate({ + scrollTop: ($sample.offset().top - 100), + }); } - return $sample; - } - - static scrollToSample($sample) { - const sampleTop = $sample.offset().top; - - $('html, body').animate({ - scrollTop: sampleTop - 100, - }); + return $sample.data('sample').play(spam, loop); } } - -export default SampleContainer; diff --git a/src/scripts/config.js b/src/scripts/config.js deleted file mode 100644 index 9490ca7..0000000 --- a/src/scripts/config.js +++ /dev/null @@ -1,13 +0,0 @@ -import system from '../../system.json'; - -export default fetch('config.json').then((response) => { - if (!response.ok) { - throw new Error('Could not fetch public config.'); - } - - return response.json().then(config => - // Merge system and public config - Object.assign(system, config)); -}).catch(() => { - throw new Error('Could not fetch public config.'); -}); diff --git a/src/scripts/helpers/Player.js b/src/scripts/helpers/Player.js index 145e380..3430a14 100644 --- a/src/scripts/helpers/Player.js +++ b/src/scripts/helpers/Player.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import SettingsManager from './SettingsManager'; import Modal from '../components/Modal'; -class Player { +export default class Player { static instance; // The registered samples @@ -20,6 +20,16 @@ class Player { // Whether an animation frame is requested, indicating that no new loop has to be spawned frameRequested = false; + // When samples are blocked from playing due to browser policies, they end + // up here: [{sample, loop}] + blockedSamples = []; + + /** + * The function assigned to this property will be called when a sample is + * blocked from playing. + */ + onBlocked; + static init() { this.instance = new Player(); } @@ -61,9 +71,9 @@ class Player { const sampleIndex = this.samples.push(sample) - 1; this.playing[sampleIndex] = []; - sample.play = (loop) => { + sample.play = async (loop) => { // Resume context if it is suspended due to a lack of user input - this.audioContext.resume(); + await this.audioContext.resume(); // Create an audio element source and link it to the context const audio = new Audio(url); @@ -84,17 +94,33 @@ class Player { // Remove from playing this.playing[sampleIndex].splice(audioIndex, 1); - // Trigger onStop only when we just removed the last playing intance of this sample + // Trigger onStop only when we just removed the last playing instance of this sample if (this.playing[sampleIndex].length === 0) { sample.onStop(); } } }; - audio.play().catch(stop); audio.onpause = stop; audio.onended = stop; + try { + await audio.play(); + } catch (error) { + stop(); + + if ( + error instanceof DOMException && + error.name === 'NotAllowedError' + ) { + // Audio requires user interaction + this.blockedSamples.push({sample, loop}); + this.onBlocked?.(); + } + + return false; + } + // Trigger onPlay only when this is the first instance of this sample to start playing if (this.playing[sampleIndex].length === 1) { sample.onPlay(); @@ -106,19 +132,29 @@ class Player { requestAnimationFrame(this.progressStep); } } + + return true; }; // Return the ID return sampleIndex; } - play(sampleIndex, spam = false, loop = false) { + async play(sampleIndex, spam = false, loop = false) { // Stop all sounds before playing if multiple are not allowed if (!spam) { this.stopAll(); } - this.samples[sampleIndex].play(loop); + return this.samples[sampleIndex].play(loop); + } + + playBlocked() { + this.blockedSamples.forEach((blockedSample) => { + blockedSample.sample.play(blockedSample.loop); + }); + + this.blockedSamples.length = 0; } stop(sampleIndex) { @@ -157,5 +193,3 @@ class Player { return this.playing[sampleIndex].length > 0; } } - -export default Player; diff --git a/src/scripts/helpers/getConfig.js b/src/scripts/helpers/getConfig.js new file mode 100644 index 0000000..db7955f --- /dev/null +++ b/src/scripts/helpers/getConfig.js @@ -0,0 +1,20 @@ +import system from '../../../system.json'; + +let configPromise; + +export default async function getConfig() { + if (configPromise) { + return configPromise; + } + + configPromise = fetch('config.json').then((response) => { + if (!response.ok) { + throw new Error('Could not fetch public config.'); + } + + // Merge system and public config + return response.json().then((config) => ({...system, ...config})); + }); + + return configPromise; +}; diff --git a/src/scripts/helpers/playFromUri.js b/src/scripts/helpers/playFromUri.js new file mode 100644 index 0000000..1ac3dbc --- /dev/null +++ b/src/scripts/helpers/playFromUri.js @@ -0,0 +1,26 @@ +function getPathArguments() { + // Ensure baseuri is pointing to a directory + let path = window.location.href.substr(document.baseURI.length); + + while (path.endsWith('/')) { + path = path.substr(0, path.length - 1); + } + + return path.split('/'); +} + +/** + * Plays samples supplied by URL path, if supplied. + */ +export default async function playFromUri(sampleContainer) { + const pathArguments = getPathArguments(); + + for (let i = 0; i < pathArguments.length; i++) { + await sampleContainer.playRandomWithId( + pathArguments[i], + true, + false, + true + ); + } +} diff --git a/src/scripts/helpers/randomizeTitle.js b/src/scripts/helpers/randomizeTitle.js new file mode 100644 index 0000000..94d2c92 --- /dev/null +++ b/src/scripts/helpers/randomizeTitle.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; + +const boardNames = [ + 'music', + 'spam', + 'crack', + 'shit', + 'originality', + 'meme', +]; + +const postNames = [ + 'amirite', + 'correct', + 'no', + 'you see', + 'eh', + 'hmm', +]; + +export default function randomizeTitle() { + const boardName = boardNames[Math.floor(Math.random() * boardNames.length)]; + const postName = postNames[Math.floor(Math.random() * postNames.length)]; + + $('title').text(`More like ${boardName}board, ${postName}?`); +}; diff --git a/src/scripts/main.js b/src/scripts/main.js index de17ff7..d98b0ed 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import '@babel/polyfill'; import ApiClient from './helpers/ApiClient'; -import configPromise from './config'; +import getConfig from './helpers/getConfig'; import Intern from './helpers/Intern'; import Player from './helpers/Player'; import SampleContainer from './components/SampleContainer'; @@ -9,118 +9,56 @@ import Search from './components/Search'; import SettingsManager from './helpers/SettingsManager'; import SettingsModal from './components/SettingsModal'; import ThemeManager from './helpers/ThemeManager'; +import playFromUri from './helpers/playFromUri'; +import randomizeTitle from './helpers/randomizeTitle'; SettingsManager.init(); ThemeManager.init(); Player.init(); -const settingsModal = new SettingsModal(); -const sampleContainer = new SampleContainer(); - -// Returns an array of strings representing the given path arguments relative to the base url of the index -function getArguments() { - // Ensure baseuri is pointing to a directory - let baseUri = document.baseURI; - if (!baseUri.endsWith('/')) { - baseUri = baseUri.substr(0, baseUri.lastIndexOf('/') + 1); - } - - // Get path relative to base URI - let argumentPath; - if ((location.origin + location.pathname).startsWith(baseUri)) { - argumentPath = (location.origin + location.pathname).substr(baseUri.length); - } else { - console.warn('Base URI does not seems to be on same origin.\nFalling back to full path for argument parsing.'); - argumentPath = location.pathname.substr(1); // Remove leading slash - } - - // Make sure a trailing slash does not translate into an empty argument - if (argumentPath.endsWith('/')) { - argumentPath = argumentPath.slice(0, -1); - } - - return argumentPath ? argumentPath.split('/') : []; -} - -// Will play samples based on the arguments obtained with getArguments -function playFromArguments() { - return new Promise((resolve) => { - let played = 0; - getArguments().forEach((argument) => { - if (sampleContainer.playRandomWithId(argument, true, false, true)) { - played += 1; - } - }); - - resolve(played); - }); -} - -// Sets given samples for the container -function setContainerSamples(samples) { - return new Promise((resolve) => { - sampleContainer.setSamples(samples); +Player.instance.onBlocked = () => { + const overlay = document.querySelector('.audio-blocked-overlay'); + overlay.classList.add('audio-blocked-overlay--active'); - resolve(samples.length); + overlay.addEventListener('click', () => { + overlay.classList.remove('audio-blocked-overlay--active'); + Player.instance.playBlocked(); }); -} +}; -// Randomized page title -const boardNames = [ - 'music', - 'spam', - 'crack', - 'shit', - 'originality', - 'meme', -]; - -const postNames = [ - 'amirite', - 'correct', - 'no', - 'you see', - 'eh', - 'hmm', -]; +const settingsModal = new SettingsModal(); -const $title = $('title'); -$title.text( - `More like ${boardNames[Math.floor(Math.random() * boardNames.length)]}board, \ - ${postNames[Math.floor(Math.random() * postNames.length)]}?`, -); +randomizeTitle(); // Action buttons $('[data-action="show-settings-modal"]').on('click', () => { settingsModal.show(); }); +const sampleContainer = new SampleContainer(); const intern = new Intern(); // Init search -// eslint-disable-next-line no-unused-vars -const search = new Search({ - onChange: (query) => { - sampleContainer.update(query); - }, - - onSubmit: (query, e) => { - if (!SampleContainer.playRandomVisible(e.shiftKey, e.ctrlKey, true)) { +new Search({ + onChange: (query) => {sampleContainer.update(query)}, + onSubmit: async (query, e) => { + if (!await sampleContainer.playRandomVisible(e.shiftKey, e.ctrlKey, true)) { intern.say(query); } }, }); -// Wait for config to register samples and set some other values -configPromise.then((config) => { +// Load config and use it to initialize other components +(async () => { + const config = await getConfig(); const apiClient = new ApiClient(config.apiBaseUrl); // Get samples and add them to the container - apiClient.getSamples() - .then(setContainerSamples) - .then(playFromArguments); + const samples = await apiClient.getSamples(); + sampleContainer.setSamples(samples); + playFromUri(sampleContainer); - // Modals + // Register modals that rely on configuration $('[data-action="show-contribution-modal"]').on('click', () => { window.open(config.contributeUrl, '_blank'); }); @@ -136,4 +74,4 @@ configPromise.then((config) => { // Version in settings modal $('[data-content=version-number]').text(`v${config.versionNumber}`); $('[data-content=version-name]').text(config.versionName); -}); +})(); diff --git a/src/styles/components/_audio-blocked-overlay.scss b/src/styles/components/_audio-blocked-overlay.scss new file mode 100644 index 0000000..9025600 --- /dev/null +++ b/src/styles/components/_audio-blocked-overlay.scss @@ -0,0 +1,47 @@ +.audio-blocked-overlay { + z-index: 1; + text-align: center; + padding-top: $header-inner-height + $spacing-base * 2 + $spacing-base / 2; + + width: 100%; + height: 100%; + position: fixed; + + align-items: center; + justify-content: center; + flex-direction: column; + + cursor: pointer; + + display: none; + + &--active { + display: flex; + } +} + +.audio-blocked-overlay__icon { + font-size: 50px; +} + +.audio-blocked-overlay__text { + font-size: 25px; + margin-top: 10px; + opacity: 0.7; +} + +@each $theme, $colors in $themes { + $color-sample-container: map-get($colors, sample-container); + $color-sample-text: map-get($colors, sample-text); + + .theme--#{$theme} { + .audio-blocked-overlay { + background-color: $color-sample-container; + color: $color-sample-text; + } + + .audio-blocked-overlay__text { + color: $color-sample-text + } + } +} diff --git a/src/styles/components/_header.scss b/src/styles/components/_header.scss index c5c2050..54cfef9 100644 --- a/src/styles/components/_header.scss +++ b/src/styles/components/_header.scss @@ -3,7 +3,7 @@ .header { position: fixed; - z-index: 1; + z-index: 2; top: 0; left: 0; right: 0; diff --git a/src/styles/components/_sample-container.scss b/src/styles/components/_sample-container.scss index e4f8235..b3543cb 100644 --- a/src/styles/components/_sample-container.scss +++ b/src/styles/components/_sample-container.scss @@ -59,9 +59,5 @@ .sample-container { color: mix($color-sample-container, $color-sample-text, 70%); } - - .sample-container__empty { - - } } } diff --git a/src/styles/main.scss b/src/styles/main.scss index cd614d3..5dc740f 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -15,3 +15,4 @@ @import 'components/theme-selector'; @import 'components/theme-thumb'; @import 'components/version'; +@import 'components/audio-blocked-overlay'; diff --git a/system.json b/system.json index 8407a57..aae4f47 100644 --- a/system.json +++ b/system.json @@ -1,7 +1,7 @@ { - "versionNumber": "3.1.1", - "versionName": "alright alright alright", - "versionSampleId": "6d02153a", + "versionNumber": "3.2.0", + "versionName": "what do you think of that", + "versionSampleId": "4ba49d58", "repositoryUrl": "https://github.com/villermen/soundboard-front-end", "contributeUrl": "https://docs.google.com/document/d/1tDlWfX2TtczI5IHLmffY4LPya1J1Z9ZWHW4pYlD8toM/edit?usp=sharing",