gatsbyjs / gatsby

The best React-based framework with performance, scalability and security built in.
https://www.gatsbyjs.com
MIT License
55.26k stars 10.32k forks source link

Media players and Gatsby #9214

Closed jserrao closed 5 years ago

jserrao commented 5 years ago

I'm working on a new v2 Gatsby project and have added in the usually great react-media-player. It works great locally as expected but makes use of audioContext on the window object.

When I go to build the site I run into that familiar window not defined as there is no window for server-side generated React sites like Gatsby. I've read through the Debugging HTML Builds but that'd be a lot of hacking on this player.

I'm wondering if there is a best practice or recommended media player that works with SSR. I haven't seen anything out there, thought maybe team Gatsby had some recommendations.

pieh commented 5 years ago

I think You just need to not render react-media-player component in SSR - one way to do it is

import ReactMediaPlayer from "react-media-player"

...

<div>
  {typeof window !== 'undefined' && <ReactMediaPlayer/>}
</div>

This will work if that component only reference window object inside react component lifecycle methods - if it still doesn't work You will need to use null-loader trick from https://www.gatsbyjs.org/docs/debugging-html-builds/ in your gatsby-node.js and then in your components something like that:

import ReactMediaPlayer from "react-media-player"

...

<div>
  {ReactMediaPlayer && <ReactMediaPlayer/>}
</div>
KyleAMathews commented 5 years ago

You can also PR a fix to the component so it doesn't check window APIs during SSR. Most maintainers are happy to take PRs like that and it ensures everyone else who uses the component has a great experience.

Let us know if you need any more help!

jserrao commented 5 years ago

Helpful feedback, thanks gentlemen. I moved some easily offending window references in my component into componentDidMount() and that cleared up the initial build issue. But that revealed deeper dependencies in react-media-player that have issues:

  75 |     key: '_createAudioObject',
  76 |     value: function _createAudioObject(src) {
> 77 |       this._player = new Audio(src);
     | ^
  78 |     }
  79 |   }, {
  80 |     key: '_destroyAudioObject',

  WebpackError: ReferenceError: Audio is not defined

  - AudioObject.js:77 AudioObject._createAudioObject
    [lib]/[react-media-player]/lib/vendors/AudioObject.js:77:1

  - AudioObject.js:52 AudioObject.componentWillMount
    [lib]/[react-media-player]/lib/vendors/AudioObject.js:52:1

I guess I need to put that gatsby-node.js loaders.null() hack into service. Not 100% how that works based on the docs. My question is regarding how to configure this trick. My issue is at node-modules/react-media-player/lib/vendors/AudioObject.js - do I more globally test for /node-modules/react-media-player or a specific dependency. And what exactly is going on with this switch in Webpack? This is why I love Parcel.js!

An attempt:

exports.onCreateWebpackConfig = ({ stage, loaders, actions }) => {
  if (stage === "build-html") {
    actions.setWebpackConfig({
      module: {
        rules: [
          {
            test: '/node-modules/react-media-player/',
            use: loaders.null(),
          },
        ],
      },
    })
  }
}
pieh commented 5 years ago

The test should be /react-media-player/ (no quotes around it - it's regex test). it will check the way you import module and not its path in node_modules. This will catch imports like: import ReactMediaPlayer from "react-media-player" or import SomethingElse from "react-media-player/someOtherThing"

If the switch you refer to is if (stage === "build-html") then this is needed so it only happens when we build static html (that's gatsby thing - not webpack).

Not sure if parcel would be much different here (but I never used it so I might be wrong)

jserrao commented 5 years ago

@pieh - thanks for the suggestions. I got the gatsby loaders.null() thing working but that leads to more errors down the dependency chain. How far out do I have to nullify components?

This error seems to be created by the fact webpack is no longer loading react-media-player. I feel like I'm chasing my tail!

WebpackError: TypeError: Object(...) is not a function

  - PlayPause.js:59 Module../src/components/AudioPlayer/PlayPause.js
    lib/src/components/AudioPlayer/PlayPause.js:59:31

  - bootstrap:19 __webpack_require__
    lib/webpack/bootstrap:19:1

  - index.js:1 Module../src/components/AudioPlayer/index.js
    lib/src/components/AudioPlayer/index.js:1:1

  - bootstrap:19 __webpack_require__
    lib/webpack/bootstrap:19:1

  - index.js:1 Module../src/components/CaseStudyStepSummary/index.js
    lib/src/components/CaseStudyStepSummary/index.js:1:1

  - bootstrap:19 __webpack_require__
    lib/webpack/bootstrap:19:1

  - index.js:1 Module../src/pages/cases/health-maintenance-primary-care/index.js
    lib/src/pages/cases/health-maintenance-primary-care/index.js:1:1

  - bootstrap:19 __webpack_require__
    lib/webpack/bootstrap:19:1

  - sync-requires.js:9 Object../.cache/sync-requires.js
    lib/.cache/sync-requires.js:9:89

  - bootstrap:19 __webpack_require__
    lib/webpack/bootstrap:19:1

  - static-entry.js:9 Module../.cache/static-entry.js
    lib/.cache/static-entry.js:9:22

  - bootstrap:19 __webpack_require__
    lib/webpack/bootstrap:19:1

  - bootstrap:83
    lib/webpack/bootstrap:83:1

  - universalModuleDefinition:3 webpackUniversalModuleDefinition
    lib/webpack/universalModuleDefinition:3:1

  - universalModuleDefinition:10 Object.<anonymous>
    lib/webpack/universalModuleDefinition:10:2

Offending component - seems like withMediaProps never gets loaded so this whole thing errors:

import React, { Component } from 'react'
import { withMediaProps } from 'react-media-player'
import Transition from 'react-motion-ui-pack'

class ScaleX extends Component {
  render() {
    return (
      <Transition
        component="g"
        enter={{ scaleX: 1 }}
        leave={{ scaleX: 0 }}
      >
        {this.props.children}
      </Transition>
    )
  }
}

class PlayPause extends Component {
  _handlePlayPause = () => {
    this.props.media.playPause()
  }

  render() {
    const { media: { isPlaying }, className } = this.props
    return (
      <svg
        role="button"
        width="36px"
        height="36px"
        viewBox="0 0 36 36"
        className={className}
        onClick={this._handlePlayPause}
      >
        <circle fill="#eaebec" cx="18" cy="18" r="18"/>
          <ScaleX>
            { isPlaying &&
              <g key="pause" style={{ transformOrigin: '0% 50%' }}>
                  <rect x="12" y="11" fill="#3b3b3b" width="4" height="14"/>
                  <rect x="20" y="11" fill="#3b3b3b" width="4" height="14"/>
              </g>
            }
          </ScaleX>
          <ScaleX>
            { !isPlaying &&
              <polygon
                key="play"
                fill="#3b3b3b"
                points="14,11 26,18 14,25"
                style={{ transformOrigin: '100% 50%' }}
              />
            }
          </ScaleX>
      </svg>
    )
  }
}

export default withMediaProps(PlayPause)

I combed through https://www.gatsbyjs.org/docs/behind-the-scenes/ trying to understand this product better, Gatsby is crazy under the hood. Not sure where to go next on this debugging journey.

jserrao commented 5 years ago

For future googlers / those interested, the final solution to this issue required a quite a few hoops to jump through on my end. I'll walk through all the things it took on my end, hope this helps others:

AudioPlayer Component Issues

AudioPlayer.js

import React, { Component } from 'react'
import { Media, Player, controls } from 'react-media-player'
import PlayPause from '../../components/AudioPlayerPlayPause'
import MuteUnmute from '../../components/AudioPlayerMuteUnmute'

const { CurrentTime, SeekBar, Duration, Volume } = controls
let panner = null

class AudioPlayer extends Component {

  componentDidMount() {
    const audioContext = new (window.AudioContext || window.webkitAudioContext)()
    panner = audioContext.createPanner()

    panner.setPosition(0, 0, 1)
    panner.panningModel = 'equalpower'
    panner.connect(audioContext.destination)

    const source = audioContext.createMediaElementSource(this._player.instance)
    source.connect(panner)
    panner.connect(audioContext.destination)
  }

  _handlePannerChange = ({ target }) => {
    const x = +target.value
    const y = 0
    const z = 1 - Math.abs(x)
    panner.setPosition(x, y, z)
  }

  render() {
    return (
      <div>
        { typeof window !== 'undefined' && Media && 
          <Media>   
            <div>
              <Player
                ref={c => this._player = c}
                src={this.props.src}
                useAudioObject
              />
              <section className="media-controls">
                <div className="media-title-box">
                  { typeof window !== 'undefined' && PlayPause && 
                    <PlayPause className="media-control media-control--play-pause"/>
                  }
                  <div className="media-title-content">
                    <div className="media-title">{ this.props.mediaTitle }</div>
                    <div className="media-subtitle">{ this.props.mediaSubtitle }</div>
                  </div>
                </div>
                <div className="media-controls-container">
                  <CurrentTime className="media-control media-control--current-time"/>
                  <SeekBar className="media-control media-control--volume-range"/>
                  <Duration className="media-control media-control--duration"/>
                </div>
                <div className="media-sound-controls">
                  { typeof window !== 'undefined' && MuteUnmute && 
                    <MuteUnmute className="media-control media-control--mute-unmute"/>
                  }
                  <Volume className="media-control media-control--volume"/>
                </div>            
              </section>
            </div>
          </Media>
        }
      </div>
    )
  }
}

export default AudioPlayer

PlayPause + MuteUnmute Component Issues

MuteUnmute.js:

import React, { Component } from 'react'
import { withMediaProps } from 'react-media-player'
import Transition from 'react-motion-ui-pack'

class MuteUnmute extends Component {
  _handleMuteUnmute = () => {
    this.props.media.muteUnmute()
  }

  render() {
    const { media: { volume }, className } = this.props
    return (
      <svg width="36px" height="36px" viewBox="0 0 36 36" className={className} onClick={this._handleMuteUnmute}>
        <circle fill="#eaebec" cx="18" cy="18" r="18"/>
        <polygon fill="#3b3b3b" points="11,14.844 11,21.442 14.202,21.442 17.656,25 17.656,11 14.074,14.844"/>
        <Transition
          component="g"
          enter={{ scale: 1 }}
          leave={{ scale: 0 }}
        >
          { volume >= 0.5 &&
            <path key="first-bar" fill="#3b3b3b" d="M24.024,14.443c-0.607-1.028-1.441-1.807-2.236-2.326c-0.405-0.252-0.796-0.448-1.153-0.597c-0.362-0.139-0.682-0.245-0.954-0.305c-0.058-0.018-0.104-0.023-0.158-0.035v1.202c0.2,0.052,0.421,0.124,0.672,0.22c0.298,0.125,0.622,0.289,0.961,0.497c0.662,0.434,1.359,1.084,1.864,1.94c0.26,0.424,0.448,0.904,0.599,1.401c0.139,0.538,0.193,0.903,0.216,1.616c-0.017,0.421-0.075,1.029-0.216,1.506c-0.151,0.497-0.339,0.977-0.599,1.401c-0.505,0.856-1.202,1.507-1.864,1.94c-0.339,0.209-0.663,0.373-0.961,0.497c-0.268,0.102-0.489,0.174-0.672,0.221v1.201c0.054-0.012,0.1-0.018,0.158-0.035c0.272-0.06,0.592-0.166,0.954-0.305c0.358-0.149,0.748-0.346,1.153-0.597c0.795-0.519,1.629-1.298,2.236-2.326C24.639,20.534,24.994,19.273,25,18C24.994,16.727,24.639,15.466,24.024,14.443z"/>
          }
        </Transition>
        <Transition
          component="g"
          enter={{ scale: 1 }}
          leave={{ scale: 0 }}
        >
          { volume > 0 &&
            <path key="second-bar" fill="#3b3b3b" d="M21.733,18c0-1.518-0.91-2.819-2.211-3.402v6.804C20.824,20.818,21.733,19.518,21.733,18z"/>
          }
        </Transition>
        <Transition
          component="g"
          enter={{ scale: 1 }}
          leave={{ scale: 0 }}
        >
          { volume === 0 &&
            <polygon key="mute" fill="#3b3b3b" points="24.839,15.955 23.778,14.895 21.733,16.94 19.688,14.895 18.628,15.955 20.673,18 18.628,20.045 19.688,21.106 21.733,19.061 23.778,21.106 24.839,20.045 22.794,18 "/>
          }
        </Transition>
      </svg>
    )
  }
}

export default withMediaProps(MuteUnmute)

PlayPause.js:

import React, { Component } from 'react'
import { withMediaProps } from 'react-media-player'
import Transition from 'react-motion-ui-pack'

class PlayPause extends Component {
  _handlePlayPause = () => {
    this.props.media.playPause()
  }

  render() {
    const { media: { isPlaying }, className } = this.props
    return (
      <svg
        role="button"
        width="36px"
        height="36px"
        viewBox="0 0 36 36"
        className={className}
        onClick={this._handlePlayPause}
      >
        <circle fill="#eaebec" cx="18" cy="18" r="18"/>
          <Transition
            component="g"
            enter={{ scaleX: 1 }}
            leave={{ scaleX: 0 }}
          >
            { isPlaying &&
              <g key="pause" style={{ transformOrigin: '0% 50%' }}>
                  <rect x="12" y="11" fill="#3b3b3b" width="4" height="14"/>
                  <rect x="20" y="11" fill="#3b3b3b" width="4" height="14"/>
              </g>
            }
          </Transition>
          <Transition
            component="g"
            enter={{ scaleX: 1 }}
            leave={{ scaleX: 0 }}
          >
            { !isPlaying &&
              <polygon
                key="play"
                fill="#3b3b3b"
                points="14,11 26,18 14,25"
                style={{ transformOrigin: '100% 50%' }}
              />
            }
          </Transition>
      </svg>
    )
  }
}

export default withMediaProps(PlayPause)

gatsby-node.js

/**
 * Implement Gatsby's Node APIs in this file.
 *
 * See: https://www.gatsbyjs.org/docs/node-apis/
 */

exports.onCreateWebpackConfig = ({ stage, loaders, actions }) => {
  if (stage === "build-html") {
    actions.setWebpackConfig({
      module: {
        rules: [
          {
            test: /AudioPlayer|PlayPause|MuteUnmute/,
            use: loaders.null(),
          },
        ],
      },
    })
  }
}

Package / build issues

*I was using Node v10.12 and needed to bump node-gyp up for whatever reason. After combing the web, best I could surmise is that my global node-gyp package was out of step with the latest Node, so it bonked. npm install -g node-gyp and then node-gyp -v should yield 3.8.0. That version got my build going.

package.json ended up like this for me:

  "dependencies": {
    "gatsby": "^2.0.12",
    "gatsby-cli": "^2.4.3",
    "gatsby-link": "^2.0.2",
    "gatsby-plugin-manifest": "^2.0.4",
    "gatsby-plugin-react-helmet": "^3.0.0",
    "gatsby-plugin-sass": "^2.0.1",
    "node-sass": "^4.9.3",
    "prop-types": "^15.6.2",
    "react": "^16.4.2",
    "react-anchor-link-smooth-scroll": "^1.0.11",
    "react-dom": "^16.4.2",
    "react-helmet": "^5.2.0",
    "react-hot-loader": "^4.3.11",
    "react-media-player": "^0.7.1",
    "react-modal": "^3.5.1",
    "react-motion-ui-pack": "^0.10.3",
    "react-tabs": "^2.2.2",
    "react-transition-group": "^2.4.0",
    "react-waypoint": "^8.0.3"
  }

Finally, a shout out to @pieh + @KyleAMathews and the rest of team Gatsby - the Gatsby ecosystem and support remains amongst the best in the OSS community. I'll keep contributing where I can. Thanks for all you've given us.

interglobalmedia commented 5 years ago

Hi, I am having a similar issue as well, and when I go to https://reactjs.org/docs/error-decoder.html?invari ant=130&args[]=undefined&args[]= there is nothing there. The problem is that I have no errors when I run npx gatsby develop. So I don't know where the error is coming from. I am not using a media player, but wanted to respond here because the error on build I was getting was the same, but no message when I when to the decoder page. Was blank. Why have that message show up in Terminal then, when nothing is rendered in the linked page?

Update: I looked again in what was being printed to Terminal in npx gatsby develop, and I had changed my import of an emotion styled component from the parent component to a child, and that is what caused the problem. So when I placed the styled components in the parent component and imported them into the child components, I no longer had a warning in npx gatsby develop and when I ran npm run build, it was successful. I still think that if we are not going to get any messages in the decoder page, that message should be removed from npm run build error(s). It's very confusing.

rchrdnsh commented 4 years ago

I am running into this issue as well, but with a custom made component using hooks. Not sure how I could translate this solution to my use case, tho. Any thoughts would be much appreciated.

I have the Audio() inside a useState declaration, like so:

const [state, setState] = useState({
    audioPlayer: new Audio(),
    currentTrackIndex: null,
    isPlaying: false,
  })

Since it's not a react class component how could I go about doing the same or a similar thing here?

jserrao commented 4 years ago

@rchrdnsh - might be a bigger refactor of the library required. Would be interesting for the maintainer to jump in @souporserious

rchrdnsh commented 4 years ago

I would love that! I'm super stumped at this point, so yes, any help would be much appreciated :-)

Repo: https://github.com/rchrdnsh/RYKR

@DSchau and @jlengstorf have suggested a few things on the twitterz as well, but because this is a react context file and it's sprinkled over the entire app, I think it's a bit more complicated, but maybe these ideas can help...I'm trying to figure it out myself as well, but I'm still relatively new to JS and very new to react and gatsby:

Jason suggested this for setting up the ability to test if the code is being run on the client:

const [isClient, setIsClient] = useState(false)

useEffect(() => {
  setIsClient(true)
}, [])

const [state, setState] = useState({}) 

if (isClient) {
 setState({
    audioPlayer: new Audio(),
    currentTrackIndex: null,
    isPlaying: false,
  })
}

...but then I got this:

ENjKhvaU0AAV0ZV

as there are a bunch of little audio pieces that are affected by this change...I'm trying to go through the file and also add extensive commenting as best as i can, but you might see it by the time you take a look at it...

all of this code will be in the player folder in the MusicPlayerContext.js file...

souporserious commented 4 years ago

Hello 👋this is definitely an issue with react-media-player in need of a refactor now that hooks are out as well as better SSR support. I'm hoping to revisit the library this year and revamp everything to update it.

I'm not sure if this works for you in the meantime @rchrdnsh, but could you possibly lazy load the import so it isn't server-side rendered?

rchrdnsh commented 4 years ago

well, sorry if i was not clear, but i'm actually not using react-media-player, but my own version of a media player. But also, I don't really know how to lazy-load it, to be honest, as this is something that I've never done before.

souporserious commented 4 years ago

Oops! I was trying to follow the thread and wasn't exactly sure 😇. Following the React.lazy guide might help with lazy loading.

rchrdnsh commented 4 years ago

so, @universse came up with a simple and awesome solution for my issue, which was to declare the audioPlayer as an empty object, then inject the Audio() element in a useEffect hook, like so:

const [state, setState] = useState({
  audioPlayer: {},
  currentTrackIndex: null,
  isPlaying: false,
})

const [currentTime, setCurrentTime] = useState(state.audioPlayer.currentTime)

useEffect(() => {
  setState({
    audioPlayer: new Audio(),
    currentTrackIndex: null,
    isPlaying: false,
  })
}, [])

...and it builds now! XD

jamesgrubb commented 4 years ago

Hi, I think I am using the same useContext hook tutorial. I wonder was it necessary to include the currentTrackIndex, and the isPlaying in the useEffect to get this working?