├── .eslintrc
├── .flowconfig
├── .gitignore
├── .storybook
├── .babelrc
├── Container.js
├── addons.js
└── config.js
├── LICENSE
├── README.md
├── flow-typed
└── npm
│ ├── classnames_v2.x.x.js
│ ├── debug_v3.x.x.js
│ └── tickedoff_vx.x.x.js
├── gatsby-browser.js
├── gatsby-config.js
├── gatsby-node.js
├── gatsby-ssr.js
├── lib
└── soundcloud
│ └── index.js
├── package.json
├── src
├── components
│ ├── GifSlide.js
│ ├── GifSlideshow.js
│ ├── GifSlideshow.stories.js
│ ├── IndexPage.js
│ ├── MediaControls.js
│ ├── MediaControls.stories.js
│ ├── MediaPlayer.js
│ ├── PlayerHotkeys.js
│ ├── PlayerPage.js
│ ├── PlayerPage.stories.js
│ ├── StationLink.js
│ ├── StationLinks.js
│ ├── StationLinks.stories.js
│ ├── StationLinksView.js
│ └── StoryHeading.js
├── data
│ ├── images.js
│ └── stations.js
├── html.js
├── layouts
│ ├── css.js
│ ├── index.css
│ └── index.js
├── pages
│ ├── 404.js
│ └── list.js
├── queries
│ └── all_stations.js
├── stores
│ ├── SoundcloudStore.js
│ └── UIStore.js
├── styles
│ └── variables.js
├── templates
│ └── PlayerTemplate.js
└── types
│ └── index.js
└── yarn.lock
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "standard",
4 | "standard-react"
5 | ],
6 | "parser": "babel-eslint",
7 | "plugins": [
8 | "flowtype"
9 | ],
10 | "rules": {
11 | "react/prop-types": 0,
12 |
13 | // Leave this to prettier
14 | "indent": 0,
15 | "react/jsx-indent-props": 0,
16 | "react/jsx-indent": 0
17 | }
18 | }
--------------------------------------------------------------------------------
/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 | .*/node_modules/tickedoff/.*
3 |
4 | [include]
5 |
6 | [libs]
7 |
8 | [lints]
9 |
10 | [options]
11 |
12 | [strict]
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Project dependencies
2 | .cache
3 | node_modules
4 | yarn-error.log
5 |
6 | # Build directory
7 | /public
8 | .DS_Store
9 |
--------------------------------------------------------------------------------
/.storybook/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "env",
4 | "react",
5 | "stage-2"
6 | ],
7 | "plugins": [
8 | "styled-jsx/babel"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.storybook/Container.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { createMemoryHistory as createHistory } from 'history'
3 | import { Router } from 'react-router'
4 |
5 | export default class Container extends Component {
6 | render() {
7 | const { story } = this.props
8 | const history = createHistory()
9 |
10 | return (
11 |
18 | {story()}
19 |
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-options/register'
2 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { configure, addDecorator } from '@storybook/react'
3 | import { setOptions } from '@storybook/addon-options'
4 | import Container from './Container'
5 |
6 | /*
7 | * Wrap all examples
8 | */
9 |
10 | addDecorator(story => )
11 |
12 | /*
13 | * Options
14 | * See: https://github.com/storybooks/storybook/tree/master/addons/options
15 | */
16 |
17 | setOptions({
18 | name: 'Lofi Storybook',
19 | showAddonPanel: false,
20 | hierarchySeparator: /\//
21 | })
22 |
23 | function requireAll(r) {
24 | r.keys().forEach(r)
25 | }
26 |
27 | /*
28 | * Load stories
29 | */
30 |
31 | function loadStories() {
32 | require('../src/layouts/css.js')
33 | requireAll(require.context('../src', true, /\.stor(y|ies)\.jsx?$/))
34 | }
35 |
36 | configure(loadStories, module)
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 gatsbyjs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ricostacruz.com/lofi
2 |
3 | # lofi
4 |
5 | > :vhs: VHS music machine from the 80's
6 |
7 | This is a quick exploration of tying together a few libraries which, at around 2018, ~~were~~ are considered cutting-edge:
8 |
9 | - [GatsbyJS](https://www.gatsbyjs.org/) for static site generation
10 | - [styled-jsx](https://github.com/zeit/styled-jsx) for CSS-in-JS
11 | - [Storybook](https://storybook.js.org/) for style guides
12 | - [React](https://reactjs.org/) as a cancer-curing panacea
13 |
14 | ## Developer setup
15 |
16 | ```bash
17 | yarn # install deps
18 | yarn develop # run server
19 | ```
20 |
21 | ## Other commands
22 |
23 | | Command | Description |
24 | | ---------------- | ------------------------------------------------------ |
25 | | `yarn develop` | Starts development server (defaults to localhost:8000) |
26 | | `yarn test` | Run tests |
27 | | `yarn storybook` | Open Storybook (defaults to localhost:9001) |
28 |
29 | ## Thanks
30 |
31 | **lofi** © 2018, Rico Sta. Cruz. Released under the [MIT] License.
32 | Authored and maintained by Rico Sta. Cruz with help from contributors ([list][contributors]).
33 |
34 | > [ricostacruz.com](http://ricostacruz.com) ·
35 | > GitHub [@rstacruz](https://github.com/rstacruz) ·
36 | > Twitter [@rstacruz](https://twitter.com/rstacruz)
37 |
38 | [](https://github.com/rstacruz)
39 | [](https://twitter.com/rstacruz)
40 |
41 | [MIT]: http://mit-license.org/
42 | [contributors]: http://github.com/rstacruz/lofi/contributors
43 |
--------------------------------------------------------------------------------
/flow-typed/npm/classnames_v2.x.x.js:
--------------------------------------------------------------------------------
1 | // flow-typed signature: cf86673cc32d185bdab1d2ea90578d37
2 | // flow-typed version: 614bf49aa8/classnames_v2.x.x/flow_>=v0.25.x
3 |
4 | type $npm$classnames$Classes =
5 | | string
6 | | { [className: string]: * }
7 | | false
8 | | void
9 | | null;
10 |
11 | declare module "classnames" {
12 | declare module.exports: (
13 | ...classes: Array<$npm$classnames$Classes | $npm$classnames$Classes[]>
14 | ) => string;
15 | }
16 |
17 | declare module "classnames/bind" {
18 | declare module.exports: $Exports<"classnames">;
19 | }
20 |
21 | declare module "classnames/dedupe" {
22 | declare module.exports: $Exports<"classnames">;
23 | }
24 |
--------------------------------------------------------------------------------
/flow-typed/npm/debug_v3.x.x.js:
--------------------------------------------------------------------------------
1 | // flow-typed signature: da5374f88debab76c20fc67be7295ba7
2 | // flow-typed version: da30fe6876/debug_v3.x.x/flow_>=v0.28.x
3 |
4 | declare module "debug" {
5 | declare type Debugger = {
6 | (...args: Array): void,
7 | (formatter: string, ...args: Array): void,
8 | (err: Error, ...args: Array): void,
9 | enabled: boolean,
10 | log: () => {},
11 | namespace: string
12 | };
13 |
14 | declare module.exports: (namespace: string) => Debugger;
15 |
16 | declare var names: Array;
17 | declare var skips: Array;
18 | declare var colors: Array;
19 |
20 | declare function disable(): void;
21 | declare function enable(namespaces: string): void;
22 | declare function enabled(name: string): boolean;
23 | declare function humanize(): void;
24 | declare function useColors(): boolean;
25 | declare function log(): void;
26 |
27 | declare var formatters: {
28 | [formatter: string]: () => {}
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/flow-typed/npm/tickedoff_vx.x.x.js:
--------------------------------------------------------------------------------
1 | // flow-typed signature: 1038b15dbf0c5fbe745ef9d17ef23037
2 | // flow-typed version: <>/tickedoff_v1.0.2/flow_v0.71.0
3 |
4 | declare module 'tickedoff' {
5 | declare module.exports: (() => void) => void
6 | }
7 |
--------------------------------------------------------------------------------
/gatsby-browser.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Router } from 'react-router-dom'
3 | import { Provider } from 'unstated'
4 |
5 | /**
6 | * Hook unstated's `` into the React tree.
7 | *
8 | * See: https://github.com/gatsbyjs/gatsby/blob/master/examples/using-redux/gatsby-browser.js
9 | */
10 |
11 | exports.replaceRouterComponent = ({ history }) => {
12 | const ConnectedRouterWrapper = ({ children }) => (
13 |
14 | {children}
15 |
16 | )
17 |
18 | return ConnectedRouterWrapper
19 | }
20 |
--------------------------------------------------------------------------------
/gatsby-config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | siteMetadata: {
3 | title: 'Lofi'
4 | },
5 | plugins: [
6 | 'gatsby-plugin-react-helmet',
7 | 'gatsby-plugin-react-next',
8 | 'gatsby-plugin-styled-jsx'
9 | ],
10 | pathPrefix: '/lofi'
11 | }
12 |
--------------------------------------------------------------------------------
/gatsby-node.js:
--------------------------------------------------------------------------------
1 | const root = require('path').resolve.bind(null, __dirname)
2 | const STATIONS = require(root('src/data/stations.js'))
3 |
4 | /**
5 | * See: https://github.com/gatsbyjs/gatsby/issues/2538#issuecomment-356769913
6 | */
7 |
8 | exports.modifyWebpackConfig = ({ config, stage }) => {
9 | const timestamp = Date.now()
10 | switch (stage) {
11 | case 'build-javascript':
12 | config.merge({
13 | output: {
14 | filename: `[name]-${timestamp}-[chunkhash].js`,
15 | chunkFilename: `[name]-${timestamp}-[chunkhash].js`
16 | }
17 | })
18 |
19 | break
20 | }
21 | return config
22 | }
23 |
24 | /**
25 | * Dynamically create pages
26 | */
27 |
28 | exports.createPages = ({ boundActionCreators }) => {
29 | const PlayerTemplate = root('src/templates/PlayerTemplate.js')
30 | const { createPage } = boundActionCreators
31 |
32 | Object.keys(STATIONS).map((path /*: string */) => {
33 | const station = STATIONS[path]
34 | createPage({
35 | path: path,
36 | context: station,
37 | component: PlayerTemplate
38 | })
39 | })
40 |
41 | // A homepage
42 | createPage({
43 | path: '/',
44 | context: STATIONS['/lofi-hiphop'],
45 | component: PlayerTemplate
46 | })
47 | }
48 |
--------------------------------------------------------------------------------
/gatsby-ssr.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Provider } from 'unstated'
3 | import { renderToString } from 'react-dom/server'
4 |
5 | /**
6 | * Hook unstated's `` into the React tree.
7 | *
8 | * See: https://github.com/gatsbyjs/gatsby/blob/master/examples/using-redux/gatsby-ssr.js
9 | */
10 |
11 | exports.replaceRenderer = ({ bodyComponent, replaceBodyHTMLString }) => {
12 | const ConnectedBody = () => {bodyComponent}
13 | replaceBodyHTMLString(renderToString( ))
14 | }
15 |
--------------------------------------------------------------------------------
/lib/soundcloud/index.js:
--------------------------------------------------------------------------------
1 | import loadjs from 'loadjs'
2 |
3 | /**
4 | * Soundcloud script
5 | */
6 |
7 | export const SOUNDCLOUD = 'https://w.soundcloud.com/player/api.js'
8 |
9 | /**
10 | * Loads the Soundcloud script and returns the `SC` object.
11 | *
12 | * See: https://developers.soundcloud.com/docs/api/html5-widget
13 | */
14 |
15 | export default function Soundcloud () {
16 | return new Promise((resolve, reject) => {
17 | if (window.SC) {
18 | return resolve(window.SC)
19 | }
20 |
21 | loadjs(SOUNDCLOUD, {
22 | success: () => {
23 | resolve(window.SC)
24 | }
25 | })
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lofi",
3 | "description": "",
4 | "version": "1.0.0",
5 | "author": "Rico Sta. Cruz ",
6 | "dependencies": {
7 | "@storybook/addon-options": "^3.4.2",
8 | "babel-eslint": "8",
9 | "classnames": "^2.2.5",
10 | "color": "^3.0.0",
11 | "debug": "^3.1.0",
12 | "eslint": "4",
13 | "eslint-config-standard": "11",
14 | "eslint-config-standard-react": "5",
15 | "eslint-plugin-flowtype": "2",
16 | "eslint-plugin-import": "2",
17 | "eslint-plugin-node": "5",
18 | "eslint-plugin-promise": "3",
19 | "eslint-plugin-react": "7",
20 | "eslint-plugin-standard": "3",
21 | "gatsby": "^1.9.247",
22 | "gatsby-link": "^1.6.40",
23 | "gatsby-plugin-react-helmet": "^2.0.10",
24 | "gatsby-plugin-react-next": "^1.0.11",
25 | "gatsby-plugin-styled-jsx": "^2.0.6",
26 | "loadjs": "^3.5.4",
27 | "qs": "^6.5.1",
28 | "react-helmet": "^5.2.0",
29 | "react-hotkeys": "^1.1.4",
30 | "sanitize.css": "^5.0.0",
31 | "styled-jsx": "^2.2.6",
32 | "unstated": "^2.0.2"
33 | },
34 | "keywords": [],
35 | "license": "MIT",
36 | "scripts": {
37 | "build": "gatsby build --prefix-paths",
38 | "ci": "run-s flow format:list",
39 | "lint": "eslint src/ lib/",
40 | "develop": "rm -rf .cache public && gatsby develop -H 0.0.0.0",
41 | "develop:local": "rm -rf .cache public && gatsby develop",
42 | "flow": "flow",
43 | "format": "prettier-eslint --write 'src/**/*.js' 'lib/**/*.js'",
44 | "format:list": "prettier-eslint --list-different 'src/**/*.js' 'lib/**/*.js'",
45 | "test": "echo \"Error: no test specified\" && exit 1",
46 | "storybook": "start-storybook -p 9001 -c .storybook",
47 | "deploy": "rm -rf .cache public && gatsby build --prefix-paths && build-storybook -c .storybook -o public/storybook && gh-pages -d public",
48 | "deploy:safe": "rm -rf .cache public && gatsby build --prefix-paths && gh-pages -d public"
49 | },
50 | "devDependencies": {
51 | "@storybook/addon-info": "^3.4.2",
52 | "@storybook/react": "^3.4.2",
53 | "babel-loader": "7.1.1",
54 | "flow-bin": "^0.71.0",
55 | "gh-pages": "^1.1.0",
56 | "npm-run-all": "^4.1.2",
57 | "prettier": "^1.12.0",
58 | "prettier-eslint-cli": "^4.7.1",
59 | "prettier_d": "^5.7.4"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/GifSlide.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import React from 'react'
3 | import cn from 'classnames'
4 |
5 | import * as VARS from '../styles/variables'
6 |
7 | /*
8 | * Types
9 | */
10 |
11 | export type Props = {
12 | image: string,
13 | preload: Array,
14 | visible: boolean
15 | }
16 |
17 | /**
18 | * A GIF slide.
19 | */
20 |
21 | export const GifSlide = ({ image, preload, visible }: Props) => (
22 |
23 | {/* Looping image */}
24 |
30 |
31 | {/* Preloadables */}
32 | {(preload || []).map(image => (
33 |
40 | ))}
41 |
42 | {/* Grid overlay */}
43 |
44 |
45 | {/* Gradient overlay */}
46 |
47 |
48 |
117 |
118 | )
119 |
120 | /*
121 | * Default export
122 | */
123 |
124 | export default GifSlide
125 |
--------------------------------------------------------------------------------
/src/components/GifSlideshow.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import * as React from 'react'
3 |
4 | import * as IMAGES from '../data/images'
5 | import GifSlide from '../components/GifSlide'
6 | import type { Imageset } from '../types'
7 |
8 | /*
9 | * Types
10 | */
11 |
12 | export type Props = {|
13 | interval: number,
14 | images: ?Array,
15 | index: number,
16 | imageset?: Imageset,
17 | visible: boolean
18 | |}
19 |
20 | export type State = {|
21 | interval: number,
22 | images: Array,
23 | index: number
24 | |}
25 |
26 | /*
27 | * Slideshow
28 | */
29 |
30 | export class GifSlideshow extends React.Component {
31 | // Timeout for timer
32 | timer: ?global.TimeoutID
33 |
34 | static defaultProps: Props = {
35 | interval: 15000,
36 | images: null,
37 | imageset: 'aesthetic',
38 | index: 0,
39 | visible: true
40 | }
41 |
42 | /**
43 | * Constructor: sets state
44 | */
45 |
46 | constructor (props: Props) {
47 | super(props)
48 |
49 | const { imageset } = props
50 |
51 | // Get images and shuffle them
52 | const rawImages: Array = props.images
53 | ? props.images
54 | : imageset && IMAGES.hasOwnProperty(imageset)
55 | ? IMAGES[imageset]
56 | : []
57 |
58 | const images = shuffle(rawImages)
59 |
60 | // TODO: Implement this as getDerivedStateFromProps
61 | this.state = {
62 | images,
63 | interval: props.interval,
64 | index: props.index
65 | }
66 | }
67 |
68 | /**
69 | * Returns the current image.
70 | */
71 |
72 | getImage (): string {
73 | const { images, index } = this.state
74 | return images[index]
75 | }
76 |
77 | /**
78 | * Returns the images to be preloaded.
79 | */
80 |
81 | getPreload (): Array {
82 | const { images, index } = this.state
83 | return [...images, ...images].slice(index + 1, index + 2)
84 | }
85 |
86 | /**
87 | * Moves to the next image.
88 | * Updates state.
89 | */
90 |
91 | nextImage (): void {
92 | let { index, images } = this.state
93 | index += 1
94 | if (index >= images.length) index = 0
95 | this.setState({ index })
96 | }
97 |
98 | /**
99 | * On mount: start the timer.
100 | */
101 |
102 | componentDidMount (): void {
103 | this.tick()
104 | }
105 |
106 | /**
107 | * On unmount: stop the timer.
108 | */
109 |
110 | componentWillUnmount (): void {
111 | if (this.timer) clearTimeout(this.timer)
112 | this.timer = undefined
113 | }
114 |
115 | /**
116 | * Starts the timer.
117 | */
118 |
119 | tick (): void {
120 | const { interval } = this.state
121 |
122 | this.timer = setTimeout(() => {
123 | this.nextImage()
124 | this.tick()
125 | }, interval)
126 | }
127 |
128 | /**
129 | * Renders by delegating to ` `.
130 | */
131 |
132 | render (): React.Node {
133 | const image = this.getImage()
134 | const preload = this.getPreload()
135 | const { visible } = this.props
136 |
137 | return
138 | }
139 | }
140 |
141 | /**
142 | * Helper: shuffles an array.
143 | *
144 | * @param {Array<*>} array Array to be sorted
145 | */
146 |
147 | function shuffle (array: Array<*>, n?: number): Array<*> {
148 | if (n === 0) return array
149 |
150 | return shuffle(
151 | array.sort(() => Math.random() - 0.5),
152 | typeof n === 'undefined' ? array.length : n - 1
153 | )
154 | }
155 |
156 | /*
157 | * Default export
158 | */
159 |
160 | export default GifSlideshow
161 |
--------------------------------------------------------------------------------
/src/components/GifSlideshow.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { storiesOf } from '@storybook/react'
3 | import { withInfo } from '@storybook/addon-info'
4 |
5 | import GifSlideshow from '../components/GifSlideshow'
6 | import StoryHeading from '../components/StoryHeading'
7 |
8 | storiesOf('GifSlideshow', module).add(
9 | 'Slideshow',
10 | withInfo({
11 | text: `
12 | Gif slideshow.
13 | `
14 | })(() => (
15 |
16 |
17 |
18 |
19 | ))
20 | )
21 |
--------------------------------------------------------------------------------
/src/components/IndexPage.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import React from 'react'
3 | import StationLinks from './StationLinks'
4 |
5 | import type { StationPage } from '../types'
6 |
7 | export type Props = {
8 | stations: Array
9 | }
10 |
11 | export const IndexPage = ({ stations }: Props) => (
12 |
13 | )
14 |
15 | export default IndexPage
16 |
--------------------------------------------------------------------------------
/src/components/MediaControls.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import * as React from 'react'
3 | import { Subscribe } from 'unstated'
4 | import SoundcloudStore from '../stores/SoundcloudStore'
5 | import cn from 'classnames'
6 | import color from 'color'
7 | import Type from 'prop-types'
8 |
9 | export const INACTIVE_COLOR = '#fff'
10 | export const ACTIVE_COLOR = '#8fa'
11 | export const BACKGROUND_COLOR = '#1e1e24'
12 | export const HEIGHT = 20
13 | export const WIDTH = 40
14 |
15 | /*
16 | * Types
17 | */
18 |
19 | export type ViewProps = {|
20 | onPlay: () => void,
21 | onPause: () => void,
22 | isPaused: boolean,
23 | isPending: boolean,
24 | isPlaying: boolean
25 | |}
26 |
27 | /**
28 | * Media controls
29 | */
30 |
31 | export const MediaControlsView = ({
32 | onPlay,
33 | onPause,
34 | isPaused,
35 | isPending,
36 | isPlaying
37 | }: ViewProps) => (
38 |
39 | {isPending ? Loading... : null}
40 | {isPaused || isPlaying ? (
41 |
45 |
46 |
47 | ) : null}
48 |
49 |
157 |
158 | )
159 |
160 | MediaControlsView.propTypes = {
161 | onPlay: Type.func,
162 | onPause: Type.func,
163 | isPaused: Type.bool,
164 | isPending: Type.bool,
165 | isPlaying: Type.bool
166 | }
167 |
168 | /**
169 | * Connector
170 | */
171 |
172 | export const connect = (View: ViewProps => React.Node) => () => (
173 |
174 | {soundcloud => (
175 | {
177 | soundcloud.play()
178 | }}
179 | onPause={() => {
180 | soundcloud.pause()
181 | }}
182 | isPaused={soundcloud.state.state === 'PAUSED'}
183 | isPending={
184 | soundcloud.state.state === 'PENDING' ||
185 | soundcloud.state.state === 'READY'
186 | }
187 | isPlaying={soundcloud.state.state === 'PLAYING'}
188 | />
189 | )}
190 |
191 | )
192 |
193 | /*
194 | * Export
195 | */
196 |
197 | export default connect(MediaControlsView)
198 |
--------------------------------------------------------------------------------
/src/components/MediaControls.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { storiesOf } from '@storybook/react'
3 | import { action } from '@storybook/addon-actions'
4 | import { withInfo } from '@storybook/addon-info'
5 |
6 | import { MediaControlsView } from '../components/MediaControls'
7 | import StoryHeading from '../components/StoryHeading'
8 |
9 | storiesOf('MediaControlsView', module).add(
10 | 'Media Controls',
11 | withInfo({})(() => (
12 |
13 |
14 | {props => }
15 |
16 |
17 |
18 |
19 |
20 |
25 |
26 |
27 |
32 |
33 | ))
34 | )
35 |
36 | /**
37 | * Example provider for ` `
38 | */
39 |
40 | class Example extends React.Component {
41 | state = {
42 | state: 'PAUSED'
43 | }
44 |
45 | onPause = evt => {
46 | action('onPause')(evt)
47 | this.setState({ state: 'PAUSED' })
48 | }
49 |
50 | onPlay = evt => {
51 | action('onPlay')(evt)
52 | this.setState({ state: 'PLAYING' })
53 | }
54 |
55 | render () {
56 | const { state } = this.state
57 | const { children } = this.props
58 |
59 | return children({
60 | isPlaying: state === 'PLAYING',
61 | isPaused: state === 'PAUSED',
62 | isPending: state === 'PENDING',
63 | onPause: this.onPause,
64 | onPlay: this.onPlay
65 | })
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/MediaPlayer.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | /* eslint-disable react/no-unused-prop-types */
3 | import React from 'react'
4 | import qs from 'qs'
5 | import { Subscribe } from 'unstated'
6 |
7 | import getSoundcloud from '../../lib/soundcloud'
8 | import SoundcloudStore from '../stores/SoundcloudStore'
9 |
10 | import type { Action, SoundcloudAPI, SoundcloudWidget } from '../types'
11 |
12 | /*
13 | * Types
14 | */
15 |
16 | export type Props = {|
17 | url: string,
18 | actions: Array,
19 | dispatch: any
20 | |}
21 |
22 | export type State = {
23 | SC?: SoundcloudAPI,
24 | widget?: SoundcloudWidget
25 | }
26 |
27 | export type ViewProps = {|
28 | url: string,
29 | dispatch: any,
30 | actions: any
31 | |}
32 |
33 | const debug = require('debug')('app:MediaPlayer')
34 |
35 | /**
36 | * Soundcloud player iframe.
37 | *
38 | * See: https://developers.soundcloud.com/docs/api/html5-widget
39 | */
40 |
41 | export class MediaPlayerView extends React.Component {
42 | state: State = {}
43 |
44 | iframe: ?global.Node
45 |
46 | render () {
47 | const { SC } = this.state
48 | const { url } = this.props
49 |
50 | const options = {
51 | url,
52 | auto_play: true,
53 | buying: false,
54 | liking: false,
55 | download: false,
56 | sharing: false,
57 | show_artwork: true,
58 | show_comments: false,
59 | show_playcount: false,
60 | show_teaser: true,
61 | show_user: false,
62 | hide_related: false
63 | // visual: false,
64 | // callback: true
65 | }
66 |
67 | const src = `https://w.soundcloud.com/player/?${qs.stringify(options)}`
68 |
69 | const iframe = (
70 |
79 | )
80 |
81 | if (!SC) return null
82 | return iframe
83 | }
84 |
85 | componentDidMount () {
86 | getSoundcloud().then(SC => {
87 | this.setState({ SC })
88 | })
89 | }
90 |
91 | /**
92 | * Passes down `actions` from the store to the SoundCloud widget API.
93 | */
94 |
95 | /* eslint-disable camelcase */
96 | UNSAFE_componentWillReceiveProps (next: Props) {
97 | // If no widget, die
98 | const { widget } = this.state
99 | if (!widget) return
100 |
101 | const prev = this.props
102 |
103 | const actions = next.actions
104 | const prevActions = prev.actions
105 |
106 | if (actions !== prevActions && actions.length) {
107 | actions.map(action => {
108 | if (action.type === 'PLAY') {
109 | widget.play()
110 | } else if (action.type === 'PAUSE') {
111 | widget.pause()
112 | }
113 | })
114 | }
115 | }
116 |
117 | /**
118 | * Initializes the ``.
119 | */
120 |
121 | refIframe = (el: ?global.Node) => {
122 | this.iframe = el
123 |
124 | const { widget, SC } = this.getAPI()
125 | const { dispatch } = this.props
126 |
127 | // Save widget to state
128 | this.setState({ widget })
129 |
130 | this.bindWidgetEvents(widget, dispatch, SC)
131 | }
132 |
133 | /**
134 | * Initializes the Soundcloud widget. Hooks Soundcloud events to `dispatch`.
135 | */
136 |
137 | bindWidgetEvents = (
138 | widget: SoundcloudWidget,
139 | dispatch: any,
140 | SC: SoundcloudAPI
141 | ) => {
142 | // Can happen in development.
143 | if (!widget) {
144 | console.warn('Warning: Soundcloud widget not available')
145 | return
146 | }
147 |
148 | widget.bind(SC.Widget.Events.READY, () => {
149 | dispatch.setPlayerState('READY')
150 |
151 | widget.getSounds(sounds => {
152 | dispatch.setSounds(sounds)
153 |
154 | // Skip to random track on startup
155 | const idx = Math.round(sounds.length * Math.random())
156 | widget.skip(idx)
157 |
158 | widget.bind(SC.Widget.Events.PLAY, () => {
159 | debug('INFO: Events.PLAY received')
160 | dispatch.setPlayerState('PLAYING')
161 |
162 | widget.getCurrentSound(sound => {
163 | // Discard empty "plays"
164 | if (!sound) {
165 | debug('ERR: discarding empty sound')
166 | return
167 | }
168 |
169 | if (!sound.title && !(sound.user && sound.user.username)) {
170 | debug('ERR: discarding incomplete sound', sound)
171 | return
172 | }
173 |
174 | dispatch.setSound(sound)
175 | })
176 | })
177 |
178 | widget.bind(SC.Widget.Events.PAUSE, () => {
179 | dispatch.setPlayerState('PAUSED')
180 | })
181 |
182 | widget.bind(SC.Widget.Events.FINISH, () => {
183 | dispatch.setPlayerState('FINISH')
184 | })
185 | })
186 | })
187 | }
188 |
189 | /**
190 | * Returns the `widget` and `SC` objects.
191 | */
192 |
193 | getAPI = () => {
194 | const { iframe } = this
195 | if (!iframe) return {}
196 |
197 | const { SC } = this.state
198 | if (!SC) return {}
199 |
200 | const widget = SC.Widget(iframe)
201 | return { widget, SC }
202 | }
203 | }
204 |
205 | /**
206 | * Place this anywhere in your DOM tree. It listens to Soundcloud store.
207 | */
208 |
209 | export const MediaPlayer = ({ url }: Props) => {
210 | return (
211 |
212 | {soundcloud => (
213 |
218 | )}
219 |
220 | )
221 | }
222 |
223 | /*
224 | * Export
225 | */
226 |
227 | export default MediaPlayer
228 |
--------------------------------------------------------------------------------
/src/components/PlayerHotkeys.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import * as React from 'react'
3 | import { HotKeys } from 'react-hotkeys'
4 | import { Subscribe } from 'unstated'
5 |
6 | import UIStore from '../stores/UIStore'
7 | import type { KeyMap } from '../types'
8 |
9 | /*
10 | * Types
11 | */
12 |
13 | export type Props = {|
14 | children: React.Node
15 | |}
16 |
17 | /**
18 | * The keymap.
19 | */
20 |
21 | export const map: KeyMap = {
22 | 'ui.toggleSoundcloud': 'up',
23 | 'media.playPause': 'space'
24 | }
25 |
26 | /**
27 | * Hotkeys connector.
28 | *
29 | * As long the focus is within its children, these hotkeys will be active.
30 | *
31 | * @example
32 | *
33 | * (stuff go here)
34 | *
35 | */
36 |
37 | export const PlayerHotkeys = ({ children }: Props) => {
38 | return (
39 |
40 | {ui => (
41 | {
45 | ui.toggleSoundcloud()
46 | }
47 | }}
48 | >
49 | {children}
50 |
51 | )}
52 |
53 | )
54 | }
55 |
56 | export default PlayerHotkeys
57 |
--------------------------------------------------------------------------------
/src/components/PlayerPage.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 |
3 | import * as React from 'react'
4 | import { Subscribe } from 'unstated'
5 | import cn from 'classnames'
6 | import Helmet from 'react-helmet'
7 |
8 | import SoundcloudStore from '../stores/SoundcloudStore'
9 | import UIStore from '../stores/UIStore'
10 | import { MediaPlayerView } from '../components/MediaPlayer'
11 | import GifSlideshow from '../components/GifSlideshow'
12 | import MediaControls from '../components/MediaControls'
13 | import PlayerHotkeys from '../components/PlayerHotkeys'
14 | import StationLinks from '../components/StationLinks'
15 |
16 | import type { StationPage, Imageset } from '../types'
17 |
18 | export type ViewProps = {
19 | showSlideshow: boolean,
20 | showSoundcloud: boolean,
21 | soundcloudURL: string,
22 | dispatch: any,
23 | actions: any,
24 | stations: Array,
25 |
26 | // React nodes to render inside (eg, Helmet)
27 | children: React.Node,
28 |
29 | // The images to be used in ` `
30 | imageset?: string
31 | }
32 |
33 | export type Props = {
34 | soundcloudURL: string,
35 | title: string,
36 | imageset?: string,
37 | stations: Array
38 | }
39 |
40 | /**
41 | * Home page
42 | */
43 |
44 | export const PlayerPageView = (
45 | {
46 | showSlideshow,
47 | showSoundcloud,
48 | soundcloudURL,
49 | dispatch,
50 | actions,
51 | stations,
52 | imageset,
53 | children
54 | } /*: ViewProps */
55 | ) => (
56 |
57 | {/* Helmet */}
58 | {children}
59 |
60 |
61 |
62 |
63 |
64 |
65 | {showSlideshow ? (
66 |
67 |
68 |
69 | ) : null}
70 |
71 |
72 |
73 |
74 |
75 |
80 |
85 |
86 |
87 |
136 |
137 | )
138 |
139 | /**
140 | * Connected ` `
141 | */
142 |
143 | export const PlayerPage = (
144 | { soundcloudURL, title, imageset, stations } /*: Props */
145 | ) => (
146 |
147 | {(soundcloud, ui) => (
148 |
160 |
161 |
162 | )}
163 |
164 | )
165 |
166 | /*
167 | * Export
168 | */
169 |
170 | export default PlayerPage
171 |
--------------------------------------------------------------------------------
/src/components/PlayerPage.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Provider } from 'unstated'
3 | import { storiesOf } from '@storybook/react'
4 | import { withInfo } from '@storybook/addon-info'
5 |
6 | import PlayerPage from '../components/PlayerPage'
7 |
8 | storiesOf('PlayerPage', module).add(
9 | 'Full example',
10 | withInfo({
11 | text: `
12 | This is the player page!
13 | `
14 | })(() => (
15 |
16 |
28 |
29 | ))
30 | )
31 |
--------------------------------------------------------------------------------
/src/components/StationLink.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import React from 'react'
3 | import Link from 'gatsby-link'
4 |
5 | /**
6 | * Prop types
7 | */
8 |
9 | export type Props = {
10 | path: string,
11 | title: string
12 | }
13 |
14 | /**
15 | * A link to a station
16 | */
17 |
18 | export const StationLink = ({ path, title }: Props) => (
19 |
20 |
21 | {title}
22 |
23 |
41 |
42 |
43 | )
44 |
45 | /*
46 | * Export
47 | */
48 |
49 | export default StationLink
50 |
--------------------------------------------------------------------------------
/src/components/StationLinks.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import React from 'react'
3 |
4 | import StationLinksView from './StationLinksView'
5 | import type { StationPage } from '../types/index'
6 |
7 | /*
8 | * Types
9 | */
10 |
11 | export type Props = {
12 | stations: Array,
13 | isOpen?: boolean
14 | }
15 |
16 | export type State = {
17 | isOpen: boolean
18 | }
19 |
20 | /**
21 | * Connector component
22 | */
23 |
24 | export class StationLinks extends React.Component {
25 | constructor (props: Props) {
26 | super(props)
27 | this.state = {
28 | isOpen: 'isOpen' in props ? props.isOpen || false : false
29 | }
30 | }
31 |
32 | toggleOpen = () => {
33 | const { isOpen } = this.state
34 | this.setState({ isOpen: !isOpen })
35 | }
36 |
37 | onOpen = () => {
38 | this.setState({ isOpen: true })
39 | }
40 |
41 | onClose = () => {
42 | this.setState({ isOpen: false })
43 | }
44 |
45 | render () {
46 | const { props, onOpen, onClose } = this
47 | const { isOpen } = this.state
48 |
49 | return (
50 |
56 | )
57 | }
58 | }
59 |
60 | /*
61 | * Export
62 | */
63 |
64 | export default StationLinks
65 |
--------------------------------------------------------------------------------
/src/components/StationLinks.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Provider } from 'unstated'
3 | import { storiesOf } from '@storybook/react'
4 | import { withInfo } from '@storybook/addon-info'
5 |
6 | import StationLinks from '../components/StationLinks'
7 |
8 | storiesOf('StationLinks', module).add(
9 | 'Station links',
10 | withInfo({
11 | text: `
12 | Station links
13 | `
14 | })(() => (
15 |
16 |
24 |
30 |
31 |
32 | ))
33 | )
34 |
--------------------------------------------------------------------------------
/src/components/StationLinksView.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import React from 'react'
3 | import cn from 'classnames'
4 |
5 | import StationLink from './StationLink'
6 | import type { StationPage } from '../types/index'
7 |
8 | /*
9 | * Types
10 | */
11 |
12 | export type Props = {
13 | stations: Array,
14 | isOpen: boolean,
15 | onOpen: () => void,
16 | onClose: () => void
17 | }
18 |
19 | /**
20 | * Station Links View
21 | */
22 |
23 | export const StationLinksView = ({
24 | stations,
25 | isOpen,
26 | onOpen,
27 | onClose
28 | }: Props) => (
29 |
30 |
31 | {stations.map(({ path, title }: StationPage) => (
32 |
33 | ))}
34 |
35 |
36 |
40 |
41 |
102 |
103 | )
104 |
105 | /*
106 | * Export
107 | */
108 |
109 | export default StationLinksView
110 |
--------------------------------------------------------------------------------
/src/components/StoryHeading.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import * as React from 'react'
3 |
4 | export type Props = {|
5 | title: string,
6 | children?: React.Node
7 | |}
8 |
9 | /**
10 | * Headings for a storybook.
11 | * @param {string} title The text to render
12 | * @param {ReactNode} [children] Kids to render
13 | */
14 |
15 | const StoryHeading = ({ title, children }: Props) => (
16 |
17 |
{title}
18 | {children || null}
19 |
20 |
32 |
33 | )
34 |
35 | export default StoryHeading
36 |
--------------------------------------------------------------------------------
/src/data/images.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 |
3 | /*
4 | * Cinemagraph images for lofi.
5 | *
6 | * See:
7 | * - https://www.tumblr.com/search/cinemagraph
8 | * - https://juliendouvier.tumblr.com/
9 | * - https://leahberman.tumblr.com/
10 | */
11 |
12 | export const lofi: Array = [
13 | // Bike
14 | // https://juliendouvier.tumblr.com/post/163862485221#notes
15 | 'https://78.media.tumblr.com/e9cc23ae98e67f6956880d4ca90a0052/tumblr_ou9eyoISBp1s85u2fo1_500.gif',
16 |
17 | // Train
18 | // https://juliendouvier.tumblr.com/post/160514365031#notes
19 | 'https://78.media.tumblr.com/fda8bfa3905d45603d90d72adb3086df/tumblr_opqowiAmXO1s85u2fo1_500.gif',
20 |
21 | // Escalator
22 | // https://julie11.tumblr.com/post/97213975641/my-escalator-series-is-officially-featured-on#notes
23 | 'https://78.media.tumblr.com/89ce4c298ad5d014ec7b1ea83dabec8c/tumblr_mwf80y6rGe1s85u2fo1_500.gif',
24 |
25 | // VU meter
26 | // https://juliendouvier.tumblr.com/post/92522918506#notes
27 | 'https://78.media.tumblr.com/c83b5721adeaeaa74fce184fa51f8526/tumblr_n940167n2Y1s85u2fo1_r1_500.gif',
28 |
29 | // Lake
30 | // https://juliendouvier.tumblr.com/post/101372057946#notes
31 | 'https://78.media.tumblr.com/b74e2e5f995eba9bfa1a7c862bf5629b/tumblr_nea4onxsgk1s85u2fo1_500.gif',
32 |
33 | // Technics
34 | // https://juliendouvier.tumblr.com/post/82798370487#notes
35 | 'https://78.media.tumblr.com/6ff92fcad7e9b36081d43b748dafe2ad/tumblr_n42xwoHXXg1s85u2fo1_500.gif',
36 |
37 | // Cafe by the River
38 | // https://juliendouvier.tumblr.com/post/77004517983#notes
39 | 'https://78.media.tumblr.com/d16117a8222afc4d4ee6e7ef175096cb/tumblr_n15x6iu5uE1s85u2fo1_500.gif',
40 |
41 | // Creek
42 | // https://juliendouvier.tumblr.com/post/76011830515#notes
43 | 'https://78.media.tumblr.com/b2b9c8348b0b66a43f4b8584db5f12d1/tumblr_n05vpfV6MG1s85u2fo1_500.gif'
44 | ]
45 |
46 | /**
47 | * Vaporwave aesthetic images.
48 | */
49 |
50 | export const aesthetic: Array = [
51 | // Pink road
52 | // https://giphy.com/gifs/90s-vaporwave-vhs-gif-58dEMJQAn2SfC
53 | // 'https://media.giphy.com/media/58dEMJQAn2SfC/giphy.gif',
54 |
55 | // Tokyo rain
56 | // https://giphy.com/gifs/aesthetic-uBTWyINWTrWz6
57 | // 'https://media.giphy.com/media/uBTWyINWTrWz6/giphy.gif',
58 |
59 | // Space triangle
60 | // https://giphy.com/gifs/3d-vhs-after-effects-zlVy2qTOIoysM
61 | 'https://media.giphy.com/media/zlVy2qTOIoysM/giphy.gif',
62 |
63 | // TV static
64 | // https://giphy.com/gifs/3d-vhs-after-effects-zlVy2qTOIoysM
65 | 'https://media.giphy.com/media/LmWnCBTOGUmw8/giphy.gif',
66 |
67 | // Adjust tracking
68 | // https://giphy.com/gifs/glitch-vhs-max-capacity-yugSj8GSC0wXm
69 | 'https://media.giphy.com/media/yugSj8GSC0wXm/giphy.gif',
70 |
71 | // Pool party
72 | // https://giphy.com/gifs/bastian-berne-fUBbxS4oLW0IU
73 | 'https://media.giphy.com/media/fUBbxS4oLW0IU/giphy.gif',
74 |
75 | // LA palm trees
76 | // https://giphy.com/gifs/vintage-vhs-Z69UDgjfRMjsY
77 | 'https://media.giphy.com/media/Z69UDgjfRMjsY/giphy.gif',
78 |
79 | // 90s dancing
80 | // https://giphy.com/gifs/vintage-90s-SwjZP4UxSrDVK
81 | 'https://media.giphy.com/media/SwjZP4UxSrDVK/giphy.gif',
82 |
83 | // Bubble gum
84 | // https://giphy.com/gifs/90s-vintage-c79CePg8do6qc
85 | 'https://media.giphy.com/media/c79CePg8do6qc/giphy.gif',
86 |
87 | // La Toya Jackson
88 | // http://gph.is/1KJC7U8
89 | 'https://media.giphy.com/media/CUpS7AQEOEGXK/giphy.gif',
90 |
91 | // Weight watches idk
92 | // http://gph.is/1bTzeng
93 | 'https://media.giphy.com/media/TZZzHBGrlStSU/giphy.gif',
94 |
95 | // Sculpture
96 | // http://gph.is/1LKp1YO
97 | 'https://media.giphy.com/media/4xB2FgW1eHIL6/giphy.gif',
98 |
99 | // 1981
100 | // http://gph.is/1URPt2N
101 | 'https://media.giphy.com/media/JO83DevQc6Suk/giphy.gif',
102 |
103 | // Mousse
104 | // http://gph.is/1MUAseT
105 | 'https://media.giphy.com/media/wp7uH13Iby6as/giphy.gif',
106 |
107 | // Future cop
108 | // http://gph.is/206EHOT
109 | 'https://media.giphy.com/media/hSoemM5UuJR1S/giphy.gif',
110 |
111 | // Skateboard
112 | // http://gph.is/1IVDaSB
113 | 'https://media.giphy.com/media/xTiTnD4d66fshHzTxK/giphy.gif',
114 |
115 | // Glitch flipphone
116 | // http://gph.is/1OUZvis
117 | 'https://media.giphy.com/media/l41lO0QvEQ8kjaIko/giphy.gif',
118 |
119 | // Music note
120 | // https://giphy.com/gifs/90s-glitch-3d-l0IykENmwKDa1qCaY
121 | 'https://media.giphy.com/media/l0IykENmwKDa1qCaY/giphy.gif',
122 |
123 | // 80's trees
124 | // http://gph.is/1hGhEpq
125 | 'https://media.giphy.com/media/7twRA3tODfwt2/giphy.gif',
126 |
127 | // Need for speed
128 | // http://gph.is/2lCPfXP
129 | 'https://media.giphy.com/media/3o6YglHGRqkaf1DAkw/giphy.gif',
130 |
131 | // Kung fury grid
132 | // http://gph.is/1GhrwRA
133 | 'https://media.giphy.com/media/Bpqp2E59wkaOY/giphy.gif',
134 |
135 | // 80's beach
136 | // http://gph.is/2EkVmZN
137 | 'https://media.giphy.com/media/l0NgS8vCMk2ewa5iw/giphy.gif',
138 |
139 | // Soda sip
140 | // http://gph.is/1Tva4tK
141 | 'https://media.giphy.com/media/jobZpJ2vUWRdS/giphy.gif',
142 |
143 | // VHS sunset
144 | // http://gph.is/1JayjL1
145 | 'https://media.giphy.com/media/ijhLl5UuE7yec/giphy.gif',
146 |
147 | // CU soon
148 | // http://gph.is/1SvEcJU
149 | 'https://media.giphy.com/media/IdXAygIFzYceY/giphy.gif',
150 |
151 | // Welcome to miami
152 | // http://gph.is/1MiLHzD
153 | 'https://media.giphy.com/media/s01FCGJpDfkU8/giphy.gif',
154 |
155 | // Sun ripples
156 | // http://gph.is/2vvtkDb
157 | 'https://media.giphy.com/media/xT9IglBTX4JAsRHH9K/giphy.gif',
158 |
159 | // Color bars Glitch
160 | // http://gph.is/2lhr4LA
161 | 'https://media.giphy.com/media/3o6Yg9EEV1IeLRd3Xy/giphy.gif',
162 |
163 | // Glow cubes
164 | // http://gph.is/1Zd9h3F
165 | 'https://media.giphy.com/media/yHWnaQf2q68sE/giphy.gif'
166 | ]
167 |
168 | /**
169 | * I bless the rains down in Africa
170 | */
171 |
172 | export const africa: Array = [
173 | // Gazelles
174 | // http://gph.is/1qJy4ft
175 | 'https://media.giphy.com/media/8fu6uGQYeAkms/giphy.gif',
176 |
177 | // Lions
178 | // http://gph.is/1cN8VM4
179 | 'https://media.giphy.com/media/4UrZOtu7RadvG/giphy.gif',
180 |
181 | // Elephant
182 | // http://gph.is/1EhY1sf
183 | 'https://media.giphy.com/media/xy2x91aDutdQs/giphy.gif',
184 |
185 | // Lion cubs
186 | // http://gph.is/17gLyGL
187 | 'https://media.giphy.com/media/vKxtitjlIDBWU/giphy.gif',
188 |
189 | // Elephant converse
190 | // http://gph.is/1Lm5GZI
191 | 'https://media.giphy.com/media/SWKyABQ08mbXW/giphy.gif',
192 |
193 | // Elephant and a dude
194 | // http://gph.is/2w8czBh
195 | 'https://media.giphy.com/media/nV8wNDUcQX8Lm/giphy.gif',
196 |
197 | // Running zebra
198 | // http://gph.is/15NRbLk
199 | 'https://media.giphy.com/media/8LsOY7dirIH28/giphy.gif',
200 |
201 | // Dope zebra
202 | // http://gph.is/19ZQnmP
203 | 'https://media.giphy.com/media/bHoFqabfGJLpu/giphy.gif'
204 | ]
205 |
206 | /**
207 | * All images
208 | */
209 |
210 | export const IMAGES = { lofi, africa, aesthetic }
211 |
--------------------------------------------------------------------------------
/src/data/stations.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | /*::
3 | import type { StationList } from '../types'
4 | */
5 |
6 | /**
7 | * Stations
8 | */
9 |
10 | const STATIONS /*: StationList */ = {
11 | '/lofi-hiphop': {
12 | title: 'Lofi Beats',
13 | genre: 'Hiphop',
14 | group: 'lofi',
15 | imageset: 'aesthetic',
16 | href: 'https://soundcloud.com/parzival6/sets/lo-fi-hip-hop',
17 | soundcloudURL: 'https://api.soundcloud.com/playlists/246258956'
18 | },
19 | '/future-funk': {
20 | title: 'Future Funk',
21 | genre: 'Funk',
22 | group: 'lofi',
23 | imageset: 'aesthetic',
24 | href: 'https://soundcloud.com/seki13/sets/vaporwave-future-funk',
25 | soundcloudURL: 'https://api.soundcloud.com/playlists/248817284'
26 | },
27 | '/vhs-dreams': {
28 | title: 'VHS Dreams',
29 | genre: 'Synthpop',
30 | group: 'lofi',
31 | imageset: 'aesthetic',
32 | href: 'https://soundcloud.com/user-231461939/sets/kaimyros-vaporwave-mix',
33 | soundcloudURL: 'https://api.soundcloud.com/playlists/335930552'
34 | },
35 | '/morning-bliss': {
36 | title: 'Morning Bliss',
37 | genre: 'Indie',
38 | group: 'electronic',
39 | imageset: 'lofi',
40 | href:
41 | 'https://soundcloud.com/user-876658341/sets/sunday-morning-kisses-and',
42 | soundcloudURL: 'https://api.soundcloud.com/playlists/424032182'
43 | },
44 | '/i-bless-the-rains': {
45 | title: 'Africa',
46 | genre: 'Misc',
47 | group: 'misc',
48 | imageset: 'africa',
49 | soundcloudURL: 'https://soundcloud.com/clemenswenners/africa',
50 | href: 'https://api.soundcloud.com/tracks/151129490'
51 | }
52 | // '/timewriter': {
53 | // title: 'Lazy Sundays',
54 | // genre: 'Tech House',
55 | // group: 'electronic',
56 | // href: 'https://soundcloud.com/jessinneijts/sets/timewriter-lazy-sundays',
57 | // soundcloudURL: 'https://api.soundcloud.com/playlists/109759947'
58 | // },
59 | // '/mushroom-jazz': {
60 | // title: 'Mushroom Jazz',
61 | // href: 'https://soundcloud.com/djmarkfarina/sets/mushroom-jazz-mixes',
62 | // soundcloudURL: 'https://api.soundcloud.com/playlists/309781662'
63 | // },
64 | // '/worldwide-fm': {
65 | // title: 'Worldwide FM',
66 | // genre: 'Electronic',
67 | // group: 'electronic',
68 | // href:
69 | // 'https://soundcloud.com/worldwide-fm/sets/new-music-worldwide-guest-mixes',
70 | // soundcloudURL: 'https://api.soundcloud.com/playlists/331692615'
71 | // },
72 | // '/mojo-essentials': {
73 | // title: 'Mojo Essentials',
74 | // genre: 'Soul/Jazz',
75 | // group: 'lofi',
76 | // href: 'https://soundcloud.com/mojo_club/sets/essentials',
77 | // soundcloudURL: 'https://api.soundcloud.com/playlists/230735978'
78 | // }
79 | }
80 |
81 | /*
82 | * Default export
83 | */
84 |
85 | module.exports = STATIONS
86 |
--------------------------------------------------------------------------------
/src/html.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | let stylesStr
4 |
5 | if (process.env.NODE_ENV === 'production') {
6 | try {
7 | stylesStr = require(`!raw-loader!../public/styles.css`)
8 | } catch (e) {
9 | console.log(e)
10 | }
11 | }
12 |
13 | module.exports = class HTML extends React.Component {
14 | render () {
15 | let css
16 |
17 | if (process.env.NODE_ENV === 'production') {
18 | css = (
19 |
23 | )
24 | }
25 |
26 | const {
27 | body,
28 | bodyAttributes,
29 | headComponents,
30 | htmlAttributes,
31 | postBodyComponents,
32 | preBodyComponents
33 | } = this.props
34 |
35 | return (
36 |
37 |
38 | {/* Meta */}
39 |
40 |
41 |
45 |
46 | {/* Android */}
47 |
48 |
49 |
50 | {/* Apple */}
51 |
52 |
53 |
54 |
55 | {headComponents}
56 | {css}
57 |
58 |
59 | {preBodyComponents}
60 |
66 | {postBodyComponents}
67 |
68 |
69 | )
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/layouts/css.js:
--------------------------------------------------------------------------------
1 | import 'sanitize.css'
2 | import './index.css'
3 |
--------------------------------------------------------------------------------
/src/layouts/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | padding: 0;
5 | height: 100%;
6 | }
7 |
8 | html {
9 | background: #111;
10 | color: #888;
11 | font-family: system-ui;
12 | }
13 |
14 | a,
15 | a:visited {
16 | text-decoration: none;
17 | }
18 |
19 | * {
20 | /* disable text select */
21 | user-select: none;
22 |
23 | /* disable callout, image save panel (popup) */
24 | -webkit-touch-callout: none;
25 |
26 | /* "turn off" link highlight */
27 | -webkit-tap-highlight-color: transparent;
28 | }
29 |
--------------------------------------------------------------------------------
/src/layouts/index.js:
--------------------------------------------------------------------------------
1 | /* global graphql */
2 |
3 | import React from 'react'
4 | import PropTypes from 'prop-types'
5 | import Helmet from 'react-helmet'
6 |
7 | import './css'
8 |
9 | const Layout = ({ children, data }) => (
10 |
11 |
18 | {children()}
19 |
20 | )
21 |
22 | Layout.propTypes = {
23 | children: PropTypes.func
24 | }
25 |
26 | export default Layout
27 |
28 | export const query = graphql`
29 | query SiteTitleQuery {
30 | site {
31 | siteMetadata {
32 | title
33 | }
34 | }
35 | allSitePage {
36 | edges {
37 | node {
38 | id
39 | path
40 | layout
41 | context {
42 | title
43 | href
44 | soundcloudURL
45 | }
46 | }
47 | }
48 | }
49 | }
50 | `
51 |
--------------------------------------------------------------------------------
/src/pages/404.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import React from 'react'
3 |
4 | const NotFoundPage = () => (
5 |
6 |
NOT FOUND
7 |
You just hit a route that doesn't exist... the sadness.
8 |
9 | )
10 |
11 | export default NotFoundPage
12 |
--------------------------------------------------------------------------------
/src/pages/list.js:
--------------------------------------------------------------------------------
1 | /* global graphql */
2 | import React from 'react'
3 | import IndexPage from '../components/IndexPage'
4 | import { getStations } from '../queries/all_stations'
5 |
6 | /**
7 | * Template for the home page.
8 | */
9 |
10 | export const IndexTemplate = ({ data }) => {
11 | const stations = getStations(data)
12 |
13 | return (
14 |
15 |
16 | {/*
{JSON.stringify(props, null, 2)} */}
17 |
18 | )
19 | }
20 |
21 | /*
22 | * Default export
23 | */
24 |
25 | export default IndexTemplate
26 |
27 | /**
28 | * GraphQL to get all pages
29 | */
30 |
31 | export const pageQuery = graphql`
32 | query IndexPageQuery {
33 | allSitePage {
34 | edges {
35 | node {
36 | id
37 | path
38 | layout
39 | context {
40 | title
41 | href
42 | soundcloudURL
43 | }
44 | }
45 | }
46 | }
47 | }
48 | `
49 |
--------------------------------------------------------------------------------
/src/queries/all_stations.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 |
3 | import type { Station, StationPage } from '../types'
4 |
5 | export type Edge = {
6 | node: {
7 | context: {} | Station,
8 | path: string
9 | }
10 | }
11 |
12 | export type Data = {
13 | allSitePage: {
14 | edges: Array
15 | }
16 | }
17 |
18 | /**
19 | * Returns stations as pages.
20 | *
21 | * A page has `{ path, title, href, soundcloudURL }`.
22 | */
23 |
24 | export function getStations (data: Data): Array {
25 | const edges = data && data.allSitePage && data.allSitePage.edges
26 | if (!edges) throw new Error('getStations(): no pages')
27 | return edges.filter(isStationEdge).map(edgeToStationPage)
28 | }
29 |
30 | /**
31 | * Checks if a given edge represents a station
32 | */
33 |
34 | function isStationEdge (edge: Edge) {
35 | return (
36 | edge.node &&
37 | edge.node.path &&
38 | edge.node.context &&
39 | edge.node.context.title &&
40 | edge.node.path !== '/'
41 | )
42 | }
43 |
44 | function edgeToStationPage (edge: Edge) {
45 | return { path: edge.node.path, ...edge.node.context }
46 | }
47 |
--------------------------------------------------------------------------------
/src/stores/SoundcloudStore.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import { Container } from 'unstated'
3 | import type { Action, PlayerStatus, SoundcloudSound } from '../types'
4 |
5 | const debug = require('debug')('app:SoundcloudStore')
6 |
7 | export type State = {
8 | state: PlayerStatus,
9 | sounds: ?Array,
10 | sound: ?SoundcloudSound,
11 | actions: Array
12 | }
13 |
14 | /**
15 | * Store
16 | */
17 |
18 | export default class SoundcloudStore extends Container {
19 | state = {
20 | state: 'PENDING', // 'READY' | 'PLAYING' | 'PAUSED'
21 | // The playlist
22 | sounds: null,
23 | // The current song
24 | sound: null,
25 | // Actions to be passed onto SoundCloud
26 | actions: []
27 | }
28 |
29 | reset () {
30 | this.setState({ state: 'PENDING', sounds: null, sound: null, actions: [] })
31 | }
32 |
33 | setPlayerState (state: PlayerStatus) {
34 | if (this.state.state === state) {
35 | debug('setPlayerState() ERR: Discarding conguent state', state)
36 | return
37 | }
38 |
39 | debug('setPlayerState() OK:', state)
40 | this.setState({ state })
41 | }
42 |
43 | setSounds (sounds: Array) {
44 | this.setState({ sounds })
45 | }
46 |
47 | setSound (sound: SoundcloudSound) {
48 | if (JSON.stringify(sound) === JSON.stringify(this.state.sound)) {
49 | debug('setSound() ERR: Discarding congruent sound', sound)
50 | return
51 | }
52 |
53 | debug('setSound() OK:', sound.user && sound.user.username, '-', sound.title)
54 |
55 | this.setState({ sound })
56 | // csound.permalink_url
57 | // csound.artwork_url
58 | // csound.description
59 | // csound.title
60 | }
61 |
62 | play () {
63 | this.setState({ actions: [{ type: 'PLAY' }] })
64 | }
65 |
66 | pause () {
67 | this.setState({ actions: [{ type: 'PAUSE' }] })
68 | }
69 |
70 | next () {
71 | this.setState({ actions: [{ type: 'NEXT' }] })
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/stores/UIStore.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 | import { Container } from 'unstated'
3 |
4 | export type State = {
5 | showSoundcloud: boolean
6 | }
7 |
8 | /**
9 | * Store that has UI state
10 | */
11 |
12 | export default class UIStore extends Container {
13 | state = {
14 | showSoundcloud: false
15 | }
16 |
17 | setSoundcloud (value: boolean) {
18 | this.setState({ showSoundcloud: value })
19 | }
20 |
21 | toggleSoundcloud () {
22 | this.setState({ showSoundcloud: !this.state.showSoundcloud })
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/styles/variables.js:
--------------------------------------------------------------------------------
1 | /* Style constants */
2 |
3 | export const gridSize = '3px'
4 |
5 | export const gridBg = '#1e1e24'
6 |
7 | /*
8 | * Cinemagraph images.
9 | *
10 | * See:
11 | * - https://www.tumblr.com/search/cinemagraph
12 | * - https://juliendouvier.tumblr.com/
13 | * - https://leahberman.tumblr.com/
14 | */
15 |
16 | export const images = [
17 | // Bike
18 | // https://juliendouvier.tumblr.com/post/163862485221#notes
19 | 'https://78.media.tumblr.com/e9cc23ae98e67f6956880d4ca90a0052/tumblr_ou9eyoISBp1s85u2fo1_500.gif',
20 |
21 | // Train
22 | // https://juliendouvier.tumblr.com/post/160514365031#notes
23 | 'https://78.media.tumblr.com/fda8bfa3905d45603d90d72adb3086df/tumblr_opqowiAmXO1s85u2fo1_500.gif',
24 |
25 | // Escalator
26 | // https://julie11.tumblr.com/post/97213975641/my-escalator-series-is-officially-featured-on#notes
27 | 'https://78.media.tumblr.com/89ce4c298ad5d014ec7b1ea83dabec8c/tumblr_mwf80y6rGe1s85u2fo1_500.gif',
28 |
29 | // VU meter
30 | // https://juliendouvier.tumblr.com/post/92522918506#notes
31 | 'https://78.media.tumblr.com/c83b5721adeaeaa74fce184fa51f8526/tumblr_n940167n2Y1s85u2fo1_r1_500.gif',
32 |
33 | // Lake
34 | // https://juliendouvier.tumblr.com/post/101372057946#notes
35 | 'https://78.media.tumblr.com/b74e2e5f995eba9bfa1a7c862bf5629b/tumblr_nea4onxsgk1s85u2fo1_500.gif',
36 |
37 | // Technics
38 | // https://juliendouvier.tumblr.com/post/82798370487#notes
39 | 'https://78.media.tumblr.com/6ff92fcad7e9b36081d43b748dafe2ad/tumblr_n42xwoHXXg1s85u2fo1_500.gif',
40 |
41 | // Cafe by the River
42 | // https://juliendouvier.tumblr.com/post/77004517983#notes
43 | 'https://78.media.tumblr.com/d16117a8222afc4d4ee6e7ef175096cb/tumblr_n15x6iu5uE1s85u2fo1_500.gif',
44 |
45 | // Creek
46 | // https://juliendouvier.tumblr.com/post/76011830515#notes
47 | 'https://78.media.tumblr.com/b2b9c8348b0b66a43f4b8584db5f12d1/tumblr_n05vpfV6MG1s85u2fo1_500.gif'
48 | ]
49 |
--------------------------------------------------------------------------------
/src/templates/PlayerTemplate.js:
--------------------------------------------------------------------------------
1 | /* global graphql */
2 | import React from 'react'
3 | import PlayerPage from '../components/PlayerPage'
4 | import { Subscribe } from 'unstated'
5 | import SoundcloudStore from '../stores/SoundcloudStore'
6 | import { getStations } from '../queries/all_stations'
7 |
8 | /*
9 | * Template for player pages
10 | */
11 |
12 | export const PlayerTemplate = props => {
13 | const { pathContext: ctx, data } = props
14 | const stations = getStations(data)
15 |
16 | // const { title, href, soundcloudURL } = ctx
17 | return (
18 |
19 | {soundcloud => (
20 |
21 |
22 |
23 | )}
24 |
25 | )
26 | }
27 |
28 | /**
29 | * Tells the SoundcloudStore to reset itself when this appears.
30 | *
31 | * If Soundcloud was previously playing and you navigate to a new station, we
32 | * want to reset the state back from `state: 'PLAYING'` to `state: 'PENDING'`.
33 | */
34 |
35 | class SoundcloudReseter extends React.Component {
36 | componentDidMount () {
37 | this.props.soundcloud.reset()
38 | }
39 |
40 | render () {
41 | return this.props.children
42 | }
43 | }
44 |
45 | /*
46 | * Export
47 | */
48 |
49 | export default PlayerTemplate
50 |
51 | /**
52 | * GraphQL to get all stations
53 | */
54 |
55 | export const pageQuery = graphql`
56 | query PlayerTemplateQuery {
57 | allSitePage {
58 | edges {
59 | node {
60 | id
61 | path
62 | layout
63 | context {
64 | title
65 | href
66 | soundcloudURL
67 | imageset
68 | }
69 | }
70 | }
71 | }
72 | }
73 | `
74 |
--------------------------------------------------------------------------------
/src/types/index.js:
--------------------------------------------------------------------------------
1 | /* @flow */
2 |
3 | export type StationGroup = 'lofi' | 'electronic'
4 |
5 | export type Station = {
6 | default?: boolean,
7 | title: string,
8 | group: StationGroup,
9 | genre: ?string,
10 | href: string,
11 | soundcloudURL: string
12 | }
13 |
14 | export type StationList = {
15 | [string]: Station
16 | }
17 |
18 | // As given by all_stations.js
19 | export type StationPage = {|
20 | default?: boolean,
21 | title: string,
22 | group: StationGroup,
23 | genre: ?string,
24 | href: string,
25 | soundcloudURL: string,
26 | path: string
27 | |}
28 |
29 | export type Action = {
30 | type: string
31 | }
32 |
33 | // `window.SC`
34 | export type SoundcloudAPI = any
35 |
36 | export type SoundcloudSound = {
37 | title: string,
38 | user?: {
39 | username: string
40 | }
41 | }
42 |
43 | // The return of `SC.widget(iframe)`
44 | export type SoundcloudWidget = {
45 | play: () => void,
46 | pause: () => void,
47 | bind: (any, () => void) => void,
48 | getSounds: ((any) => void) => void,
49 | skip: number => void,
50 | getCurrentSound: ((?SoundcloudSound) => void) => void
51 | }
52 |
53 | export type PlayerStatus = 'PENDING' | 'READY' | 'PLAYING' | 'PAUSED'
54 |
55 | // Possible UI actions for keymaps
56 | export type UIAction = 'ui.toggleSoundcloud' | 'media.playPause'
57 |
58 | // KeyMap for hotkeys
59 | export type KeyMap = {
60 | [UIAction]: string
61 | }
62 |
63 | export type Imageset = 'lofi' | 'aesthetic' | 'africa'
64 |
--------------------------------------------------------------------------------