diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..683b18b --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ['react', 'es2015-webpack'] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..389c71a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# node.js +# +node_modules/ +npm-debug.log + +# Vim +# swap +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +# session +Session.vim +# temporary +.netrwhist +*~ +# auto-generated tag files +tags + +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7803db --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# NeteaseCloudMusic Electron + +网易云音乐Electron版 +`` 开发中 `` + +## 进度 +目前只有基本功能 +* 搜索歌曲+播放(版权歌曲无法播放 +* 播放列表 +* 手机登陆 +* 个人歌单(创建,收藏 +* [TODO] 歌曲界面 +* [TODO] 主页推荐 +* [TODO] 私人FM +![预览截图](http://7xn38i.com1.z0.glb.clouddn.com/snapshot5.png) + +## Build +目前还没有打包,如果想预览开发效果可以按以下步骤自行构建 + +```bash +git clone https://github.com/disoul/electron-cloud-music && cd electron-cloud-music +npm install +npm install webpack@2.1.0-beta.5 -g +npm install webpack-dev-server@2.0.0-beta -g +npm install electron-prebuilt -g +webpack-dev-server --inline --compress --content-base=./ + +// run cloudmusic in proj root path +// electron will load from 127.0.0.1:8080(webpack-dev-server +electron ./ +``` diff --git a/app/actions/actions.js b/app/actions/actions.js new file mode 100644 index 0000000..d839776 --- /dev/null +++ b/app/actions/actions.js @@ -0,0 +1,180 @@ +'use strict' +import { Search, Login, getPlayList, SonglistDetail } from '../server'; +export function play() { + return { type: 'PLAYER', state: 'PLAYER_PLAY' }; +} + +export function pause() { + return { type: 'PLAYER', state: 'PLAYER_PAUSE' }; +} + +export function startSearch(keywords) { + return { type: 'SEARCH', state: 'START', payload: { keywords: keywords }} +} + +export function errorSearch(e) { + return { type: 'SEARCH', state: 'ERROR', payload: e } +} + +export function finishSearch(res) { + return { type: 'SEARCH', state: 'FINISH', payload: res } +} + +export function closeSearch() { + return { type: 'SEARCH', state: 'CLOSE' } +} + +export function search(keywords) { + return dispatch => { + dispatch(startSearch(keywords)); + Search(keywords).then( res => { + dispatch(finishSearch(res)); + } ) + .catch( e => { + dispatch(errorSearch(e)); + } ) + }; +} + +export function changeSong(song) { + return { type: 'SONG', state: 'CHANGE', payload: song} +} + +export function playFromList(index) { + return { type: 'SONG', state: 'PLAYFROMLIST', payload: index} +} + +export function addSong(song) { + return { type: 'SONG', state: 'ADD', payload: song} +} + +export function addSongList(songlist, isplay) { + return { type: 'SONG', state: 'ADDLIST', payload: { + songlist: songlist, + play: isplay, + } + } +} + +export function nextSong() { + return { type: 'SONG', state: 'NEXT' } +} + +export function previousSong() { + return { type: 'SONG', state: 'PREVIOUS' } +} + +export function changeRule() { + return { type: 'SONG', state: 'CHANGERULE' } +} + +export function showPlayList() { + return { type: 'SONG', state: 'SHOWPLAYLIST' } +} + +export function closePlayList() { + return { type: 'SONG', state: 'CLOSEPLAYLIST' } +} + +export function logging_in(form) { + return { type: 'USER', state: 'LOGIN_STATE_LOGGING_IN', payload: form } +}; + +export function logged_in(res) { + return { type: 'USER', state: 'LOGIN_STATE_LOGGED_IN', payload: res } +}; + +export function logged_failed(errorinfo) { + return { type: 'USER', state: 'LOGIN_STATE_LOGGED_FAILED', payload: errorinfo } +} + +export function loginform(flag) { + return { type: 'USER', state: 'LOGINFORM', payload: flag } +} + +export function toguest() { + return { type: 'USER', state: 'GUEST' } +} + +export function login(form) { + return dispatch => { + dispatch(logging_in(form)); + Login(form.phone, form.password) + .then(res => { + localStorage.setItem('user', JSON.stringify(res)); + dispatch(logged_in(res)); + dispatch(fetchusersong(res.profile.userId)); + }) + .catch(error => { + dispatch(logged_failed(error.toString())); + }); + } +} + +export function fetchingusersong(id) { + return { type: 'USERSONG', state: 'FETCHING', payload: id } +} + +export function getusersong(res) { + return { type: 'USERSONG', state: 'GET', payload: res } +} + +export function fetchusersongerror(err) { + return { type: 'USERSONG', state: 'ERROR', payload: err } +} + +export function fetchusersong(uid) { + return dispatch => { + dispatch(fetchingusersong(uid)); + getPlayList(uid) + .then(res => { + dispatch(getusersong(res.playlist)); + }) + .catch(err => { + dispatch(fetchusersongerror(err)); + }); + } +} + +// push content to routerstack +export function push(content) { + return { type: 'ROUTER', state: 'PUSH', payload: content } +} + +export function pop() { + return { type: 'ROUTER', state: 'PUSH' } +} + +// 获取歌单内容 +export function fetchsonglistdetail(id) { + return dispatch => { + dispatch(fetchingsonglistdetail(id)); + SonglistDetail(id) + .then( res => { + dispatch(getsonglistdetail(res)); + }) + .catch(error => { + dispatch(fetchsonglistdetailerror(error)); + }); + }; +} + +export function fetchingsonglistdetail(id) { + return { type: 'SONGLIST', state: 'FETCHING', payload: id } +} + +export function getsonglistdetail(res) { + return { type: 'SONGLIST', state: 'GET', payload: res } +} + +export function fetchsonglistdetailerror(err) { + return { type: 'SONGLIST', state: 'ERROR', payload: err } +} + +export function showplaycontentmini() { + return { type: 'PLAYCONTENT', state: 'SHOWMINI' } +} + +export function hiddenplaycontentmini() { + return { type: 'PLAYCONTENT', state: 'HIDDENMINI' } +} diff --git a/app/assets/icon.png b/app/assets/icon.png new file mode 100644 index 0000000..e81305d Binary files /dev/null and b/app/assets/icon.png differ diff --git a/app/assets/icon.svg b/app/assets/icon.svg new file mode 100644 index 0000000..3179d92 --- /dev/null +++ b/app/assets/icon.svg @@ -0,0 +1,95 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/icon/add.svg b/app/assets/icon/add.svg new file mode 100644 index 0000000..9a3b1b0 --- /dev/null +++ b/app/assets/icon/add.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/close.svg b/app/assets/icon/close.svg new file mode 100644 index 0000000..fef83a5 --- /dev/null +++ b/app/assets/icon/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/loop.svg b/app/assets/icon/loop.svg new file mode 100644 index 0000000..5147343 --- /dev/null +++ b/app/assets/icon/loop.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/max.svg b/app/assets/icon/max.svg new file mode 100644 index 0000000..8c3bf0e --- /dev/null +++ b/app/assets/icon/max.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/icon/min.svg b/app/assets/icon/min.svg new file mode 100644 index 0000000..fa26c46 --- /dev/null +++ b/app/assets/icon/min.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/icon/music.svg b/app/assets/icon/music.svg new file mode 100644 index 0000000..6e498ac --- /dev/null +++ b/app/assets/icon/music.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/assets/icon/next.svg b/app/assets/icon/next.svg new file mode 100644 index 0000000..e90c34b --- /dev/null +++ b/app/assets/icon/next.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/one.svg b/app/assets/icon/one.svg new file mode 100644 index 0000000..1cfd6b9 --- /dev/null +++ b/app/assets/icon/one.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/pause.svg b/app/assets/icon/pause.svg new file mode 100644 index 0000000..4c1b11e --- /dev/null +++ b/app/assets/icon/pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/play.svg b/app/assets/icon/play.svg new file mode 100644 index 0000000..2b52124 --- /dev/null +++ b/app/assets/icon/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/playlist.svg b/app/assets/icon/playlist.svg new file mode 100644 index 0000000..61f4d63 --- /dev/null +++ b/app/assets/icon/playlist.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/previous.svg b/app/assets/icon/previous.svg new file mode 100644 index 0000000..7147e7b --- /dev/null +++ b/app/assets/icon/previous.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/search.svg b/app/assets/icon/search.svg new file mode 100644 index 0000000..232112c --- /dev/null +++ b/app/assets/icon/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/shuffle.svg b/app/assets/icon/shuffle.svg new file mode 100644 index 0000000..e1ee0fa --- /dev/null +++ b/app/assets/icon/shuffle.svg @@ -0,0 +1,20 @@ + + + diff --git a/app/assets/icon/volume.svg b/app/assets/icon/volume.svg new file mode 100644 index 0000000..e39cf2b --- /dev/null +++ b/app/assets/icon/volume.svg @@ -0,0 +1,22 @@ + + + + diff --git a/app/assets/icon/volume_max.svg b/app/assets/icon/volume_max.svg new file mode 100644 index 0000000..e0caf08 --- /dev/null +++ b/app/assets/icon/volume_max.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icon/volume_min.svg b/app/assets/icon/volume_min.svg new file mode 100644 index 0000000..1250e8f --- /dev/null +++ b/app/assets/icon/volume_min.svg @@ -0,0 +1,21 @@ + + + diff --git a/app/assets/icon/volume_mute.svg b/app/assets/icon/volume_mute.svg new file mode 100644 index 0000000..2df9820 --- /dev/null +++ b/app/assets/icon/volume_mute.svg @@ -0,0 +1,21 @@ + + + diff --git a/app/assets/img/up.svg b/app/assets/img/up.svg new file mode 100644 index 0000000..27b3020 --- /dev/null +++ b/app/assets/img/up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/logo.svg b/app/assets/logo.svg new file mode 100644 index 0000000..d24e7b0 --- /dev/null +++ b/app/assets/logo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/app/components/AlbumCard.jsx b/app/components/AlbumCard.jsx new file mode 100644 index 0000000..ef379b1 --- /dev/null +++ b/app/components/AlbumCard.jsx @@ -0,0 +1,54 @@ +import React, { Component } from 'react'; + +export default class AlbumCard extends Component { + constructor(props: any) { + super(props); + } + + _addsonglist(e, isplay) { + this.props.addSongList(this.props.songs, isplay); + } + + render() { + return ( +
+
+ +
+

{this.props.data.playCount}

+
+
+
+
+

+ {this.props.data.name} +

+

+ 来自:{this.props.data.creator.nickname} +

+
+
+ {this.props.data.tags.length > 0 ?

TAGS:

: ''} + {this.props.data.tags.map(tag => { + return ( +
+ {tag} +
+ ); + })} +
+
+ + +
+
+
+ ); + } +} diff --git a/app/components/App.jsx b/app/components/App.jsx new file mode 100644 index 0000000..2726ad0 --- /dev/null +++ b/app/components/App.jsx @@ -0,0 +1,84 @@ +'use strict' +import React, { Component } from 'react'; +import Header from './Header.jsx'; +import Content from './Content.jsx'; +import Player from './Player.jsx'; +import LoginForm from './LoginForm.jsx'; +import PlayContentCard from './PlayContentCard.jsx'; + +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import * as Actions from '../actions/actions'; + +const mapStateToProps = state => ({ + player: state.player, + search: state.search, + song: state.song, + user: state.user, + usersong: state.usersong, + router: state.router, + songlist: state.songlist, + playcontent: state.playcontent, +}); + +const mapDispatchToProps = (dispatch) => ({ + actions: { + play: bindActionCreators(Actions.play, dispatch), + pause: bindActionCreators(Actions.pause, dispatch), + search: bindActionCreators(Actions.search, dispatch), + closeSearch: bindActionCreators(Actions.closeSearch, dispatch), + changeSong: bindActionCreators(Actions.changeSong, dispatch), + changeRule: bindActionCreators(Actions.changeRule, dispatch), + addSong: bindActionCreators(Actions.addSong, dispatch), + addSongList: bindActionCreators(Actions.addSongList, dispatch), + nextSong: bindActionCreators(Actions.nextSong, dispatch), + previousSong: bindActionCreators(Actions.previousSong, dispatch), + showPlayList: bindActionCreators(Actions.showPlayList, dispatch), + closePlayList: bindActionCreators(Actions.closePlayList, dispatch), + playFromList: bindActionCreators(Actions.playFromList, dispatch), + login: bindActionCreators(Actions.login, dispatch), + logged_in: bindActionCreators(Actions.logged_in, dispatch), + toguest: bindActionCreators(Actions.toguest, dispatch), + loginform: bindActionCreators(Actions.loginform, dispatch), + fetchusersong: bindActionCreators(Actions.fetchusersong, dispatch), + push: bindActionCreators(Actions.push, dispatch), + pop: bindActionCreators(Actions.pop, dispatch), + fetchsonglistdetail: bindActionCreators(Actions.fetchsonglistdetail, dispatch), + showplaycontentmini: bindActionCreators(Actions.showplaycontentmini, dispatch), + hiddenplaycontentmini: bindActionCreators(Actions.hiddenplaycontentmini, dispatch), + } +}); + +class App extends Component { + loginForm() { + if (this.props.user.showForm) { + return ( + + ); + } else { + return; + } + } + + render() { + const { song } = this.props; + return ( +
+
+ {this.loginForm()} + + +
+ ); + } +} + + +export default connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/app/components/Content.jsx b/app/components/Content.jsx new file mode 100644 index 0000000..9955f74 --- /dev/null +++ b/app/components/Content.jsx @@ -0,0 +1,42 @@ +import React, { Component } from 'react'; +import SearchContent from './SearchContent.jsx'; +import HomeContent from './HomeContent.jsx'; +import SideBar from './SideBar.jsx'; +import Player from './Player.jsx'; + +export default class Content extends Component { + constructor(props: any) { + super(props); + } + + renderSearchContent() { + console.log(this); + if (this.props.search.hidden) { + return; + } else { + return + } + } + + renderContent() { + const { router } = this.props; + let Component = router.routerStack[router.routerStack.length - 1]; + return + } + + render() { + return ( +
+ +
+ {this.renderContent()} +
+ +
+ ); + } +} diff --git a/app/components/Header.jsx b/app/components/Header.jsx new file mode 100644 index 0000000..b891dd4 --- /dev/null +++ b/app/components/Header.jsx @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import SearchBar from './SearchBar.jsx'; +import UserState from './UserState.jsx'; + +export default class Header extends Component { + _closeApp(e) { + Electron.ipcRenderer.send('closeapp'); + } + + _max(e) { + Electron.ipcRenderer.send('maximize'); + } + + _min(e) { + Electron.ipcRenderer.send('minimize'); + } + + render() { + let Logo=require('../assets/logo.svg?name=Logo'); + let CloseIcon = require('../assets/icon/close.svg?name=CloseIcon'); + let MaxIcon = require('../assets/icon/max.svg?name=MaxIcon'); + let MinIcon = require('../assets/icon/min.svg?name=MinIcon'); + return ( +
+
+ +
+
+
+ + +
+ this._min(e) } + /> + this._max(e) } + /> + this._closeApp(e) } + /> +
+
+ ); + } +} diff --git a/app/components/HomeContent.jsx b/app/components/HomeContent.jsx new file mode 100644 index 0000000..dcbf1e4 --- /dev/null +++ b/app/components/HomeContent.jsx @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; + +export default class HomeContent extends Component { + constructor(props: any) { + super(props); + } + + render() { + return ( +
+
+

开发中

+
+
+ ); + } +} diff --git a/app/components/LoginForm.jsx b/app/components/LoginForm.jsx new file mode 100644 index 0000000..7bc5642 --- /dev/null +++ b/app/components/LoginForm.jsx @@ -0,0 +1,87 @@ +import React, { Component } from 'react'; + +export default class LoginForm extends Component { + constructor(props: any) { + super(props); + this.state = { + phoneValid: true, + passwordValid: true, + } + } + + _onSubmit(e) { + e.preventDefault(); + if (this.refs.phone.value === '') { + this.setState({ + phoneValid: false + }); + return; + }; + if (this.refs.password.value === '') { + this.setState({ + passwordValid: false + }); + return; + }; + this.props.login({ + phone: this.refs.phone.value, + password: this.refs.password.value, + }); + this.props.loginform(false); + } + + _closeForm(e) { + this.props.loginform(false); + } + + _onChange(e, target) { + if (target === 'phone') { + if (!this.state.phoneValid) { + this.setState({ + phoneValid: true, + }); + } + } else { + if (!this.state.passwordValid) { + this.setState({ + passwordValid: true, + }); + } + } + } + + render() { + let Close = require('../assets/icon/close.svg?name=Close'); + return ( +
+
+

登陆

+ this._closeForm(e) } + /> +
+
this._onSubmit(e)} + > + this._onChange(e, 'phone')} + /> + this._onChange(e, 'pw')} + type="password" ref="password" placeholder="输入密码" /> + +
+ +
+ ); + } +} diff --git a/app/components/MusicContent.jsx b/app/components/MusicContent.jsx new file mode 100644 index 0000000..4ada90e --- /dev/null +++ b/app/components/MusicContent.jsx @@ -0,0 +1,9 @@ +import React, { Component } from 'react'; + +export default class MusicContent extends Component { + render() { + return ( +
+ ); + } +} diff --git a/app/components/PlayContentCard.jsx b/app/components/PlayContentCard.jsx new file mode 100644 index 0000000..905f02d --- /dev/null +++ b/app/components/PlayContentCard.jsx @@ -0,0 +1,73 @@ +import React, { Component } from 'react'; + +export default class PlayContentCard extends Component { + constructor(props: any) { + super(props); + this.state = { + cardMode: 'mini', + height: '0px', + width: '0px', + }; + } + + componentWillReceiveProps(props) { + if (props.data && this.props.data == undefined) { + this.setState({ + height: '100px', + width: '300px', + }); + } + if (props.data != this.props.data) { + if (this.props.playcontent.state == 'hidden') { + this.props.showplaycontentmini(); + } + } + } + + renderMini() { + return ( +
+
+
+ +
+
+

+ {this.props.data.name} +

+

+ {this.props.data.artists[0].name} +

+
+
+
+ ); + } + + renderDefault() { + return ( +
+
+ ); + } + + render() { + if (this.props.data == undefined) + return this.renderDefault(); + if (this.state.cardMode === 'mini') + return this.renderMini(); + } +} diff --git a/app/components/PlayList.jsx b/app/components/PlayList.jsx new file mode 100644 index 0000000..34dfe4c --- /dev/null +++ b/app/components/PlayList.jsx @@ -0,0 +1,107 @@ +import React, { Component } from 'react'; + +export default class PlayList extends Component { + constructor(props: any) { + super(props); + + this.state = { + show: false, + } + } + + secToTime(sec) { + sec = sec / 1000; + let min = parseInt(sec / 60); + if (min < 10) { + min = '0' + min; + } + let second = parseInt(sec % 60); + if (second < 10) { + second = '0' + second; + } + + return min + ':' + second; + } + + getSongClassName(index) { + if (index == this.props.song.currentSongIndex) { + return "playlist__content__list__song current"; + } else { + return "playlist__content__list__song"; + } + } + + componentWillReceiveProps(props) { + if (props.showplaylist && !this.state.show) { + this.setState({ + show: true, + }); + } + if (!props.showplaylist && this.state.show) { + this.setState({ + show: false, + }); + } + } + + componentDidUpdate(props, state) { + if (this.props.song.currentSongIndex != props.song.currentSongIndex) { + this.autoScroll(); + } + } + + autoScroll() { + let target = this.refs.current; + let container = this.refs.container; + container.scrollTop = 0; + container.scrollTop = target.getBoundingClientRect().top - container.getBoundingClientRect().top - 150; + } + + _closeplaylist(e) { + this.props.closePlayList(); + } + + _playfromlist(e, index) { + this.props.playFromList(index); + } + + render() { + let Close = require('../assets/icon/close.svg?name=Close'); + return ( +
+
+

播放列表

+ this._closeplaylist(e)} + /> +
+
+
    + {this.props.song.songlist.map( + (song, index) => { + return ( +
  • this._playfromlist(e, index)} + > +
    +

    {song.name}

    +
    +
    +

    {song.artists[0].name}

    +
    +
    +

    {this.secToTime(song.duration)}

    +
    +
  • + ); + }) + } +
+
+
+ ); + } +} diff --git a/app/components/PlayListControl.jsx b/app/components/PlayListControl.jsx new file mode 100644 index 0000000..ad92f4a --- /dev/null +++ b/app/components/PlayListControl.jsx @@ -0,0 +1,65 @@ +import React, { Component } from 'react'; + +export default class PlayListControl extends Component { + constructor(props: any) { + super(props); + } + + _changeRule(e) { + this.props.changeRule(); + } + + _showorhidePlaylist(e) { + if (this.props.song.showplaylist) { + this.props.closePlayList(); + } else { + this.props.showPlayList(); + } + } + + getClassName() { + if (this.props.song.showplaylist) { + return "player__playlistcontrol__playlist active"; + } else { + return "player__playlistcontrol__playlist"; + } + } + + getPlayRule() { + //FIXME: svg-loader only accept string args + if (this.props.song.rules[this.props.song.playRule] == 'one') { + return require('../assets/icon/one.svg'); + } + if (this.props.song.rules[this.props.song.playRule] == 'loop') { + return require('../assets/icon/loop.svg'); + } + if (this.props.song.rules[this.props.song.playRule] == 'shuffle') { + return require('../assets/icon/shuffle.svg'); + } + } + + render() { + let PlayRule = this.getPlayRule(); + let PlayListIcon = require('../assets/icon/playlist.svg'); + return ( +
+
+ this._changeRule(e)} + /> +
+
this._showorhidePlaylist(e)} + className={this.getClassName()}> + +
+

{this.props.song.songlist.length}

+
+
+
+ ); + } +} diff --git a/app/components/Player.jsx b/app/components/Player.jsx new file mode 100644 index 0000000..b16c4cc --- /dev/null +++ b/app/components/Player.jsx @@ -0,0 +1,257 @@ +import React, { Component } from 'react'; +import Volume from './Volume.jsx'; +import PlayListControl from './PlayListControl.jsx'; +import PlayerList from './PlayList.jsx'; +import { getSongUrl } from '../server'; + +export default class Player extends Component { + constructor(props: any) { + super(props); + this.mouseState = { + press: false, + }; + + this.autoplay = true; + + this.state = { + playbuttonIcon: 'play', + currentTime: 0, + duration: 1, + buffered: 0, + source: '', + state: 'ready', + } + } + + componentDidMount() { + let self = this; + this.refs.audio.addEventListener("progress", (e) => { + this.setState({ + buffered: e.target.buffered.end(e.target.buffered.length - 1) + }); + }, true); + + this.refs.audio.addEventListener("durationchange", e => { + this.setState({ + duration: e.target.duration + }); + }, true) + + this.refs.audio.addEventListener("timeupdate", e => { + if (self.mouseState.press) { + return; + } + this.setState({ + currentTime: e.target.currentTime + }); + }, true) + + this.refs.audio.addEventListener("canplay", e => { + if (this.autoplay) { + self.props.actions.play(); + this.autoplay = false; + } + }, true) + + this.refs.audio.addEventListener("ended", e => { + self.props.actions.nextSong(); + }, true) + } + + componentWillReceiveProps(props) { + let self = this; + if (props.player.isplay) { + this.refs.audio.play(); + this.setState({ + playbuttonIcon: 'pause', + }); + } else { + this.refs.audio.pause(); + this.setState({ + playbuttonIcon: 'play', + }); + } + + if ( + props.song.songlist[props.song.currentSongIndex] !== + this.props.song.songlist[this.props.song.currentSongIndex] + ) { + this.setState({ + state: 'fetch', + }); + getSongUrl(props.song.songlist[props.song.currentSongIndex], data => { + if (!data.url) { + self.setState({ + state: 'ready', + }); + self.props.actions.nextSong(); + return; + } + + self.setState({ + source: data.url, + currentTime: 0, + state: 'ready', + }, () => { + self.props.actions.pause(); + }); + }); + } + } + + componentDidUpdate(props, state) { + // update audio + if (state.source !== this.state.source) { + this.autoplay = true; + } + } + + secToTime(sec) { + let min = parseInt(sec / 60); + if (min < 10) { + min = '0' + min; + } + let second = parseInt(sec % 60); + if (second < 10) { + second = '0' + second; + } + + return min + ':' + second; + } + + updateVolume(volume, ismute) { + console.log('mute', ismute); + if (ismute) { + this.refs.audio.volume = 0; + } else { + this.refs.audio.volume = volume; + } + } + + _playorpause() { + const { song } = this.props; + if (this.props.player.isplay) { + this.props.actions.pause(); + } else { + this.props.actions.play(); + if (song.songlist.length > 0 && song.currentSongIndex == undefined) { + this.props.actions.playFromList(0); + } + } + } + + _previous(e) { + this.props.actions.previousSong(); + } + + _next(e) { + this.props.actions.nextSong(); + } + + _handleMouseUp(e) { + if (!this.mouseState.press) { + return; + } + this.mouseState.press = false; + let pgbarWidth = this.refs.pgbar.clientWidth; + this.setState({ + currentTime: this.refs.audio.duration * (e.pageX - this.refs.pgbar.getBoundingClientRect().left) / pgbarWidth + }); + this.refs.audio.currentTime = this.state.currentTime; + } + + _handleMouseMove(e) { + if (!this.mouseState.press) { + return; + } + let pgbarWidth = this.refs.pgbar.clientWidth; + this.setState({ + currentTime: this.refs.audio.duration * (e.pageX - this.refs.pgbar.getBoundingClientRect().left) / pgbarWidth + }); + } + + _seek(e) { + if (!this.state.source) { + return; + } + this.mouseState.press = true; + window.addEventListener("mouseup", this._handleMouseUp.bind(this)); + window.addEventListener("mousemove", this._handleMouseMove.bind(this)); + } + + render() { + let Previous = require('../assets/icon/previous.svg'); + let Next = require('../assets/icon/next.svg'); + let Play = require('../assets/icon/' + this.state.playbuttonIcon + '.svg'); + return ( +
+ +
+ + + +
+
+

+ {this.secToTime(this.state.currentTime)} +

+
{ this._seek(e) }} + > +
+
{ this._seek(e) }} + style={{ + width: String(this.state.currentTime / this.state.duration * 100) + '%' + }} + > +
+
+
+
+

+ {this.secToTime(this.state.duration)} +

+
+ + + +
+ ); + } +} diff --git a/app/components/SearchBar.jsx b/app/components/SearchBar.jsx new file mode 100644 index 0000000..690f1ea --- /dev/null +++ b/app/components/SearchBar.jsx @@ -0,0 +1,36 @@ +import React, { Component } from 'react'; +import { search } from '../server'; +import SeachContent from './SearchContent.jsx'; + +export default class SearchBar extends Component { + constructor(props: any) { + super(props); + } + + _onSubmit(e) { + e.preventDefault(); + this.props.push(SeachContent); + this.props.search(this.refs.search.value); + } + + render() { + let SearchIcon = require("../assets/icon/search.svg?name=SearchIcon"); + return ( +
+
this._onSubmit(e)} + > + + + +
+
+ ); + } +} diff --git a/app/components/SearchContent.jsx b/app/components/SearchContent.jsx new file mode 100644 index 0000000..b38c6b2 --- /dev/null +++ b/app/components/SearchContent.jsx @@ -0,0 +1,83 @@ +import React, { Component } from 'react'; +import Spinner from './Spinner.jsx'; +import SongCard from './SongCard.jsx'; +import SongList from './SongList.jsx'; + +export default class SearchContent extends Component { + constructor(props: any) { + super(props); + } + + renderResult() { + if (this.props.search.searchResponse.songCount === 0) { + return (

无结果

); + } else { + return ( +
+
+

最佳匹配

+ +
+
+ +
+
+ ); + } + } + + render() { + if (this.props.search.searchState == 'START') { + return this.renderSearching(); + } + if (this.props.search.searchState == 'FINISH') { + return this.renderFinish(); + } + if (this.props.search.searchState == 'ERROR') { + return this.renderError(); + } + } + + renderSearching() { + return ( +
+
+

+ {this.props.search.searchInfo.keywords} + 搜索中... +

+
+
+ +
+
+ ); + } + + renderFinish() { + return ( +
+
+

+ {this.props.search.searchInfo.keywords} + 搜索到{this.props.search.searchResponse.songCount}首歌曲 +

+
+ { this.renderResult() } +
+ ); + } + + renderError() { + return ( +
Error
+ ); + } +} diff --git a/app/components/SideBar.jsx b/app/components/SideBar.jsx new file mode 100644 index 0000000..b1d6a1d --- /dev/null +++ b/app/components/SideBar.jsx @@ -0,0 +1,155 @@ +import React, { Component } from 'react'; +import Spinner from './Spinner.jsx'; +import SongListContent from './SongListContent.jsx'; + +export default class SideBar extends Component { + constructor(props: any) { + super(props); + this.state = { + showCreate: true, + showCollect: true, + createHeight: null, + collectHeight: null, + }; + this.scroll = { + lastScrollTop: 0, + }; + } + + componentDidUpdate(props, state) { + // 获取歌单块高度进行transition + if (this.props.usersong.state == 'get' && props.usersong.state == 'fetching'){ + this.setState({ + createHeight: this.refs.create.getBoundingClientRect().height, + collectHeight: this.refs.collect.getBoundingClientRect().height, + }); + } + } + + _songlistdetail(id) { + this.props.actions.push(SongListContent); + this.props.actions.fetchsonglistdetail(id); + } + + getCreate() { + const { usersong } = this.props; + switch (usersong.state){ + case 'nouser': + return

无用户

+ case 'fetching': + return + case 'error': + return

获取歌单出错{usersong.errorinfo}

+ case 'get': + let PlayListIcon = require('../assets/icon/playlist.svg?name=PlayListIcon') + let self = this; + return ( +
    + {usersong.create.map(songlist => { + return ( +
  • self._songlistdetail(songlist.id)} + className="sidebar__mylist__content__list"> + +

    {songlist.name}

    +
  • + ) + })} +
+ ); + } + } + + getCollect() { + const { usersong } = this.props; + let self = this; + switch (usersong.state){ + case 'nouser': + return

无用户

+ case 'fetching': + return + case 'error': + return

获取歌单出错{usersong.errorinfo}

+ case 'get': + let PlayListIcon = require('../assets/icon/playlist.svg?name=PlayListIcon') + return ( +
    + {usersong.collect.map(songlist => { + return ( +
  • self._songlistdetail(songlist.id)} + className="sidebar__mylist__content__list"> + +

    {songlist.name}

    +
  • + ) + })} +
+ ); + } + } + + _showorhide(target) { + if (target === 'showCreate') { + this.setState({ + showCreate: !this.state.showCreate, + }); + } + if (target === 'showCollect') { + this.setState({ + showCollect: !this.state.showCollect, + }); + } + } + + _onscroll(e) { + if (this.props.playcontent.state == 'show' && e.target.scrollTop > this.scroll.lastScrollTop) { + this.props.actions.hiddenplaycontentmini(); + } + if (this.props.playcontent.state == 'hidden' && e.target.scrollTop < this.scroll.lastScrollTop) { + this.props.actions.showplaycontentmini(); + } + this.scroll.lastScrollTop = e.target.scrollTop; + } + + render() { + return ( +
this._onscroll(e)} + className="sidebar"> +
+

我创建的歌单

+ this._showorhide('showCreate')} + src={require('url!../assets/img/up.svg')} + /> + {this.getCreate()} +
+
+

我收藏的歌单

+ this._showorhide('showCollect')} + /> + {this.getCollect()} +
+
+ ); + } +} diff --git a/app/components/SongCard.jsx b/app/components/SongCard.jsx new file mode 100644 index 0000000..8f7b18b --- /dev/null +++ b/app/components/SongCard.jsx @@ -0,0 +1,31 @@ +import React, { Component } from 'react'; + +export default class SongCard extends Component { + constructor(props: any) { + super(props); + } + + _playsong(e, song) { + this.props.changeSong(song); + } + + render() { + return ( +
this._playsong(e, this.props.data)} + > + +
+

+ {this.props.data.name} +

+

+ 专辑:{this.props.data.album.name} + 歌手:{this.props.data.artists[0].name} +

+
+
+ ); + } +} diff --git a/app/components/SongList.jsx b/app/components/SongList.jsx new file mode 100644 index 0000000..707a1aa --- /dev/null +++ b/app/components/SongList.jsx @@ -0,0 +1,96 @@ +import React, { Component } from 'react'; + +export default class SongList extends Component { + constructor(props: any) { + super(props); + } + + secToTime(sec) { + sec = sec / 1000; + let min = parseInt(sec / 60); + if (min < 10) { + min = '0' + min; + } + let second = parseInt(sec % 60); + if (second < 10) { + second = '0' + second; + } + + return min + ':' + second; + } + + getShortName(name, limit) { + if (name.length > limit) { + return name.slice(0, limit) + '...'; + } else { + return name; + } + } + + _playsong(e, song) { + this.props.changeSong(song); + } + + _addsong(e, song) { + this.props.addSong(song); + } + + render() { + let Add = require('../assets/icon/add.svg?name=Add'); + return ( +
+ + + + + + + + + + + + + + {this.props.data.map( (song, index) => { + return ( + + + + + + + + + + ); + })} + +
编号音乐标题歌手专辑时长热度
{index + 1} + this._addsong(e, song)} + /> + this._playsong(e, song)} + > + {this.getShortName(song.name, 30)} + + {this.getShortName(song.artists[0].name, 15)} + + {this.getShortName(song.album.name, 18)} + + {this.secToTime(song.duration)} + +
+
+
+
+
+ ); + } +} diff --git a/app/components/SongListContent.jsx b/app/components/SongListContent.jsx new file mode 100644 index 0000000..c3a1125 --- /dev/null +++ b/app/components/SongListContent.jsx @@ -0,0 +1,114 @@ +'use strict'; +// 歌单内容 + +import React, { Component } from 'react'; +import Spinner from './Spinner.jsx'; +import AlbumCard from './AlbumCard.jsx'; +import SongList from './SongList.jsx'; + +export default class SongListContent extends Component { + constructor(props: any) { + super(props); + } + + renderResult() { + if (this.props.search.searchResponse.songCount === 0) { + return (

无结果

); + } else { + return ( +
+
+

最佳匹配

+ +
+
+ +
+
+ ); + } + } + + render() { + if (this.props.songlist.state == 'fetching') { + return this.renderFetching(); + } + if (this.props.songlist.state == 'get') { + return this.renderFinish(); + } + if (this.props.songlist.state == 'error') { + return this.renderFetching(); + } + } + + renderFetching() { + return ( +
+
+

歌单详情

+
+
+ +
+
+ ); + } + + renderFinish() { + let songs = this.props.songlist.content.tracks; + songs.map(song => { + song.artists = song.ar; + song.album = song.al; + song.duration = song.dt; + song.score = song.pop; + if (song.h) { + song.hMusic = song.h; + song.hMusic.bitrate = song.h.br; + } + if (song.m) { + song.mMusic = song.m; + song.mMusic.bitrate = song.m.br; + } + if (song.l) { + song.lMusic = song.l; + song.lMusic.bitrate = song.l.br; + } + }); + return ( +
+
+

歌单详情

+
+
+
+ +
+
+ +
+
+
+ ); + } + + renderError() { + return ( +
Error
+ ); + } +} diff --git a/app/components/Spinner.jsx b/app/components/Spinner.jsx new file mode 100644 index 0000000..f27cf59 --- /dev/null +++ b/app/components/Spinner.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; + +export default class Spinner extends Component { + render() { + return ( +
+
+ ); + } +} diff --git a/app/components/UserState.jsx b/app/components/UserState.jsx new file mode 100644 index 0000000..74be8ff --- /dev/null +++ b/app/components/UserState.jsx @@ -0,0 +1,126 @@ +import React, { Component } from 'react'; +import { search } from '../server'; +import Spinner from './Spinner.jsx'; + +export default class UserState extends Component { + constructor(props: any) { + super(props); + this.state = { + showMenu: false, + } + } + + _login(e) { + this.props.loginform(true); + } + + _showMenu(e) { + this.setState({ + showMenu: !this.state.showMenu, + }); + } + + _logout(e) { + this.props.toguest(); + } + + componentDidMount() { + let self = this; + // 根据cookie判断是否自动登陆 + Electron.ipcRenderer.on('cookie', (e, cookies) => { + console.log(cookies); + let flag = 0; + cookies.map(cookie => { + if (cookie.name === 'MUSIC_U') { + flag++; + } + if ((cookie.name === '__remember_me') && (cookie.value === 'true')) { + flag++; + } + }); + if (flag > 1) { + if (localStorage.user) { + self.props.logged_in(JSON.parse(localStorage.getItem('user'))); + self.props.fetchusersong(self.props.user.profile.userId); + } + } + }); + } + + render() { + if (this.props.user.loginState == 'logged_in') { + return this.renderUser(); + } else if (this.props.user.loginState == 'logging_in') { + return this.renderLogging(); + } else { + return this.renderGuest(); + } + } + + renderGuest() { + if (this.props.user.loginState == 'logged_failed') { + alert(this.props.user.loginError, "登录失败"); + this.props.toguest(); + } + return ( +
+
+
+
this._login(e) }> + 登录 +
+
+ ); + } + + renderLogging() { + return ( +
+
+ +
+
this._login(e) }> + 登录中.. +
+
+ ); + } + + renderUser() { + return ( +
+
this._showMenu(e)} + > + + { this.state.showMenu ? (
+
    +
  • this._logout(e) } + >退出登录
  • +
+
) : ''} +
+
+ {this.props.user.profile.nickname} +
+
+ ); + } +} diff --git a/app/components/Volume.jsx b/app/components/Volume.jsx new file mode 100644 index 0000000..c56bb2a --- /dev/null +++ b/app/components/Volume.jsx @@ -0,0 +1,94 @@ +import React, { Component } from 'react'; + +export default class Volume extends Component { + constructor(props: any) { + super(props); + this.mouseState = { + press: false, + }; + this.state = { + volume: 1, + mute: false, + } + } + + _handleMouseUp(e) { + if (!this.mouseState.press) { + return; + } + this.mouseState.press = false; + let volumeWidth = this.refs.volume.clientWidth; + let volume = (e.pageX - this.refs.volume.getBoundingClientRect().left) / volumeWidth; + volume = volume > 1 ? 1 : volume; + volume = volume < 0 ? 0 : volume; + + this.setState({ + "volume": volume + }); + this.props.updateVolume(this.state.volume, this.state.mute); + } + + _handleMouseMove(e) { + if (!this.mouseState.press) { + return; + } + let volumeWidth = this.refs.volume.clientWidth; + let volume = (e.pageX - this.refs.volume.getBoundingClientRect().left) / volumeWidth; + volume = volume > 1 ? 1 : volume; + volume = volume < 0 ? 0 : volume; + + this.setState({ + "volume": volume + }); + this.props.updateVolume(this.state.volume, this.state.mute); + } + + _mouseDown(e) { + this.mouseState.press = true; + window.addEventListener("mouseup", this._handleMouseUp.bind(this)); + window.addEventListener("mousemove", this._handleMouseMove.bind(this)); + } + + _mute(e) { + this.setState({ + mute: !this.state.mute + }, () => { + this.props.updateVolume(this.state.volume, this.state.mute); + }); + } + + getVolumeIcon() { + if (this.state.mute) { + return require('../assets/icon/volume_mute.svg'); + } + + if (this.state.volume > 0.7 ) { + return require('../assets/icon/volume_max.svg'); + } else if (this.state.volume > 0.3) { + return require('../assets/icon/volume_min.svg'); + } else { + return require('../assets/icon/volume.svg'); + } + } + + render() { + var VolumeIcon = this.getVolumeIcon(); + return ( +
+ this._mute(e) } + className="i" /> +
this._mouseDown(e) } + > +
+
+
+ ); + } +} diff --git a/app/containers/CloudMusic.jsx b/app/containers/CloudMusic.jsx new file mode 100644 index 0000000..69d518e --- /dev/null +++ b/app/containers/CloudMusic.jsx @@ -0,0 +1,31 @@ +import React, { Component } from 'react'; +import { render } from 'react-dom'; +import App from '../components/App.jsx'; + +import thunkMiddleware from 'redux-thunk'; +import createLogger from 'redux-logger'; +import { Provider } from 'react-redux'; +import { createStore, applyMiddleware } from 'redux'; + +import cloudMusic from '../reducers'; + +const loggerMiddleware = createLogger(); +const createStoreWithMiddleware = applyMiddleware( + thunkMiddleware, + loggerMiddleware +)(createStore); + +var store = createStoreWithMiddleware(cloudMusic); +class CloudMusic extends Component { + render() { + return ( + + + + ); + } +} +React.render( + , + document.body +); diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..4fe089e --- /dev/null +++ b/app/main.js @@ -0,0 +1,2 @@ +require('./postcss/index.css'); +require('./containers/CloudMusic.jsx'); diff --git a/app/postcss/_albumcard.css b/app/postcss/_albumcard.css new file mode 100644 index 0000000..824757d --- /dev/null +++ b/app/postcss/_albumcard.css @@ -0,0 +1,77 @@ +.albumcard { + height: 200px; + min-width: 500px; + max-width: 800px; + display: flex; + align-items: stretch; +} + +.albumcard__cover { + position: relative; +} + +.albumcard__cover__playcount { + position: absolute; + bottom: 0; + width: 100%; + height: 30px; + line-height: 30px; + padding-left: 10px; + background-color: rgba(0, 0, 0, 0.5); + color: #fff; +} + +.albumcard-left { + flex: 1; + display: flex; + justify-content: space-between; + padding-right: 16px; + padding-bottom: 16px; + flex-direction: column; +} + +.albumcard__info { + padding: 20px 20px; +} + +.albumcard__info__name { + font-size: 30px; + color: $primarytext; +} + +.albumcard__info__creator { + font-size: 20px; + margin-top: 20px; + color: $secondarytext; +} + +.albumcard__buttons { + display: flex; + justify-content: flex-end; +} + +.albumcard__buttons__button { + margin-right: 5px; + border-radius: 2px; + &:first-child { + color: $red-500; + } + &:last-child { + color: $indigo-500; + } +} + +.albumcard__tags { + display: flex; + font-size: 18px; + padding-left: 20px; + p { + color: $primarytext; + display: block; + } +} + +.albumcard__tags__tag{ + color: $secondarytext; + margin-right: 10px; +} diff --git a/app/postcss/_button.css b/app/postcss/_button.css new file mode 100644 index 0000000..6a2e372 --- /dev/null +++ b/app/postcss/_button.css @@ -0,0 +1,28 @@ +@define-mixin btn { + border: 0; + background-color: $indigo-200.0; + height: 36px; + font-size: 20px; + line-height: 36px; + text-align: center; + cursor: pointer; + padding: 0 16px; + transition: background-color ease 0.5s; + font-weight: bold; + &:hover { + background-color: $indigo-200; + } +} + +.btn-normal { + @mixin btn; +} + +.btn-bg { + @mixin btn; + color: #fff; + background-color: $indigo-700; + &:hover { + background-color: $indigo-500; + } +} diff --git a/app/postcss/_card.css b/app/postcss/_card.css new file mode 100644 index 0000000..0254472 --- /dev/null +++ b/app/postcss/_card.css @@ -0,0 +1,15 @@ +.card { + background-color: #fff; + cursor: pointer; + box-shadow: 0 2px 2px rgba(0, 0, 0, .18); + transition: all ease 0.5s; + transform: translateY(0); + border-radius: 2px; + * { + cursor: pointer; + } + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 8px rgba(0, 0, 0, .18); + } +} diff --git a/app/postcss/_colors.css b/app/postcss/_colors.css new file mode 100644 index 0000000..dd52e82 --- /dev/null +++ b/app/postcss/_colors.css @@ -0,0 +1,30 @@ +$red-200: #ef9a9a; +$red-300: #e57373; +$red-500: #f44336; +$red-700: #d32f2f; +$red-300: #e57373; +$red-900: #b0120a; +$red-100: #f9bdbb; + +$indigo-50: #e8eaf6; +$indigo-200: #9fa8da; +$indigo-500: #3f51b5; +$indigo-700: #303f9f; +$indigo-900: #1a237e; + +$primarytext: rgba(0, 0, 0, .87); +$secondarytext: rgba(0, 0, 0, .54); +$disabletext: rgba(0, 0, 0, .38); + +$content: #fafafa; +$content2: #f7f7f7; +$content3: #eeeeee; +$dividers: #e0e0e0; + +$grey-200: #eeeeee; +$grey-300: #e0e0e0; +$grey-400: #bdbdbd; +$grey-500: #9e9e9e; +$grey-600: #757575; + +$icon: rgba(0, 0, 0, .38); diff --git a/app/postcss/_content.css b/app/postcss/_content.css new file mode 100644 index 0000000..1fe1a93 --- /dev/null +++ b/app/postcss/_content.css @@ -0,0 +1,51 @@ +@import "home-content"; +@import "search-content"; +@import "songlist-content"; +@import "sidebar"; + +#content { + flex: 1; + display: flex; + position: relative; + align-items: strench; +} + +.main-content { + flex: 1; + position: relative; + display: flex; +} + +.content { + flex: 1; + color: $primarytext; + background-color: $content3; + z-index: 10; + display: flex; + flex-direction: column; + overflow-y: auto; + @mixin scrollbar; +} + +.content__headinfo { + width: 100%; + padding: 35px 30px 5px 30px; + border-bottom: 2px solid #c70c0c; + background-color: $content; + p { + font-size: 25px; + } +} + +/* FIXME */ +.content__main { + height: 0; +} + +.content__main__list { + margin-top: 30px; +} + +.content__card { + padding: 20px 20px; +} diff --git a/app/postcss/_header.css b/app/postcss/_header.css new file mode 100644 index 0000000..98fe1a7 --- /dev/null +++ b/app/postcss/_header.css @@ -0,0 +1,37 @@ +@import "searchbar"; +@import "user-state"; + + +.header { + display: flex; + align-items: center; + height: 70px; + border-top: 0.8px solid black; + border-bottom: 3px solid #c70c0c; + padding: 0 5px 0 30px; + justify-content: space-between; + background-color: $red-500; + box-shadow: 0 4px 4px rgba(0, 0, 0, .18); + z-index: 11; +} + +.header__logo { + img { + height: 55px; + } +} + +.header__space { + flex: 1; +} + +.header__windowcontrol { + margin-left: 20px; + svg { + fill: #fff; + cursor: pointer; + * { + cursor: pointer; + } + } +} diff --git a/app/postcss/_home-content.css b/app/postcss/_home-content.css new file mode 100644 index 0000000..217c912 --- /dev/null +++ b/app/postcss/_home-content.css @@ -0,0 +1,12 @@ +.home-content { + flex: 1; + background-color: $content; + display: flex; + align-items: center; + justify-content: center; +} + +.home-content__main { + font-size: 200px; + color: rgba(255, 255, 255, 0.1); +} diff --git a/app/postcss/_loginform.css b/app/postcss/_loginform.css new file mode 100644 index 0000000..897c6a3 --- /dev/null +++ b/app/postcss/_loginform.css @@ -0,0 +1,69 @@ +.loginform { + position: absolute; + height: 270px; + width: 500px; + z-index: 100; + top: calc(50% - 100px); + left: calc(50% - 250px); + background-color: $content; + overflow: hidden; + box-shadow: 0 24px 24px rgba(0, 0, 0, .18); + display: flex; + flex-direction: column; + align-items: center; +} + +.loginform__header { + width: 100%; + height: 56px; + font-size: 20px; + position: relative; + background-color: $indigo-500; + color: white; + display: flex; + align-items: center; + justify-content: space-between; + h2 { + height: 100%; + line-height: 56px; + padding-left: 20px; + } +} + +.loginform__header__close { + cursor: pointer; + margin-right: 10px; + fill: #fff; +} + +.loginform__form { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + input { + font-size: 18px; + width: 450px; + height: 50px; + border: 0; + border-bottom: 2px solid $grey-300; + background-color: transparent; + margin-top: 20px; + outline: none; + transition: all ease 0.5s; + &:focus { + border-bottom: 3px solid $indigo-500; + + } + } +} + +.loginform__submit { + width: 100px; + height: 40px; + font-size: 20px; + position: absolute; + bottom: 16px; + right: 25px; +} diff --git a/app/postcss/_playcontentcard.css b/app/postcss/_playcontentcard.css new file mode 100644 index 0000000..f599930 --- /dev/null +++ b/app/postcss/_playcontentcard.css @@ -0,0 +1,40 @@ +.miniplaycontent { + bottom: 70px; + left: 10px; + overflow: hidden; + position: absolute; + z-index: 10; + background-color: $indigo-50; +} + +.miniplaycontent-wrapper { + display: flex; + padding: 10px 10px; + width: 300px; + height: 100px; +} + +.miniplaycontent__cover { + height: 80px; + width: 80px; + overflow: hidden; + img { + width: 100%; + } +} + +.miniplaycontent__info { + margin-left: 20px; + display: flex; + flex-direction: column; + font-size: 20px; + justify-content: space-around; +} + +.miniplaycontent__info__name { + color: $primarytext; +} + +.miniplaycontent__info__artist { + color: $secondarytext; +} diff --git a/app/postcss/_player.css b/app/postcss/_player.css new file mode 100644 index 0000000..92bb2e3 --- /dev/null +++ b/app/postcss/_player.css @@ -0,0 +1,132 @@ +$PlayerHeight: 53px; +@import "volume"; +@import "playlist"; +@import "playlistcontrol"; + + +@define-mixin player-btn $size { + height: $size; + width: $size; + .i { + fill: $red-500; + } +} + +@keyframes loading { + from { + background-color: $indigo-500; + } + + to { + background-color: $red-500; + } +} + +.player { + height: $PlayerHeight; + background-color: #fff.9; + padding: 0 30px; + display: flex; + align-items: center; + position: fixed; + z-index: 20; + box-shadow: 0 -4px 4px rgba(0, 0, 0, .18); + bottom: 0; + left: 0; + width: 100%; +} + +.player__btns { + display: flex; + align-items: center; +} + +.player__btns__backward, .player__btns__forward { + @mixin player-btn 30px; +} + +.player__btns__play { + @mixin player-btn 40px; +} + +.player__btns-btn { + padding: 0; + border: 2px solid $red-500; + border-radius: 50%; + margin: 0 15px; + cursor: pointer; + background-color: transparent; + * { + cursor: pointer; + } + &:focus { + outline: none; + } +} + +.player__pg { + display: flex; + height: 100%; + flex: 1; + margin: 0 20px; + align-items: center; + p { + color: $primarytext; + } +} + +.player__pg__bar { + flex: 1; + height: 7px; + margin: 0 15px; + border-radius: 5px; + background-color: $red-200; + position: relative; +} + +.player__pg__bar-ready { + position: absolute; + top: 0; + height: 100%; + background-color: $red-300; + border-radius: 5px; +} + +.player__pg__bar-cur-wrapper { + position: absolute; + top: 0; + height: 100%; + width: 100%; + background-color: transparent; + z-index: 10; +} + +.player__pg__bar-cur { + height: 100%; + background-color: #c70c0c; + border-top: 1px solid #f41616; + border-radius: 5px; + position: relative; + &::after { + display: block; + content: ''; + width: 20px; + height: 20px; + background-color: $indigo-500; + position: absolute; + right: -10px; + top: -7px; + border-radius: 50%; + box-sizing: border-box; + cursor: pointer; + } +} + +.player__pg__bar-cur.loading { + &::after { + animation-duration: 0.6s; + animation-direction: alternate; + animation-iteration-count: infinite; + animation-name: loading; + } +} diff --git a/app/postcss/_playlist.css b/app/postcss/_playlist.css new file mode 100644 index 0000000..da6da47 --- /dev/null +++ b/app/postcss/_playlist.css @@ -0,0 +1,68 @@ +.playlist { + position: absolute; + width: 600px; + height: 400px; + bottom: $PlayerHeight; + background-color: $content; + display: flex; + flex-direction: column; + /* chrome absolute zindex bug */ + box-shadow: 0 -4px 4px rgba(0, 0, 0, .18) inset; + overflow: hidden; + border-radius: 2px; + z-index: 13; + transition: all ease 0.3s; +} + +.playlist__header { + height: 60px; + width: 100%; + background-color: $indigo-500; + color: white; + font-szie: 30px; + padding: 0 20px; + box-shadow: 0 4px 4px rgba(0, 0, 0, .18); + display: flex; + align-items: center; + justify-content: space-between; + .i { + cursor: pointer; + fill: #fff; + } +} + +.playlist__content { + flex: 1; + color: $primarytext; + @mixin scrollbar; + overflow-y: auto; +} + +.playlist__content__list__song { + display: flex; + padding: 5px 20px; + height: 40px; + align-items: center; + border-bottom: 1px solid $dividers; + &:hover { + background-color: $grey-300; + } + cursor: pointer; + * { + cursor: pointer; + } +} + +.playlist__content__list__song.current { + background-color: $grey-300; +} + +.playlist__content__list__song-name { + flex: 9; +} +.playlist__content__list__song-artist { + flex: 3; +} +.playlist__content__list__song-duration { + flex: 1; +} diff --git a/app/postcss/_playlistcontrol.css b/app/postcss/_playlistcontrol.css new file mode 100644 index 0000000..6b6482f --- /dev/null +++ b/app/postcss/_playlistcontrol.css @@ -0,0 +1,42 @@ +.player__playlistcontrol { + margin: 0 20px; + height: 24px; + display: flex; + align-items: center; + .i { + height: 100%; + cursor: pointer; + fill: $icon; + } +} + +.player__playlistcontrol__playlistrule { + height: 100%; +} + +.player__playlistcontrol__playlist { + padding: 0 10px; + height: 30px; + margin-left: 10px; + display: flex; + align-items: center; + color: #535353; + background-color: rgba(255, 255, 255, 0); + border-radius: 15px; + transition: background-color ease 0.5s; + cursor: pointer; + * { + cursor: pointer; + } + &:hover { + background-color: rgba(0, 0, 0, 0.3); + } +} + +.player__playlistcontrol__playlist.active { + background-color: rgba(0, 0, 0, 0.3); +} + +.player__playlistcontrol__playlist__count { + margin-left: 5px; +} diff --git a/app/postcss/_reset.css b/app/postcss/_reset.css new file mode 100644 index 0000000..e515647 --- /dev/null +++ b/app/postcss/_reset.css @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/app/postcss/_scrollbar.css b/app/postcss/_scrollbar.css new file mode 100644 index 0000000..848939e --- /dev/null +++ b/app/postcss/_scrollbar.css @@ -0,0 +1,18 @@ +@define-mixin scrollbar { + &::-webkit-scrollbar { + width: 10px; + } + + &::-webkit-scrollbar-track { + border-radius: 10px; + background-color: white; + } + + &::-webkit-scrollbar-thumb { + border-radius: 10px; + background-color: rgba(0, 0, 0, 0.2); + &:hover { + background-color: rgba(0, 0, 0, 0.3); + } + } +} diff --git a/app/postcss/_search-content.css b/app/postcss/_search-content.css new file mode 100644 index 0000000..f46967c --- /dev/null +++ b/app/postcss/_search-content.css @@ -0,0 +1,16 @@ +#search-content .content__headinfo { + .keywords { + color: $secondarytext; + margin-right: 10px; + } +} + +#search-content .content__main__bestmarch { + h2 { + padding:16px 30px; + font-size: 21px; + } + .songcard { + margin-left: 30px; + } +} diff --git a/app/postcss/_searchbar.css b/app/postcss/_searchbar.css new file mode 100644 index 0000000..028b8c3 --- /dev/null +++ b/app/postcss/_searchbar.css @@ -0,0 +1,33 @@ +.header__searchbar { + height: 30px; + width: 250px; + border-radius: 15px; + background-color: white; + display: flex; + align-items: center; + padding: 0 12px; + label { + width: 20px; + height: 20px; + transform: translateY(2px); + display: inline-block; + } + .i { + color: #535353; + width: 100%; + height: 100%; + } + input { + width: 200px; + height: 100%; + background-color: transparent; + border: 0; + margin-left: 5px; + outline: 0; + } +} + +#search-form { + display: flex; + align-items: center; +} diff --git a/app/postcss/_sidebar.css b/app/postcss/_sidebar.css new file mode 100644 index 0000000..57e811d --- /dev/null +++ b/app/postcss/_sidebar.css @@ -0,0 +1,62 @@ +.sidebar { + min-height: 100%; + width: 300px; + background-color: #fff; + border-right: 1px solid $dividers; + overflow-y: auto; + overflow-x: hidden; + @mixin scrollbar; + padding-bottom: $PlayerHeight; +} + +.sidebar__mylist { + position: relative; + color: $secondarytext; + h3 { + padding-left: 5px; + margin-top: 15px; + color: $primarytext; + height: 30px; + line-height: 30px; + border-bottom: 1px solid $dividers; + } +} + +.sidebar__mylist__control { + position: absolute; + right: 10px; + top: 2px; + transition: transform ease 0.5s; + cursor: pointer; + @mixin scrollbar; +} + +.sidebar__mylist__content { + overflow: hidden; + transition: height ease 0.5s; +} + +.sidebar__mylist__content__list { + padding-left: 15px; + height: 30px; + line-height: 30px; + display: flex; + flex-wrap: nowrap; + align-items: center; + cursor: pointer; + * { + cursor: pointer; + } + .i { + fill: $secondarytext; + margin-right: 5px; + } + p { + white-space: nowrap; + width: 1px; + } + &:hover { + color: $primarytext; + background-color: $grey-300; + } +} diff --git a/app/postcss/_songcard.css b/app/postcss/_songcard.css new file mode 100644 index 0000000..7ab7ee9 --- /dev/null +++ b/app/postcss/_songcard.css @@ -0,0 +1,18 @@ +.songcard { + height: 100px; + min-width: 400px; + max-width: 600px; + display: flex; + padding: 10px 10px; + align-items: center; + img { + height: 100%; + } +} + +.songcard__info { + margin-left: 20px; + line-height: 30px; + font-size: 20px; + cursor: pointer; +} diff --git a/app/postcss/_songlist-content.css b/app/postcss/_songlist-content.css new file mode 100644 index 0000000..e62f9c9 --- /dev/null +++ b/app/postcss/_songlist-content.css @@ -0,0 +1,3 @@ +#songlist-content .content__main__card { + padding: 20px 20px; +} diff --git a/app/postcss/_songlist.css b/app/postcss/_songlist.css new file mode 100644 index 0000000..5755582 --- /dev/null +++ b/app/postcss/_songlist.css @@ -0,0 +1,99 @@ +.songlist { + width: 100%; + font-size: 16px; + background-color: $content2; + color: $primarytext; + tr { + height: 30px; + line-height: 30px; + border-top: 1px solid $dividers; + } + + th,td { + padding-left: 5px; + } + + th.th-center { + text-align: center; + } + + thead tr { + padding: 5px 0; + color: rgba(0, 0, 0, .54); + } + + tbody { + tr { + transition: all ease 0.5s; + } + tr:hover { + background-color: $grey-300; + } + } +} +.songlist-table__index { + width: 5%; + padding-right: 10px; + text-align: right; +} + +.songlist-table__name { + width: 30%; + cursor: pointer; + &:hover { + color: black; + } +} + +.songlist-table__button { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + .i { + fill: $icon; + height: 26px; + width: 26px; + /* FIXME: parent can't height 100% */ + transform: translateY(6px); + } + * { + cursor: pointer; + } +} + +.songlist-table__artists { + width: 20%; +} + +.songlist-table__album { + width: 20%; +} + +.songlist-table__duration { + width: 5%; +} + +.songlist-table__hot { + width: 15%; +} + +.songlist-table__hotbar-wrapper { + height: 10px; + width: 80%; + margin: auto; + border-radius: 5px; + background-color: $red-300; + overflow: hidden; +} + +.songlist-table__hotbar { + background-color: $red-700; + border-radius: 5px; + height: 100%; +} + +.songlist-table { + text-align: left; + width: 100%; +} diff --git a/app/postcss/_spinner.css b/app/postcss/_spinner.css new file mode 100644 index 0000000..02c9732 --- /dev/null +++ b/app/postcss/_spinner.css @@ -0,0 +1,63 @@ +/* https://github.com/lukehaas/css-loaders */ + +.loader, +.loader:before, +.loader:after { + background: #ffffff; + -webkit-animation: load1 1s infinite ease-in-out; + animation: load1 1s infinite ease-in-out; + width: 1em; + height: 4em; +} +.loader:before, +.loader:after { + position: absolute; + top: 0; + content: ''; +} +.loader:before { + left: -1.5em; + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; +} +.loader { + color: #ffffff; + text-indent: -9999em; + margin: 88px auto; + position: relative; + font-size: 11px; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation-delay: -0.16s; + animation-delay: -0.16s; +} +.loader:after { + left: 1.5em; +} + +@-webkit-keyframes load1 { + 0%, + 80%, + 100% { + box-shadow: 0 0; + height: 4em; + } + 40% { + box-shadow: 0 -2em; + height: 5em; + } +} + +@keyframes load1 { + 0%, + 80%, + 100% { + box-shadow: 0 0; + height: 4em; + } + 40% { + box-shadow: 0 -2em; + height: 5em; + } +} diff --git a/app/postcss/_user-state.css b/app/postcss/_user-state.css new file mode 100644 index 0000000..ac53e68 --- /dev/null +++ b/app/postcss/_user-state.css @@ -0,0 +1,67 @@ +.header__user { + height: 50px; + display: flex; + align-items: center; + color: #D6D6D6; +} + +.header__user__avatar { + width: 50px; + margin-right: 20px; + margin-left: 20px; + position: relative; + cursor: pointer; + * { + cursor: pointer; + } + img { + width: 100%; + height: 100%; + border-radius: 50%; + } + + .loader { + font-size: 7px; + } +} + +.header__user__menu { + position: absolute; + top: 70px; + width: 150px; + background-color: $content; + box-shadow: 0px 0px 14px rgba(0, 0, 0, .18); + color: $primarytext; + padding: 10px 0; + font-size: 17px; + &::before { + content: ''; + position: absolute; + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-bottom: 10px solid $content; + top: -10px; + left: 13px; + } +} + +.header__user__menu__list { + li { + padding-left: 10px; + height: 25px; + line-height: 25px; + transition: background-color ease 0.5s; + cursor: pointer; + &:hover { + background-color: $grey-300; + } + } +} + +.header__user__login, .header__user__name { + height: 30px; + line-height: 30px; + cursor: pointer; +} diff --git a/app/postcss/_volume.css b/app/postcss/_volume.css new file mode 100644 index 0000000..cbdfda2 --- /dev/null +++ b/app/postcss/_volume.css @@ -0,0 +1,23 @@ +.player__volume { + display: flex; + align-items: center; + .i { + fill: $icon; + } +} + +.player__volume__bar-wrapper { + width: 120px; + height: 10px; + margin-left: 10px; + background-color: $red-200; + border-radius: 10px; + overflow: hidden; +} + +.player__volume__bar { + background-color: $red-500; + height: 100%; + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} diff --git a/app/postcss/index.css b/app/postcss/index.css new file mode 100644 index 0000000..1c143ee --- /dev/null +++ b/app/postcss/index.css @@ -0,0 +1,31 @@ +@import "reset"; +@import "colors"; +@import "scrollbar"; +@import "header"; +@import "player"; +@import "content"; +@import "button"; +@import "spinner"; +@import "songcard"; +@import "albumcard"; +@import "songlist"; +@import "loginform"; +@import "card"; +@import "playcontentcard"; + +* { + box-sizing: border-box; + cursor: default; + user-select: none; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; + position: relative; +} + +body { + overflow: hidden; +} diff --git a/app/reducers/alert.js b/app/reducers/alert.js new file mode 100644 index 0000000..ed488e6 --- /dev/null +++ b/app/reducers/alert.js @@ -0,0 +1,24 @@ +'use strict' +export default function alert(state, action) { + if (action.type !== 'USER') { + if (state) { + return state; + } else { + return { + showAlert: false, + }; + } + } + newState = Object.assign({}, state); + switch (action.state) { + case 'NEWALERT': + newState.showAlert = true; + newState.body = action.payload; + return newState; + case 'CLOSEALERT': + newState.showAlert = false; + return newState; + default: + return newState; + } +} diff --git a/app/reducers/index.js b/app/reducers/index.js new file mode 100644 index 0000000..a72f887 --- /dev/null +++ b/app/reducers/index.js @@ -0,0 +1,22 @@ +import { combineReducers } from 'redux'; +import player from './player'; +import search from './search'; +import song from './song'; +import user from './user'; +import usersong from './usersong'; +import router from './router'; +import songlist from './songlist'; +import playcontent from './playcontent'; + +const cloudMusic = combineReducers({ + player, + search, + song, + user, + usersong, + router, + songlist, + playcontent, +}); + +export default cloudMusic; diff --git a/app/reducers/playcontent.js b/app/reducers/playcontent.js new file mode 100644 index 0000000..eedf08e --- /dev/null +++ b/app/reducers/playcontent.js @@ -0,0 +1,26 @@ +'use strict' +export default function playcontent(state, action) { + if (action.type !== 'PLAYCONTENT') { + if (state) { + return state; + } else { + return { + mode: 'mini', + state: 'show', + }; + } + } + newState = Object.assign({}, state); + switch (action.state) { + case 'SHOWMINI': + newState.mode = 'mini'; + newState.state = 'show'; + return newState; + case 'HIDDENMINI': + newState.mode = 'mini'; + newState.state = 'hidden'; + return newState; + default: + return newState; + } +} diff --git a/app/reducers/player.js b/app/reducers/player.js new file mode 100644 index 0000000..880d8b1 --- /dev/null +++ b/app/reducers/player.js @@ -0,0 +1,18 @@ +'use strict' +export default function player(state, action) { + switch (action.type) { + case 'PLAYER': + if (action.state == 'PLAYER_PLAY') { + return { isplay: true } + } else { + return { isplay: false } + } + + default: + if (state) { + return state; + } else { + return { isplay: false } + } + } +} diff --git a/app/reducers/router.js b/app/reducers/router.js new file mode 100644 index 0000000..d23e3db --- /dev/null +++ b/app/reducers/router.js @@ -0,0 +1,35 @@ +'use strict' +import HomeContent from '../components/HomeContent.jsx'; + +export default function router(state, action) { + if (action.type !== 'ROUTER') { + if (state) { + return state; + } else { + return { + // default content + routerStack: [HomeContent], + canPop: false, + }; + } + } + newState = Object.assign({}, state); + switch (action.state) { + case 'PUSH': + newState.routerStack.push(action.payload); + if (newState.routerStack.length > 1) { + newState.canPop = true; + } + return newState; + case 'POP': + if (newState.routerStack.length > 1) { + newState.routerStack.pop(); + } + if (newState.routerStack.length > 1) { + newState.canPop = true; + } + return newState; + default: + return newState; + } +} diff --git a/app/reducers/search.js b/app/reducers/search.js new file mode 100644 index 0000000..b2f3a1e --- /dev/null +++ b/app/reducers/search.js @@ -0,0 +1,31 @@ +'use strict' +export default function search(state, action) { + if (action.type !== 'SEARCH') { + if (state) { + return state; + } else { + return { + searchState: 'FINISH', + searchResponse: null, + errorInfo: null, + } + } + } + let newState = Object.assign({}, state); + newState.searchState = action.state; + switch (action.state) { + case 'START': + newState.searchInfo = action.payload; + return newState; + case 'CLOSE': + return newState; + case 'FINISH': + newState.searchResponse = action.payload; + return newState; + case 'ERROR': + newState.errorInfo = action.payload; + return newState; + default: + return newState; + } +} diff --git a/app/reducers/song.js b/app/reducers/song.js new file mode 100644 index 0000000..d262a2b --- /dev/null +++ b/app/reducers/song.js @@ -0,0 +1,193 @@ +'use strict' +export default function song(state, action) { + rules = ['loop', ] + if (action.type !== 'SONG') { + if (state) { + return state; + } else { + return { + songlist: [], + playRule: 0, // loop one shuffle + rules: ['loop', 'one', 'shuffle'], + showplaylist: false, + }; + } + } + newState = Object.assign({}, state); + switch (action.state) { + case 'CHANGE': + let index = isExist(action.payload, newState.songlist); + if (index) { + index--; + newState.currentSongIndex = index; + } else { + newState.songlist.push(action.payload); + newState.currentSongIndex = newState.songlist.length - 1; + } + return newState; + case 'SHOWPLAYLIST': + newState.showplaylist = true; + return newState; + case 'CLOSEPLAYLIST': + newState.showplaylist = false; + return newState; + case 'PLAYFROMLIST': + newState.currentSongIndex = action.payload; + // FIXME + if (newState.playRule == 2) { + let toShuffle = []; + for (let i = 0;i < newState.songlist.length;i++) { + if (i == 0) { + toShuffle[i] = newState.currentSongIndex; + } else if (i == newState.currentSongIndex) { + toShuffle[i] = 0; + } else { + toShuffle[i] = i; + } + } + newState.shuffleList = getShuffle(toShuffle, 1); + newState.shuffleIndex = 0; + } + return newState; + case 'ADD': + if (isExist(action.payload, state.songlist)) { + return state; + } + newState.songlist.push(action.payload); + // if shuffle + if (newState.playRule == 2) { + newState.shuffleList.push(newState.songlist.length - 1); + newState.shuffleList = getShuffle( + newState.shuffleList, + newState.shuffleIndex + 1 + ); + } + if (newState.songlist.length == 1) { + newState.currentSongIndex = 0; + } + return newState; + case 'ADDLIST': + if (action.payload.play) { + var playIndex = newState.songlist.length; + } + action.payload.songlist.map(song => { + if (isExist(song, newState.songlist)) { + return; + } + newState.songlist.push(song); + if (newState.playRule == 2) { + newState.shuffleList.push(newState.songlist.length - 1); + } + }); + if (newState.playRule == 2) { + newState.shuffleList = getShuffle( + newState.shuffleList, + newState.shuffleIndex + 1 + ); + } + if (action.payload.play && newState.songlist.length > playIndex) { + newState.currentSongIndex = playIndex; + } + if (newState.songlist.length == 1) { + newState.currentSongIndex = 0; + } + return newState; + case 'CHANGERULE': + if (newState.playRule == 2) { + newState.playRule = 0; + } else { + newState.playRule++; + } + + // if rule is shuffle + if (newState.playRule == 2) { + let toShuffle = []; + for (let i = 0;i < newState.songlist.length;i++) { + if (i == 0) { + toShuffle[i] = newState.currentSongIndex; + } else if (i == newState.currentSongIndex) { + toShuffle[i] = 0; + } else { + toShuffle[i] = i; + } + } + newState.shuffleList = getShuffle(toShuffle, 1); + newState.shuffleIndex = 0; + } + + return newState; + case 'NEXT': + if (newState.songlist.length == 0) { + return newState; + } + if ((newState.playRule == 0) || (newState.playRule == 1)) { + if (newState.currentSongIndex === newState.songlist.length - 1){ + newState.currentSongIndex = 0; + } else { + newState.currentSongIndex++; + } + return newState; + } else if (newState.playRule == 2) { // shuffle + if (newState.shuffleIndex === newState.shuffleList.length - 1){ + newState.shuffleIndex = 0; + } else { + newState.shuffleIndex++; + } + newState.currentSongIndex = newState.shuffleList[newState.shuffleIndex]; + return newState; + } + //TODO: shuffle + case 'PREVIOUS': + if (newState.songlist.length == 0) { + return newState; + } + if ((newState.playRule == 0) || (newState.playRule == 1)) { + if (newState.currentSongIndex === 0){ + newState.currentSongIndex = newState.songlist.length - 1; + } else { + newState.currentSongIndex--; + } + return newState; + } else if (newState.playRule == 2) { // shuffle + if (newState.shuffleIndex === 0){ + newState.shuffleIndex = newState.shuffleList.length - 1; + } else { + newState.shuffleIndex--; + } + newState.currentSongIndex = newState.shuffleList[newState.shuffleIndex]; + return newState; + } + default: + return newState; + } +} + +function getShuffle(lastshuffle, index) { + let toShuffle = lastshuffle.slice(index, lastshuffle.length); + lastshuffle = lastshuffle.slice(0, index); + doShuffle(toShuffle).map(value => { + lastshuffle.push(value); + }); + + return lastshuffle; +} + +function doShuffle(list) { + for (let i = list.length;i > 0;i--) { + let j = Math.floor(Math.random() * i); + let x = list[i - 1]; + list[i - 1] = list[j]; + list[j] = x; + } + + return list; +} + +function isExist(newsong, list) { + for (let i = 0;i < list.length;i++) { + if (list[i].id == newsong.id) { + return i + 1; + } + } + return false; +} diff --git a/app/reducers/songlist.js b/app/reducers/songlist.js new file mode 100644 index 0000000..713588f --- /dev/null +++ b/app/reducers/songlist.js @@ -0,0 +1,28 @@ +'use strict' +export default function songlist(state, action) { + if (action.type !== 'SONGLIST') { + if (state) { + return state; + } else { + return { + state: 'fetching', + }; + } + } + newState = Object.assign({}, state); + switch (action.state) { + case 'FETCHING': + newState.state = 'fetching'; + return newState; + case 'GET': + newState.content = action.payload; + newState.state = 'get'; + return newState; + case 'ERROR': + newState.errorinfo = action.payload; + newState.state = 'error'; + return newState; + default: + return newState; + } +} diff --git a/app/reducers/user.js b/app/reducers/user.js new file mode 100644 index 0000000..db09fa2 --- /dev/null +++ b/app/reducers/user.js @@ -0,0 +1,44 @@ +'use strict' +export default function user(state, action) { + if (action.type !== 'USER') { + if (state) { + return state; + } else { + return { + loginState: 'guest', + showForm: false, + }; + } + } + newState = Object.assign({}, state); + switch (action.state) { + case 'LOGIN_STATE_LOGGING_IN': + newState.loginState = 'logging_in'; + return newState; + case 'LOGIN_STATE_LOGGED_IN': + newState.loginState = 'logged_in'; + newState.account = action.payload.account; + newState.profile = action.payload.profile; + return newState; + case 'LOGIN_STATE_LOGGED_FAILED': + newState.loginState = 'logged_failed'; + newState.loginError = action.payload; + return newState; + case 'LOGINFORM': + newState.showForm = action.payload; + return newState; + case 'GUEST': + newState.loginState = 'guest'; + removeCookie(); + return newState; + default: + return newState; + } +} + +function removeCookie() { + Electron.ipcRenderer.sendSync('removecookie', 'http://localhost:11015', 'MUSIC_U'); + Electron.ipcRenderer.sendSync('removecookie', 'http://loaclhost:11015', 'NETEASE_WDA_UID'); + Electron.ipcRenderer.sendSync('removecookie', 'http://localhost:11015', '__csrf'); + Electron.ipcRenderer.sendSync('removecookie', 'http://localhost:11015', '__remember_me'); +} diff --git a/app/reducers/usersong.js b/app/reducers/usersong.js new file mode 100644 index 0000000..c8cd9ca --- /dev/null +++ b/app/reducers/usersong.js @@ -0,0 +1,45 @@ +'use strict' +export default function user(state, action) { + if (action.type !== 'USERSONG') { + if (state) { + return state; + } else { + return { + create: [], + collect: [], + state: 'nouser', + uid: null, + }; + } + } + newState = Object.assign({}, state); + switch (action.state) { + case 'FETCHING': + newState.state = 'fetching'; + newState.uid = action.payload; + return newState; + case 'GET': + [newState.create, newState.collect] = separatePlayList(newState.uid, action.payload); + newState.state = 'get'; + return newState; + case 'ERROR': + newState.state = 'error'; + newState.errorinfo = action.payload; + return newState; + default: + return newState; + } +} + +function separatePlayList(id, list) { + let create = [], collect = []; + list.map(songlist => { + if (songlist.userId === id) { + create.push(songlist); + } else { + collect.push(songlist); + } + }); + + return [create, collect]; +} diff --git a/app/server/crypto.js b/app/server/crypto.js new file mode 100644 index 0000000..df9b6d5 --- /dev/null +++ b/app/server/crypto.js @@ -0,0 +1,65 @@ +// 参考 https://github.com/darknessomi/musicbox/wiki/ +'use strict' +const crypto = require('crypto'); +const bigInt = require('big-integer'); +const modulus = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7' +const nonce = '0CoJUm6Qyw8W8jud' +const pubKey = '010001' + +String.prototype.hexEncode = function(){ + var hex, i; + + var result = ""; + for (i=0; i mp3url +export function getSongUrl(song, callback) { + var id = song.id, br; + if (song.hMusic) { + br = song.hMusic.bitrate; + } else if (song.mMusic) { + br = song.mMusic.bitrate; + } else if (song.lMusic) { + br = song.lMusic.bitrate; + } + fetch('http://localhost:11015/music/url?id=' + id + '&br=' + br, { + credentials: 'include', + }) + .then( res => { + return res.json(); + }).then( json => { + callback(json.data[0]); + } ) +} + +// 搜索歌曲 +export function Search(keywords) { + return new Promise((resolve, reject) => { + fetch( + 'http://localhost:11015/search/?keywords=' + keywords + '&type=1&limit=40' + ) + .then( res => { + return res.json(); + }).then( json => { + resolve(json.result); + }).catch( e => { + reject(e); + }); + }) +} + +// 登录 +export function Login(username, pw) { + return new Promise((resolve, reject) => { + const emailReg = /^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*@([a-z0-9_][-a-z0-9_]*(\.[-a-z0-9_]+)*\.(aero|arpa|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro|travel|mobi|[a-z][a-z])|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}))(:[0-9]{1,5})?$/i; + const phoneReg = /^[0-9]{11}$/i + let fetchUrl= '' + if (phoneReg.test(username)) { + fetchUrl = 'http://localhost:11015/login/cellphone?phone=' + username + '&password=' + pw; + } else if (emailReg.test(username)) { + fetchUrl = 'http://localhost:11015/login?email=' + username + '&password=' + pw; + } else { + reject('用户名格式错误'); + } + + fetch(fetchUrl, { + credentials: 'include', + }) + .then( res => { + return res.json(); + }).then( json => { + if (json.code != 200) { + reject('Error:' + JSON.stringify(json)); + } else { + console.log('resolve', json); + resolve(json); + } + }).catch( e => { + reject(e); + }); + }) +} + +export function getPlayList(uid) { + return new Promise((resolve, reject) => { + fetch('http://localhost:11015/user/playlist?uid=' + uid, { + credentials: 'include', + }) + .then( res => { + return res.json(); + }).then( json => { + resolve(json); + }).catch( e => { + reject(e); + }); + }) +} + +// 获取歌单详情 +export function SonglistDetail(id) { + return new Promise((resolve, reject) => { + fetch('http://localhost:11015/playlist/detail?id=' + id) + .then( res => { + return res.json(); + }).then( json => { + resolve(json.playlist); + }).catch( e => { + reject(e); + }); + }) +} diff --git a/app/server/server.js b/app/server/server.js new file mode 100644 index 0000000..372c375 --- /dev/null +++ b/app/server/server.js @@ -0,0 +1,291 @@ +var Encrypt = require('./crypto.js'); +var express = require('express'); +var http = require('http'); +var crypto = require('crypto'); +var tough = require('tough-cookie'); +var Cookie = tough.Cookie; + +var app = express(); + +function createWebAPIRequest(host, path, method, data, cookie, callback) { + console.log('reqCookie', cookie); + var music_req = ''; + var cryptoreq = Encrypt(data); + var http_client = http.request({ + hostname: host, + method: method, + path: path, + headers: { + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.8,gl;q=0.6,zh-TW;q=0.4', + 'Connection': 'keep-alive', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': 'http://music.163.com', + 'Host': 'music.163.com', + 'Cookie': cookie, + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.152 Safari/537.36', + + }, + }, function(res) { + res.setEncoding('utf8'); + if (res.statusCode == 500) { + console.log("500"); + createWebAPIRequest(host, path, method, data, cookie, callback); + return; + } else { + console.log("200"); + res.on('data', function (chunk) { + music_req += chunk; + }); + res.on('end', function() { + if (music_req == '') { + console.log('empty'); + createWebAPIRequest(host, path, method, data, cookie, callback); + return; + } + if (res.headers['set-cookie']) { + callback(music_req, res.headers['set-cookie']); + } else { + callback(music_req); + } + }) + } + }); + http_client.write('params=' + cryptoreq.params + '&encSecKey=' + cryptoreq.encSecKey); + http_client.end(); +} + +function createRequest(path, method, data, callback) { + var ne_req = ''; + var http_client = http.request({ + hostname: 'music.163.com', + method: method, + path: path, + headers: { + 'Referer': 'http://music.163.com', + 'Cookie' : 'appver=2.0.2', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, function(res) { + res.setEncoding('utf8'); + res.on('data', function (chunk) { + ne_req += chunk; + }); + res.on('end', function() { + callback(ne_req); + }) + }); + console.log(data); + http_client.write(data); + http_client.end(); +} + +app.get('/music/url', function(request, response) { + var id = parseInt(request.query.id); + var br = parseInt(request.query.br); + var data = { + "ids": [id], + "br": br, + "csrf_token": "" + }; + console.log(data); + var cookie = request.get('Cookie') ? request.get('Cookie') : ''; + + createWebAPIRequest( + 'music.163.com', + '/weapi/song/enhance/player/url', + 'POST', + data, + cookie, + function(music_req) { + response.setHeader("Content-Type", "application/json"); + response.send(music_req); + } + ) +}); + +app.get('/search', function(request, response) { + var keywords = request.query.keywords; + var type = request.query.type; + var limit = request.query.limit; + var data = 's=' + keywords + '&limit=' + limit + '&type=' + type + '&offset=0'; + createRequest('/api/search/pc/', 'POST', data, function(res) { + response.setHeader("Content-Type", "application/json"); + response.send(res); + }); +}); + +app.get('/login/cellphone', function(request, response) { + var phone = request.query.phone; + var cookie = request.get('Cookie') ? request.get('Cookie') : ''; + var md5sum = crypto.createHash('md5'); + md5sum.update(request.query.password); + var data = { + 'phone': phone, + 'password': md5sum.digest('hex'), + 'rememberLogin': 'true' + }; + + console.log(data); + + createWebAPIRequest( + 'music.163.com', + '/weapi/login/cellphone', + 'POST', + data, + cookie, + function(music_req, cookie) { + console.log(music_req); + response.set({ + 'Set-Cookie': cookie, + }); + response.send(music_req); + } + ) +}); + +app.get('/login', function(request, response) { + var email = request.query.email; + var cookie = request.get('Cookie') ? request.get('Cookie') : ''; + var md5sum = crypto.createHash('md5'); + md5sum.update(request.query.password); + var data = { + 'username': email, + 'password': md5sum.digest('hex'), + 'rememberLogin': 'true' + }; + + console.log(data); + + createWebAPIRequest( + 'music.163.com', + '/weapi/login', + 'POST', + data, + cookie, + function(music_req, cookie) { + console.log(music_req); + response.set({ + 'Set-Cookie': cookie, + }); + response.send(music_req); + } + ) +}); + +app.get('/recommend/songs', function(request, response) { + var cookie = request.get('Cookie') ? request.get('Cookie') : ''; + var data = { + "offset": 0, + "total": true, + "limit": 20, + "csrf_token": "" + }; + + console.log(data); + + createWebAPIRequest( + 'music.163.com', + '/weapi/v1/discovery/recommend/songs', + 'POST', + data, + cookie, + function(music_req) { + console.log(music_req); + response.send(music_req); + } + ) +}); + +app.get('/user/playlist', function(request, response) { + var cookie = request.get('Cookie') ? request.get('Cookie') : ''; + var data = { + "offset": 0, + "uid": request.query.uid, + "limit": 1000, + "csrf_token": "" + }; + + console.log(data); + + createWebAPIRequest( + 'music.163.com', + '/weapi/user/playlist', + 'POST', + data, + cookie, + function(music_req) { + console.log(music_req); + response.send(music_req); + } + ) +}); + +app.get('/playlist/detail', function(request, response) { + var cookie = request.get('Cookie') ? request.get('Cookie') : ''; + var detail, imgurl; + var data = { + "id": request.query.id, + "offset": 0, + "total": true, + "limit": 1000, + "n": 1000, + "csrf_token": "" + }; + + console.log(data); + + createWebAPIRequest( + 'music.163.com', + '/weapi/v3/playlist/detail', + 'POST', + data, + cookie, + function(music_req) { + console.log(music_req); + detail = music_req; + mergeRes(); + } + ) + + // FIXME:i dont know the api to get coverimgurl + // so i get it by parsing html + var http_client = http.get({ + hostname: 'music.163.com', + path: '/playlist?id=' + request.query.id, + headers: { + 'Referer': 'http://music.163.com', + }, + }, function(res) { + res.setEncoding('utf8'); + var html = ''; + res.on('data', function (chunk) { + html += chunk; + }); + res.on('end', function() { + console.log('end', html); + var regImgCover = /\ + + + + + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..2bd02e7 --- /dev/null +++ b/main.js @@ -0,0 +1,97 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; +const BrowserWindow = electron.BrowserWindow; +const ipcMain = electron.ipcMain; +const Tray = electron.Tray; +const Menu = electron.Menu; + +const Childprocess = require('child_process'); +const path = require('path'); +const http = require('http'); + +let server_process; + +let mainWindow; +var appIcon = null; + +function createWindow () { + mainWindow = new BrowserWindow({ + width: 1000, + height: 800, + webPreferences: { + nodeIntegration: 'iframe', + webSecurity: false, + }, + title: 'CloudMusic', + frame: false, + icon: 'app/assets/icon.png', + }); + + mainWindow.loadURL('http://127.0.0.1:8080'); + //mainWindow.loadURL('file://' + __dirname + '/index.html'); + + mainWindow.webContents.on('did-finish-load', function() { + var session = electron.session.fromPartition(); + session.cookies.get({}, function(error, cookies) { + mainWindow.webContents.send('cookie', cookies); + }); + }); + + //mainWindow.webContents.openDevTools(); + + mainWindow.on('closed', function() { + mainWindow = null; + }); + + server_process = Childprocess.spawn('node', ['server.js'], { + cwd: path.resolve(__dirname, './app/server/'), + stdio: ['ignore', process.stdout, process.stderr], + }); + + ipcMain.on('closeapp', function(e) { + mainWindow.close(); + e.sender.send('closed'); + }); + + ipcMain.on('minimize', function(e) { + mainWindow.minimize(); + e.sender.send('minimize'); + }); + + ipcMain.on('maximize', function(e) { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + e.sender.send('maximize'); + }); +} + +app.on('ready', createWindow); + +app.on('window-all-closed', function () { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', function () { + if (mainWindow === null) { + createWindow(); + } +}); + +process.on('exit', function() { + server_process.kill('SIGHUP'); +}); + +ipcMain.on('removecookie', function(e, url, name) { + var session = electron.session.fromPartition(); + session.cookies.remove(url, name, function() { + console.log('remove', url, name); + e.returnValue = 'OK'; + }); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..0d298dc --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "electron-cloud-music", + "version": "0.0.1", + "description": "", + "main": "main.js", + "scripts": { + "start": "babel-node server.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/disoul/electron-cloud-music.git" + }, + "author": "disoul", + "license": "ISC", + "bugs": { + "url": "https://github.com/disoul/electron-cloud-music/issues" + }, + "homepage": "https://github.com/disoul/electron-cloud-music#readme", + "devDependencies": { + "autoprefixer": "^6.3.6", + "babel-core": "^6.7.4", + "babel-loader": "^6.2.4", + "babel-preset-es2015-webpack": "^6.4.0", + "babel-preset-react": "^6.5.0", + "browserslist": "^1.3.1", + "css-loader": "^0.23.1", + "electron-packager": "^7.0.1", + "electron-prebuilt": "^0.37.7", + "eslint-loader": "^1.3.0", + "exports-loader": "^0.6.3", + "express": "^4.13.4", + "file-loader": "^0.8.5", + "imports-loader": "^0.6.5", + "less": "^2.6.1", + "less-loader": "^2.2.3", + "postcss-color-alpha": "^1.0.3", + "postcss-loader": "^0.8.2", + "precss": "^1.4.0", + "querystring": "^0.2.0", + "react": "^0.14.8", + "react-dom": "^0.14.8", + "react-hot-loader": "^1.3.0", + "react-redux": "^4.4.1", + "react-transform": "0.0.3", + "redux": "^3.3.1", + "redux-logger": "^2.6.1", + "redux-thunk": "^2.0.1", + "style-loader": "^0.13.1", + "svg-react-loader": "^0.3.3", + "tough-cookie": "^2.2.2", + "url-loader": "^0.5.7", + "webpack": "^2.1.0-beta.5", + "webpack-dev-middleware": "^1.6.1", + "webpack-hot-middleware": "^2.10.0" + }, + "dependencies": { + "big-integer": "^1.6.15", + "whatwg-fetch": "^0.11.0" + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..8e135fa --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,42 @@ +var path = require('path'); +var webpack = require('webpack'); +var browserslist = require('browserslist'); +var precss = require('precss'); +var autoprefixer = require('autoprefixer'); +var postcsscoloralpha = require('postcss-color-alpha'); + +module.exports = { + entry: path.join(__dirname, 'app/main.js'), + output: { + path: path.join(__dirname, 'dist'), + publicPath: '/dist/', + filename: 'main.js', + }, + resolve: { + extensions: ['', '.js', '.jsx'], + }, + module: { + preloaders: [ + { test: /\.jsx?$/, loaders: ['eslint-loader']} + ], + loaders: [ + { test: /\.json$/, loader: 'json' }, + { test: /\.jsx?$/, exclude: /node_modules/, loader: "babel" }, + { test: /\.css?$/, loader: "style-loader!css-loader!postcss-loader" }, + { test: /\.svg?$/, loader: "babel!svg-react", exclude: [ + path.resolve(__dirname, './app/assets/img'), + ]}, + { test: /\.(png|jpg)?$/, loader: "url?name=[path]" }, + + ] + }, + postcss: function() { + return [precss, autoprefixer({ browsers: browserslist('last 2 Chrome versions') }), postcsscoloralpha] + }, + plugins: [ + new webpack.HotModuleReplacementPlugin(), + new webpack.ProvidePlugin({ + 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch' + }), + ], +};