Tracking Development with Git now

This commit is contained in:
Paoda 2019-02-08 19:34:04 -06:00
commit 3aedb93bbe
40 changed files with 8813 additions and 0 deletions

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

76
.gitignore vendored Normal file
View File

@ -0,0 +1,76 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
#Custom
build/bundle.js

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Rekai Nyangadzayi Musuka
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# Melodii Music Player
### An Electron Based Music Player
## Why Was Melodii Music Player Made?
MMP was created as a long term project to introduce me to electron and more complicated programming in general.
Before this project I had worked on small projects that never took more than an hour. After seing a friend create a videogame
in Java, I felt the need to challenge myself as well...
## Goals
I wish to have a Operational Music Player that can achieve a couple of things:
- Good Looking Flat UI
- Ability to Play at the bare minimum: FLAC and MP3.
- Ability to read a directory chosen by user and scan said directory for Music
- Play, Pause, Go Back 30s, Skip Forward and Backwards Controls
- Progress Bar thing that allows you to go back and forward in a song.
- Total song Time & current song time e.g. 3:20/6:23
- Display Albums with Album Covers
- Have Album Cover in general
Damn This is very ambitous for a beginner like me...
## TODO
- UI
- Design
- HTML, CSS Written
- Choose Music From Directory
- Working Table in which you can select songs from
- ~~Read Files from directory~~
- Pull Metadata from music files.
- Media Controls
- ~~Audio Seeking~~
- Audio Playback
- Support for:
- ~~FLAC~~
- ~~MP4~~
- ~~MP3~~
- ~~M4a~~
- ~~AAC~~
- ~~WAV~~
- ~~Ogg~~
- Sorting of albums
- By Artist
- By Title
- By Album Artist
- Searching of albums
- [last.fm](http://last.fm) scrobbling.
- [Visualizer](https://github.com/paoda/js-visualizer)

15
build/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Melodii</title>
</head>
<body>
<div class="app"></div>
<!-- Webpack -->
<script src="./bundle.js"></script>
</body>
</html>

28
main.js Normal file
View File

@ -0,0 +1,28 @@
require('dotenv').config();
const { app, BrowserWindow } = require('electron');
let win;
function createWindow() {
win = new BrowserWindow({ width: 800, height: 600, frame: false, webPreferences: { webSecurity: false } }); //TODO: Please find a better solution for the love of god.
if (process.env.NODE_ENV) win.loadURL(`http://localhost:${process.env.PORT}`);
else win.loadFile('./build/index.html');
win.webContents.openDevTools();
win.on('closed', () => {
win = null;
});
}
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (win === null) createWindow();
});

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "melodii",
"version": "2018.1226.0",
"description": "Melodii Music Player",
"main": "main.js",
"author": "Rekai Nyangadzayi Musuka",
"license": "MIT",
"scripts": {
"app": "electron .",
"serve": "webpack-dev-server",
"build:prod": "webpack --mode production",
"build:dev": "webpack --mode development",
"start": "run-p serve app",
"start:serverless": "run-p build:dev app",
"test": "node ./node_modules/mocha/bin/mocha"
},
"private": false,
"devDependencies": {
"@babel/core": "^7.2.2",
"@babel/polyfill": "^7.2.5",
"@babel/preset-env": "^7.2.3",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.0.0",
"babel-loader": "^8.0.4",
"chai": "^4.2.0",
"css-loader": "^2.1.0",
"electron": "^4.0.0",
"file-loader": "^3.0.1",
"mocha": "^5.2.0",
"node-sass": "^4.11.0",
"npm-run-all": "^4.1.5",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"webpack": "^4.28.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.14"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.12",
"@fortawesome/free-solid-svg-icons": "^5.6.3",
"@fortawesome/react-fontawesome": "^0.1.3",
"dotenv": "^6.2.0",
"electron-settings": "^3.2.0",
"lastfm": "^0.9.2",
"lastfmapi": "^0.1.1",
"music-metadata": "^3.3.2",
"react": "^16.7.0",
"react-dom": "^16.7.0"
}
}

134
src/App.js Normal file
View File

@ -0,0 +1,134 @@
import React, { Component } from "react";
import "./scss/App.scss";
import Header from "./components/Header.js";
import Body from "./components/Body.js";
import SeekBar from "./components/SeekBar.js";
import AlbumArt from "./components/AlbumArt.js";
import Footer from "./components/Footer.js";
import SettingsManager from "./components/Modal/SettingsManager";
import Song from "./melodii/Song";
import MusicPlayer from "./melodii/MusicPlayer";
import Filepath from "./melodii/Filepath";
import Archive from "./melodii/SongArchive";
var archive = new Archive();
var mp = new MusicPlayer();
// const playlist = new Playlist("Test", "\\\\PAODA-SERVER\\Weeb_Home\\Music")
// archive.add(null, playlist);
const filepath = new Filepath("C:\\Users\\Paoda\\Downloads");
(async () => {
let list = await filepath.getValidFiles();
let song = new Song(list[~~(Math.random() * list.length)]);
song = await Song.getMetadata(song);
Song.setAlbumArt(song.metadata);
mp.load(song);
// mp.play();
mp.element.onended = async () => {
let song = new Song(list[~~(Math.random() * list.length)]);
song = await Song.getMetadata(song);
Song.setAlbumArt(song.metadata);
mp.load(song);
mp.play();
};
})();
class App extends Component {
constructor(props) {
super(props);
this.seekBarFps = 60; //fps
this.textFps = 10;
this.state = {
currentTime: 0,
duration: 100,
title: "",
artist: "",
album: ""
};
}
componentDidMount() {
this.seekBarTimer = window.setInterval(
this.checkSeekBar.bind(this),
1000 / this.seekBarFps
);
this.textTimer = window.setInterval(
this.checkText.bind(this),
1000 / this.textFps
);
}
componentWillUnmount() {
window.clearInterval(this.seekBarTimer);
window.clearInterval(this.textTimer);
}
/** Updates the Seekbar with the current time. */
checkSeekBar() {
if (archive.currentSongExists()) {
let dur = mp.duration();
let pos = mp.currentTime();
if (dur !== this.state.duration) {
//both position and duration have changed, update both.
this.setState({
currentTime: mp.currentTime() || 0,
duration: mp.duration() || 0
});
} else if (pos !== this.state.currentTime) {
this.setState({
//only currentTime() has changed
currentTime: mp.currentTime() || 0
});
}
}
}
/** Updates the Song Text area with the current Song's Title - Artist | Album Name
* @async
* @return {void}
*/
async checkText() {
if (archive.currentSongExists()) {
let song = archive.getCurrentSong();
if (!song.metadata) {
song = await Song.getMetadata(song);
console.log("Async checkText() was run");
}
if (song.metadata.common.title !== this.state.title) {
//if title's changed might as well re render entire thing.
this.setState({
title: song.metadata.common.title || "",
artist: song.metadata.common.artist || "",
album: song.metadata.common.album || ""
});
}
}
}
render() {
return (
<div className="melodiiContainer">
<Header />
<Body />
<SeekBar
currentTime={this.state.currentTime}
duration={this.state.duration}
/>
<AlbumArt />
<Footer
title={this.state.title}
artist={this.state.artist}
album={this.state.album}
/>
<SettingsManager />
</div>
);
}
}
export default App;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,32 @@
import React from 'react';
import noalbumart from '../assets/img/noalbumart.png';
import Emitter from '../melodii/Events';
export default class AlbumArt extends React.Component {
constructor() {
super();
this.state = {albumArt: noalbumart};
this.handleEvents();
}
shouldComponentUpdate(nextprops, nextState) {
return this.state.albumArt !== nextState.albumArt;
}
render() {
console.log("Album Art Updated");
return (
<div id='albumContainer'>
<img alt='Album-Art' src={this.state.albumArt} id='albumImg'></img>
</div>
);
}
/**
* @listens Song#updateAlbumArt Updates Album Art
*/
handleEvents() {
Emitter.on('updateAlbumArt', (blob, err) => {
if (err) throw err;
this.setState({albumArt: blob});
});
}
}

236
src/components/Body.js Normal file
View File

@ -0,0 +1,236 @@
import React from "react";
import Table from "./Body/Table";
import Song from "../melodii/Song";
import Filepath from "../melodii/Filepath";
import Misc from "./MiscMethods";
import Emitter from "../melodii/Events";
import Modal from "./Modal";
const Settings = window.require("electron-settings");
export default class Body extends React.Component {
constructor() {
super();
this.state = {
table: null,
msg: ""
};
}
/** @listens Table#newTable loads new Table*/
handleEvents() {
Emitter.on("newTable", (obj, err) => {
if (err) throw err;
this.setState({ table: obj });
});
}
componentWillMount() {
this.initialize();
}
shouldComponentUpdate(nextProps, nextState) {
return this.state.table !== nextState.table;
}
render() {
return (
<div className="wrapper">
<div id="searchBar">
<input
type="text"
placeholder="Search..."
onKeyUp={this.checkKey.bind(this)}
tabIndex="0"
/>
<input
type="button"
onClick={this.openSettings.bind(this)}
value="Settings"
/>
<span id="bad-search-syntax" />
</div>
<Table table={this.state.table} />
</div>
);
}
/** Initializes Body Element (Therefore Table too) @async @return {void}*/
async initialize() {
this.handleEvents.bind(this);
this.handleEvents();
let template = {
thead: {
tr: ["Artist", "Title", "Album", "Year", "Genre", "Time"] //contains <th> strings
},
tbody: []
};
if (!Settings.has("tableJSON")) {
let tableJSON = await this.generate(template);
this.setState({
table: tableJSON
});
let timestamp = Date.now();
Settings.set("tableJSON", {
data: tableJSON,
timestamp: timestamp
});
let date = new Date(timestamp);
console.log(
"Table data created at: " +
date.toDateString() +
" at " +
date.toTimeString()
);
} else {
console.log("Data Loaded from Persistent Storage Space");
this.setState({
table: Settings.get("tableJSON").data
});
let date = new Date(Settings.get("tableJSON").timestamp);
console.log(
"Table data created on: " +
date.toDateString() +
" at " +
date.toTimeString()
);
}
}
/**
* Generates Table From Scratch
* @param {Array<String>} template
* @return {Promise<}
* @async
*/
async generate(template) {
let filepath = new Filepath("C:\\Users\\Paoda\\Downloads");
let list = await filepath.getValidFiles();
console.log("Found " + list.length + "valid files.");
return new Promise(async (res, rej) => {
let temp = await this.generateBody(
template,
list,
0,
list.length - 1
);
res(temp);
});
}
/**
* Generates Body of Table from Scratch
* @param {Object} tableArg Table Element
* @param {Array<String>} arr Array of Valid Song Files
* @param {Number} start of Array
* @param {Number} end of Array
* @return {Object} Table Object with Body Completely parsed.
* @async
*/
async generateBody(tableArg, arr, start, end) {
let table = tableArg;
let t1 = performance.now();
let dom = document.getElementById("bad-search-syntax");
for (let i = 0; i <= end; i++) {
let song = new Song(arr[i]);
song = await Song.getMetadata(song);
table.tbody.push(Misc.formatMetadata(song, song.metadata));
dom.innerHTML = "Creating Table Data: " + ~~((i / end) * 100) + "%";
}
let t2 = performance.now();
console.log(
"Time Taken (Table Data Creation): " +
Math.floor(t2 - t1) / 1000 +
"s"
);
return new Promise((res, rej) => {
res(table);
});
}
/**
* Handles Key Presses
* @param {KeyboardEvent} e
*/
checkKey(e) {
if (e.keyCode === 13) this.search(e.target.value);
}
/**
* Searches for Elements using a the provided String Parameter as an argument.
* - Directly Manipulates the State of Body once it's done so it returns void
* @param {String} string
*/
search(string) {
const table = Settings.get("tableJSON").data;
console.log("Search Text: " + string);
if (string === "") {
this.setState({ table: table });
} else if (string.includes(":")) {
let type = string.match(/^(.*):/)[0];
type = type.substr(0, type.length - 1);
let term = string.match(/:( ?.*)$/)[0];
if (term[1] === " ") term = term.substr(2, term.length);
else term = term.substr(1, term.length);
type = type.toLowerCase();
let temp = {
tbody: null,
thead: table.thead
};
if (type === "title") {
term = term.toLowerCase();
temp.tbody = table.tbody.filter(obj =>
obj.title.toLowerCase().includes(term)
);
} else if (type === "artist") {
term = term.toLowerCase();
temp.tbody = table.tbody.filter(obj =>
obj.artist.toLowerCase().includes(term)
);
} else if (type === "album") {
term = term.toLowerCase();
temp.tbody = table.tbody.filter(obj =>
obj.album.toLowerCase().includes(term)
);
} else if (type === "genre") {
term = term.toLowerCase();
temp.tbody = table.tbody.filter(obj =>
obj.genre.toLowerCase().includes(term)
);
} else if (type === "year") {
term = parseInt(term, 10);
temp.tbody = table.tbody.filter(obj => obj.year === term);
} else {
// type == time
term = term.toLowerCase();
temp.tbody = table.tbody.filter(obj =>
obj.time.toLowerCase().includes(term)
);
}
this.setState({ table: temp });
let error = document.getElementById("bad-search-syntax");
if (error.innerHTML !== "") error.innerHTML = "";
console.log("Search found: " + temp.tbody.length + " Songs");
} else {
document.getElementById("bad-search-syntax").innerHTML =
"Invalid Syntax!";
}
}
openSettings() {
const settings = document.querySelector('.settings-window');
Emitter.emit('loadModal');
}
}

View File

@ -0,0 +1,179 @@
import React from "react";
import Song from "../../melodii/Song";
import MusicPlayer from "../../melodii/MusicPlayer";
import Misc from "../MiscMethods";
import Emitter from "../../melodii/Events";
/** @type {HTMLElement} */
var active = document.createElement("tr");
active.classList.toggle("active");
const mp = new MusicPlayer();
var JSXcache;
/** The React Component Responsible for Rendering the Song Table */
export default class Table extends React.Component {
/** Throttles the interval of which the class re-renders when resizing.
* @param {Object} props */
constructor(props) {
super(props);
let self = this;
let throttle;
window.onresize = e => {
window.clearTimeout(throttle);
throttle = setTimeout(() => {
self.forceUpdate();
}, 250);
};
}
/**
* Method responsible for Initializing Table.headJSX and Table.bodyJSX, generating the Table JSX.
* @param {Object} table Obejct of Elements, Was once JSON (usually)
*/
initialize(table) {
this.headJSX = this.parseHead(table);
this.bodyJSX = this.parseBody(table);
}
render() {
if (this.props.table) {
if (this.props.table !== JSXcache) {
this.initialize(this.props.table);
console.log("Table Rendered from Scratch");
JSXcache = this.props.table;
return (
<table id="songTable">
<thead>
<tr>{this.headJSX}</tr>
</thead>
<tbody>{this.bodyJSX}</tbody>
</table>
);
} else {
return (
<table id="songTable">
<thead>
<tr>{this.headJSX}</tr>
</thead>
<tbody>{this.bodyJSX}</tbody>
</table>
);
}
} else {
return <table id="songTable" />;
}
}
/**
* Parses the Head Portion of the Table
* @param {Object} table
* @return {Array<HTMLTableHeaderCellElement>}
*/
parseHead(table) {
let arr = table.thead.tr;
return arr.map(string => (
<th
key={string}
onClick={this.handleSort.bind(this, table, string)}>
{string}
</th>
));
}
/**
* Handles Sorting the table by a Album, Title, Year etc..
* @fires Table#newTable Event to Generate a new Table
* @param {Object} table
* @param {String} term
*/
handleSort(table, term) {
const temp = table;
Emitter.emit("newTable", Misc.sortTable(temp, term));
}
/**
* Parses the Body Portion of the Table Object
* @param {Object} table
* @return {Array<HTMLTableRowElement>}
*/
parseBody(table) {
let arr = table.tbody;
let clientWidth = document.documentElement.clientWidth;
let innerWidth = window.innerWidth || 0;
let maxWidth = Math.max(clientWidth, innerWidth) / 6;
let temp = arr.map(obj => (
<tr
key={obj.location}
data-filepath={obj.location}
onClick={this.handleClick.bind(this)}
onKeyDown={this.handleKeyDown.bind(this)}
tabIndex="0">
<td id="text">
{Misc.truncateText(obj.artist, maxWidth, "Roboto")}
</td>
<td id="text">
{Misc.truncateText(obj.title, maxWidth, "Roboto")}
</td>
<td id="text">
{Misc.truncateText(obj.album, maxWidth, "Roboto")}
</td>
<td id="number">
{Misc.truncateText(obj.year, maxWidth, "Roboto")}
</td>
<td id="text">
{Misc.truncateText(obj.genre, maxWidth, "Roboto")}
</td>
<td id="number">
{Misc.truncateText(obj.time, maxWidth, "Roboto")}
</td>
</tr>
));
return temp;
}
/**
* Handles a Click on the Table
* @param {MouseEvent} e
* @return {void}
* @async
*/
async handleClick(e) {
if (active !== e.currentTarget) {
active.classList.toggle("active");
e.currentTarget.classList.toggle("active");
active = e.currentTarget;
} else {
let filepath = e.currentTarget.dataset.filepath;
let song = new Song(filepath);
mp.load(song);
mp.play();
song = await Song.getMetadata(song);
Song.setAlbumArt(song.metadata);
}
}
/**
* Handles a KeyDown Event
* @param {KeyboardEvent} e
* @async
*/
async handleKeyDown(e) {
// console.log("Focus:" + e.currentTarget.dataset.filepath + " Key: " + e.key);
if (e.keyCode === 13 && e.currentTarget === active) {
//Active and Presses Enter
let filepath = e.currentTarget.dataset.filepath;
let song = new Song(filepath);
mp.load(song);
mp.play();
song = await Song.getMetadata(song);
Song.setAlbumArt(song.metadata);
}
}
}

22
src/components/Footer.js Normal file
View File

@ -0,0 +1,22 @@
import React from 'react';
import SongInfo from './Footer/SongInfo';
import PlayPause from './Footer/PlayPause';
import Volume from './Footer/Volume';
export default class Footer extends React.Component {
render() {
return (
<footer>
<div className='mediaControls'>
{/* <SkipBkwd /> */}
<PlayPause />
{/* <SkipFwd /> */}
<Volume />
</div>
<div className='songInfo'>
<SongInfo title={this.props.title} artist={this.props.artist} album={this.props.album} />
</div>
</footer>
);
}
}

View File

@ -0,0 +1,16 @@
import React from 'react';
// import '@fortawesome/fontawesome-free/css/all.css';
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faVolumeUp } from '@fortawesome/free-solid-svg-icons';
library.add(faVolumeUp)
export default class Mute extends React.Component {
render() {
return (
// <i className= 'fa fa-volume-up' id='muteIcon'></i>
<FontAwesomeIcon icon="faVolumeUp" id='muteIcon' />
);
}
}

View File

@ -0,0 +1,52 @@
import React from "react";
import Emitter from "../../melodii/Events";
import MusicPlayer from "../../melodii/MusicPlayer";
// import "@fortawesome/fontawesome-free/css/all.css";
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
library.add(faPause);
library.add(faPlay);
const mp = new MusicPlayer();
export default class PlayPause extends React.Component {
/** @listens */
constructor(props) {
super(props);
Emitter.on("toggle", bool => this.handleEvent(bool));
this.state = { icon: "pause" };
}
handleClick() {
//Updates Icon and controls the Audio came from clicking on button
if (this.state.icon === "pause") {
//set to play
mp.pause();
this.setState({ icon: "play" });
} else {
//set to pause
mp.play();
this.setState({ icon: "pause" });
}
}
/**
* Updates Icon From Event
* @param {Boolean} bool
*/
handleEvent(bool) {
if (!bool) this.setState({ icon: "play" });
else this.setState({ icon: "pause" });
}
render() {
return (
<FontAwesomeIcon icon={this.state.icon} id="playPause" onClick={this.handleClick.bind(this)} />
);
}
}

View File

@ -0,0 +1,9 @@
import React from 'react';
export default class SongInfo extends React.Component {
render() {
return (
<span>{this.props.title} - {this.props.artist} | {this.props.album}</span>
);
}
}

View File

@ -0,0 +1,48 @@
import React from "react";
import MusicPlayer from "../../melodii/MusicPlayer";
const Settings = window.require("electron-settings");
const mp = new MusicPlayer();
export default class Volume extends React.Component {
constructor() {
super();
this.state = {
input: Settings.get("Volume") || 50,
max: 50
};
}
render() {
return (
<input
style={{
backgroundSize:
(this.state.input * 100) / this.state.max + "% 100%"
}}
value={this.state.input}
id="volBar"
type="range"
max={this.state.max}
onChange={this.setVolume.bind(this)}
onMouseUp={this.setLastVolume.bind(this)}
/>
);
}
/**
* Sets Volume
* @param {Event} e
*/
setVolume(e) {
this.setState({
input: e.target.value
});
mp.setVolume(e.target.value / 50);
}
/**
* Saves the Previous Volume
* @param {Event} e
*/
setLastVolume(e) {
Settings.set("Volume", e.target.value);
}
}

20
src/components/Header.js Normal file
View File

@ -0,0 +1,20 @@
import React from 'react';
import Minimize from './Header/Minimize';
import Quit from './Header/Quit';
export default class Header extends React.Component {
shouldComponentUpdate() {
return false;
}
render() {
return (
<header>
<span>Melodii Music Player</span>
<div id='headerIcons'>
<Minimize />
<Quit />
</div>
</header>
);
}
}

View File

@ -0,0 +1,21 @@
import React from 'react';
import Buttons from '../../melodii/Buttons';
// import '@fortawesome/fontawesome-free/css/all.css';
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faWindowMinimize } from '@fortawesome/free-solid-svg-icons'
library.add(faWindowMinimize)
export default class Minimize extends React.Component {
render() {
return (
<FontAwesomeIcon icon="window-minimize" onClick={this.minimize} />
// <i onClick={this.minimize} className= 'fa fa-window-minimize'></i>
);
}
minimize() {
Buttons.minimize();
}
}

View File

@ -0,0 +1,21 @@
import React from 'react';
import Buttons from '../../melodii/Buttons';
// import '@fortawesome/fontawesome-free/css/all.css';
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes);
export default class Quit extends React.Component {
render() {
return (
// <i onClick={this.quit} className= 'fa fa-times'></i>
<FontAwesomeIcon icon="times" onClick={this.quit} />
);
}
quit() {
Buttons.quit();
}
}

View File

@ -0,0 +1,324 @@
import Song from "../melodii/Song";
/**
* - Every Method in this file must be Static and _probably_ Synchronous
* - The Methods contained in this class must only be methods that don't really fit anywhere else
* - Any Functions that require the use of Fs are not allowd in this Class.
*/
export default class MiscMethods {
/**
* Finds the Mode of a Set of Numbers
* @param {Array<Number>} arr
* @return {Number} The Mode
* @static
*/
static mode(arr) {
//https://codereview.stackexchange.com/a/68431
return arr.reduce(
function(current, item) {
var val = (current.numMapping[item] =
(current.numMapping[item] || 0) + 1);
if (val > current.greatestFreq) {
current.greatestFreq = val;
current.mode = item;
}
return current;
},
{ mode: null, greatestFreq: -Infinity, numMapping: {} },
arr
).mode;
}
/**
* Finds the Median of a Set of Numbers
* @param {Array<Number>} arr
* @return {Number} The Median
* @static
*/
static median(arr) {
arr.sort((a, b) => {
return a - b;
});
let half = ~~(arr.length / 2);
if (arr.length % 2) return arr[half];
else return (arr[half - 1] + arr[half]) / 2.0;
}
/**
* Finds the Average of a Set of Numbers
* @param {Array<Number>} arr
* @return {Number} The Average
* @static
*/
static average(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total / arr.length;
}
/**
* Stack Overflow: https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
*
* This Method finds how much hortizontal space text takes up (UTF8 Compliant) using the HTML5 Canvas
*
* @param {String} text Text to Measure
* @param {String} font Font of Text
* @param {*} cnvs Cached Canvas (if it exists)
* @return {Number} Width of String of Text
* @static
*/
static measureText(text, font, cnvs) {
// let canvas =
// self.canvas || (self.canvas = document.createElement("canvas"));
// let ctx = canvas.getContext("2d");
let ctx;
let canvas = cnvs;
if (canvas) ctx = canvas.getContext("2d");
else {
canvas = document.createElement("canvas");
ctx = canvas.getContext("2d");
}
ctx.font = font;
let metrics = ctx.measureText(text);
return metrics.width;
}
/**
* This Method Truncates Text given the amount of available horizontal space and the font of the text desired so that
* All the text fits onto one line. If truncated the String ends up looking like thi...
*
*
* @param {String} text Text to be Truncated
* @param {Number} maxWidth How much Horizontal Space is available to be used up by text.
* @param {String} font Name of Font
* @return {String} Truncated Text
* @static
*/
static truncateText(text, maxWidth, font) {
let canvas = document.createElement("canvas");
let width = MiscMethods.measureText(text, font, canvas);
if (width > maxWidth) {
//text needs truncating...
let charWidths = [];
let ellipsisWidth = MiscMethods.measureText("...", font, canvas);
//get Average width of every char in string
for (let char in text)
if (typeof char === "string")
charWidths.push(MiscMethods.measureText(char, font));
// let charWidth = this.median(charWidths);
let charWidth = MiscMethods.average(charWidths);
// let charWidth = this.mode(charWidths);
//Find out how many of these characters fit in max Width;
let maxChars = (maxWidth - ellipsisWidth) / charWidth;
let truncated = "";
try {
truncated = text.substr(0, maxChars);
} catch (e) {
// console.warn('\n' + e + ' ASSUMPTION: Melodii width shrunk to extremely small proportions');
// console.warn('Text: "' + text + '"\nMaximum Width: ' + maxWidth + 'px.\nMaximum Space for Characters: ' + maxChars + 'px.');
}
return truncated + "...";
} else return text;
}
/**
* This function takes a Song and the Metadata of said Song and formats it so that it can be easlily processed by Table Generation.
* @param {Song} song
* @param {Object} metadata
* @return {Object} The Formateed Metadata
* @static
*/
static formatMetadata(song, metadata) {
let format = metadata.format;
let common = metadata.common;
let min = ~~((format.duration % 3600) / 60);
let sec = ~~(format.duration % 60);
if (sec < 10) sec = "0" + sec;
let time = min + ":" + sec;
return {
location: song.location,
time: time,
artist: common.artist || "",
title: common.title || "",
album: common.album || "",
year: common.year || "",
genre: common.genre ? common.genre.toString() : "",
inSeconds: format.duration
};
}
/**
* Sorts a Table based on a Term Given to the Method
*
* @param {Object} table Table Object
* @param {*} term Sort Term
* @return {Object} Processed Table Object
* @static
*
*/
static sortTable(table, term) {
term = term.toLowerCase();
let tbody = table.tbody.slice();
let res = {
thead: {
tr: table.thead.tr.slice()
},
tbody: null
};
if (term === "title") {
tbody.sort((a, b) => {
// turns [" Uptown Funk"] into ["Uptown Funk"]
let fixedStr = MiscMethods.removeLeadingWhitespaces(
a.title,
b.title
);
a.title = fixedStr[0];
b.title = fixedStr[1];
if (a.title < b.title) return -1;
else if (a.title > b.title) return 1;
else return 0;
});
} else if (term === "artist") {
tbody.sort((a, b) => {
let fixedStr = MiscMethods.removeLeadingWhitespaces(
a.artist,
b.artist
);
a.artist = fixedStr[0];
b.artist = fixedStr[1];
if (a.artist < b.artist) return -1;
else if (a.artist > b.artist) return 1;
else return 0;
});
} else if (term === "album") {
tbody.sort((a, b) => {
let fixedStr = MiscMethods.removeLeadingWhitespaces(
a.album,
b.album
);
a.album = fixedStr[0];
b.album = fixedStr[1];
if (a.album < b.album) return -1;
else if (a.album > b.album) return 1;
else return 0;
});
} else if (term === "genre") {
tbody.sort((a, b) => {
let fixedStr = MiscMethods.removeLeadingWhitespaces(
a.genre,
b.genre
);
a.genre = fixedStr[0];
b.genre = fixedStr[1];
if (a.genre < b.genre) return -1;
else if (a.genre > b.genre) return 1;
else return 0;
});
} else if (term === "year") {
tbody.sort((a, b) => {
return a.year - b.year;
});
} else if (term === "time") {
//term == time convert the time into seconds
//The messy else if + else is becomes a.inSeconds || b.inSeconds can be undefined.
tbody.sort((a, b) => {
if (a.inSeconds && b.inSeconds) {
return a.inSeconds - b.inSeconds;
} else if (a.inSeconds || b.inSeconds) {
if (a.inSeconds) {
let parsedB = b.time.split(":");
if (parsedB[0][0] === "0") parsedB[0] = parsedB[0][1];
if (parsedB[1][0] === "0") parsedB[1] = parsedB[1][1];
let totalB =
parseInt(parsedB[0], 10) * 60 +
parseInt(parsedB[1], 10);
b.inSeconds = totalB;
return a.inSeconds - totalB;
} else {
let parsedA = a.time.split(":");
if (parsedA[0][0] === "0") parsedA[0] = parsedA[0][1];
if (parsedA[1][0] === "0") parsedA[1] = parsedA[1][1];
let totalA =
parseInt(parsedA[0], 10) * 60 +
parseInt(parsedA[1], 10);
a.inSeconds = totalA;
return totalA - b.inSeconds;
}
} else {
let parsedA = a.time.split(":");
if (parsedA[0][0] === "0") parsedA[0] = parsedA[0][1];
if (parsedA[1][0] === "0") parsedA[1] = parsedA[1][1];
let parsedB = b.time.split(":");
if (parsedB[0][0] === "0") parsedB[0] = parsedB[0][1];
if (parsedB[1][0] === "0") parsedB[1] = parsedB[1][1];
let totalA =
parseInt(parsedA[0], 10) * 60 +
parseInt(parsedA[1], 10);
let totalB =
parseInt(parsedB[0], 10) * 60 +
parseInt(parsedB[1], 10);
a.inSeconds = totalA;
b.inSeconds = totalB;
return totalA - totalB;
}
});
}
if (table.tbody === tbody) console.warn("Music has not been sorted");
else console.log("Music has been sorted");
res.tbody = tbody;
return res;
}
/**
* Removes Leading Whitespaces from 2 Strings
* - Used Exclusively in MiscMethods.sortTable
* - Used so that Both Strings can properly be compared
* @param {String} string1
* @param {String} string2
* @return {Array<String>} Truncated Strings
* @static
*/
static removeLeadingWhitespaces(string1, string2) {
const regex = /^\s+/i;
let stringA = string1;
let stringB = string2;
if (regex.test(stringA)) {
let spaces = regex.exec(stringA)[0];
stringA = stringA.substr(spaces.length, stringA.length);
}
if (regex.test(stringB)) {
let spaces = regex.exec(stringB)[0];
stringB = stringB.substr(spaces.length, stringB.length);
}
return [stringA, stringB];
}
}

44
src/components/Modal.js Normal file
View File

@ -0,0 +1,44 @@
import React from 'react';
import Emitter from '../melodii/Events';
export default class Modal extends React.Component {
constructor(props) {
super(props);
this.state = {
style: { display: "none" }
}
Emitter.on('loadModal', () => {
this.setState({
style: { display: "flex" }
});
});
}
render() {
console.log("Modal Created");
return(
<div
className="modal"
onClick={this.handleClick.bind(this)}
style={this.state.style}
>
{this.props.content}
</div>
)
}
shouldComponentUpdate(nProps, nState) {
return (
this.props.content !== nProps.content ||
this.state.display !== nState.style.display)
}
handleClick() {
console.log("Dismissing Modal.");
this.setState({
style: { display: "none" }
});
}
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import Modal from '../Modal';
const Settings = window.require("electron-settings");
export default class SettingsManager extends Modal {
render() {
const JSX = <div className='settings-window'>Hello</div>
return(
<Modal content={JSX} />
);
}
}

54
src/components/SeekBar.js Normal file
View File

@ -0,0 +1,54 @@
import React from "react";
import MusicPlayer from "../melodii/MusicPlayer";
var mp = new MusicPlayer();
export default class SeekBar extends React.Component {
constructor(props) {
super(props);
this.isPlayingOnMouseDown = false;
this.onChangeUsed = false;
}
/** @param {Event} e */
handleChange(e) {
mp.seek(+e.target.value);
this.onChangeUsed = true;
}
/** @param {KeyboardEvent} e */
handleMouseDown(e) {
this.isPlayingOnMouseDown = !mp.isPaused;
mp.pause();
}
/** @param {MouseEvent} e */
handleMouseUp(e) {
if (!this.onChangeUsed) {
mp.seek(+e.target.value);
}
if (this.isPlayingOnMouseDown) mp.play();
}
render() {
return (
<div className="seekBar">
<input
type="range"
style={{
backgroundSize:
((this.props.currentTime || 0) * 100) /
(this.props.duration || 0) +
"% 100%"
}}
value={this.props.currentTime || 0}
max={this.props.duration || 0}
id="seekRange"
className="melodiiSlider"
onChange={this.handleChange.bind(this)}
onMouseDown={this.handleMouseDown.bind(this)}
onMouseUp={this.handleMouseUp.bind(this)}
/>
</div>
);
}
}

20
src/melodii/Buttons.js Normal file
View File

@ -0,0 +1,20 @@
const electron = window.require('electron');
export default class Buttons {
/**
* Quits Melodii
* @static
*/
static quit() {
electron.remote.app.quit();
}
/**
* Minimized Melodii
* @static
*/
static minimize() {
electron.remote.BrowserWindow.getFocusedWindow().minimize();
}
}

8
src/melodii/Events.js Normal file
View File

@ -0,0 +1,8 @@
import EventEmitter from 'events';
class Emitter extends EventEmitter {};
let emitter = new Emitter();
emitter.setMaxListeners(Infinity);
export default emitter;

74
src/melodii/Filepath.js Normal file
View File

@ -0,0 +1,74 @@
import os from "os";
const fs = window.require("fs");
export default class Filepath {
/**
*
* @param {String} location Filepath
* @param {Object} cache I don't know what this is.
*/
constructor(location, cache) {
this.cache = cache;
this.location = location;
if (os.platform() !== "win32") this.slash = "/";
else this.slash = "\\";
}
/**
* @return {Promise<Array<String>>} list of valid files.
*/
getValidFiles() {
return new Promise((res, rej) => {
this.scan(this.location, (err, list) => {
if (err) rej(err);
else {
let filteredList = list.filter(arg => {
if (
arg.match(
/^.*\.(flac|mp4|mp3|m4a|aac|wav|ogg)$/gi
) !== null
)
return true;
else return false;
});
res(filteredList);
}
});
});
}
/**
*
* Stack Overflow: http://stackoverflow.com/questions/5827612/node-js-fs-readdir-recursive-directory-search
*
* Recursively
*
* @param {String} dir
* @param {Object} done Callback
*/
scan(dir, done) {
let self = this;
let results = [];
fs.readdir(dir, (err, list) => {
if (err) return done(err);
let i = 0;
(function next() {
let file = list[i++];
if (!file) return done(null, results);
file = dir + self.slash + file;
fs.stat(file, (err, stat) => {
if (stat && stat.isDirectory()) {
self.scan(file, (err, res) => {
results = results.concat(res);
next();
});
} else {
results.push(file);
next();
}
});
})();
});
}
}

114
src/melodii/MusicPlayer.js Normal file
View File

@ -0,0 +1,114 @@
import Emitter from "./Events";
import SongArchive from "./SongArchive";
import Song from './Song';
var mp = new Audio();
var archive = new SongArchive();
export default class MusicPlayer {
constructor() {
this.element = mp;
this.isPaused = false;
}
/** Pauses Music and sets currentTime to 0. */
stop() {
this.pause();
this.isPaused = false;
this.element.currentTime = 0.0;
}
/** Pauses Music*/
pause() {
this.element.pause();
this.isPaused = true;
Emitter.emit("toggle", false);
}
/** Plays Music
* @async
*/
async play() {
if (this.isPaused) {
this.element.play();
this.isPaused = false;
Emitter.emit("toggle", true);
let song = await Song.getMetadata(archive.getCurrentSong());
document.title = song.metadata.common.title;
}
}
/** Loads Song
* @param {Song} song
*/
load(song) {
if (archive.getCurrentSong() !== undefined)
archive.add(archive.getCurrentSong());
let path = song.location;
archive.setCurrentSong(song);
if (!this.isPaused) this.pause();
try {
this.element.src = this.getURICompatible(path);
this.element.load();
console.log(path + " succesfully loaded");
} catch (e) {
console.error(path + " failed to load: " + e.name);
}
}
/** Turns a Filepath into one Chrome can handle
* @param {string} path
*/
getURICompatible(path) {
//eslint-disable-next-line
return path.replace(/[!'()*#?@$&+,;=\[\]]/g, c => {
//Excluded '/' '\' ':'
return "%" + c.charCodeAt(0).toString(16);
});
}
/** Seeks to a certain position in a song
* @param {Number} pos
*/
seek(pos) {
this.element.currentTime = pos;
}
/** gets the Current Time in song.
* @return {Number} currentTIme
*/
currentTime() {
return this.element.currentTime;
}
/** gets the Duration of the Song so far.
* @return {Number} duration
*/
duration() {
return this.element.duration;
}
/** Sets the Volume of the Audio Element
* @param {Number} vol
*/
setVolume(vol) {
if (vol <= 1) this.element.volume = vol;
else if (vol < 0) console.error(vol + "is too small (Volume)");
else if (vol > 1) console.error(vol + " is too large (Volume)");
}
/** onSongEnd Handler.
* @param {Object} lastfm will scrobble track
* @param {Boolean} random will choose a random song and play it.
* @param {Object} playlist will load next song in given playlist
*/
setOnSongEnd(lastfm, random, playlist) {
this.element.onended = () => {
if (random) console.log("random");
if (lastfm) console.log("lastfm");
if (playlist) console.log("playlist");
};
}
}

53
src/melodii/Playlist.js Normal file
View File

@ -0,0 +1,53 @@
import Filepath from "./Filepath";
import Misc from "../components/MiscMethods";
import Song from "./Song";
export default class Playlist {
/**
* Creates Playlist with Friendly Title, and Filepath
* @param {String} title
* @param {(String|Filepath)} path
*/
constructor(title, path) {
this.initialize(title, path);
}
/**
* Initializes Playlist
* @param {String} title
* @param {(String|Filepath)} path
* @return {void}
* @async
*/
async initialize(title, path) {
if (typeof path === "string") {
// is filepath
this.path = path;
let filepaths = await new Filepath(path).getValidFiles();
this.content = await this.getTableData(filepaths);
} else if (typeof path === "object") {
//array of songs
this.path = null;
this.content = path;
}
this.title = title;
}
/**
* Gets Formated Metadata for Table.js
* @param {Array<String>} filepaths
* @return {Object} Object of Metadata Information meant for Table Generation
*/
getTableData(filepaths) {
return new Promise(async (res, rej) => {
let content = [];
for (let i = 0; i < filepaths.length - 1; i++) {
let song = new Song(filepaths[i]);
song = await Song.getMetadata(song);
content.push(Misc.formatMetadata(song, song.metadata));
}
res(content);
});
}
}

70
src/melodii/Song.js Normal file
View File

@ -0,0 +1,70 @@
import noalbumart from '../assets/img/noalbumart.png';
import Emitter from './Events';
const mm = window.require('music-metadata');
export default class Song {
/**
*
* @param {String} path
* @param {Boolean} ShouldAlbumArtExist
*/
constructor(path, ShouldAlbumArtExist) { //whether album art should exist
this.location = path;
}
/**
* Gets Metadata from Song
* @param {Song} song
* @return {Promise<Song>}
* @static
*/
static getMetadata(song) {
const location = song.location;
return new Promise((res, rej) => {
mm.parseFile(location, {native: true, duration: true}).then((metadata) => {
song.metadata = metadata;
res(song);
}).catch((err) => {
rej(err);
});
});
}
/**
* gets and Displays Album Art
* @param {Object} metadata Song Metadata
* @static
*/
static setAlbumArt(metadata) {
if (metadata.common.picture) {
if (metadata.common.picture.length > 0) {
let picture = metadata.common.picture[0];
let url = URL.createObjectURL(new Blob([picture.data], {
'type': 'image/' + picture.format
}));
Emitter.emit('updateAlbumArt', url);
} else {
console.error(metadata.common.title + ' has Album Art, but no data was present');
Emitter.emit('updateAlbumArt', noalbumart);
}
} else {
console.warn(metadata.common.title + ' des not have Album Art');
Emitter.emit('updateAlbumArt', noalbumart);
}
}
/**
* Combines the functions of Song.getMetadata and Song.setAlbumArt into one.
* @param {Song} song
* @return {Promise<Object>}
* @static
*/
static doAll(song) {
return new Promise(async (res, rej) => {
let metadata = await this.getMetadata(song).catch((err) => rej(err));
this.setAlbumArt(metadata);
res(metadata);
});
}
}

110
src/melodii/SongArchive.js Normal file
View File

@ -0,0 +1,110 @@
import Song from './Song';
import Playlist from './Playlist';
/** @type {Array<Song>} */
var archive = [];
/** @type {Array<Playlist>} */
let playlists = [];
/** @type {Song} */
let currentSong;
/** @type {Playlist} */
let currentPlaylist;
/**
* Class for Keeping Information about Current Session of Melodii's Songs
*/
export default class SongArchive {
/**
* Adds Song to Archive
* @param {Song} song
* @param {Playlist} playlist
*/
add(song, playlist) {
if (song !== null) {
if (archive[archive.length - 1] !== song) {
console.log('Archive Updated.');
archive.push(song);
} else console.log('Archive not Updated (Same Song Loaded).');
} else if (playlist !== null) {
playlists.push(playlist);
}
}
/**
* Set the Currently Playing Song
* @param {Song} song
*/
setCurrentSong(song) {
currentSong = song;
}
/** @return {SongArchive} The Instanced Archive */
get() {
return archive;
}
/** @return {Number} Archive length */
length() {
return archive.length;
}
/** Get the Currently Playing Song
* @return {Song}
*/
getCurrentSong() {
return currentSong;
}
/** @return {Boolean} Whether a currently playing song exists or not. */
currentSongExists() {
return (currentSong) ? true : false;
}
/** Gets the Previously Played Song
* @return {Song}
*/
getPreviousSong() {
let index;
let pos = archive.indexOf(currentSong);
if (pos === -1) index = this.length() - 1; //song doesn't exist
else if (pos === 0) index = 0; //is the first song
else index = pos -1;
return archive[index];
}
/**
* Gets a pre-existing Playlist by it's title.
* @param {String} title
*/
getPlaylist(title) {
let pos = playlists.map(obj => obj.title).indexOf(title);
return playlists[pos];
}
/**
* Sets the Current Playlist
* @param {Playlist} playlist
*/
setCurrentPlaylist(playlist) {
currentPlaylist = playlist;
}
/**
* Gets the Curernt Playlist
* @return {Playlist}
*/
getCurrentPlaylist() {
return currentPlaylist;
}
/** @return {Boolean} whether a currently playing playlist exists or not */
currentPlaylistExists() {
return (currentPlaylist) ? true: false;
}
}

View File

@ -0,0 +1,180 @@
import API from "lastfmapi";
const remote = window.require("electron").remote;
const process = remote.getGlobal("process");
const settings = window.require("electron-settings");
/**
* Handles all Supported https://last.fm API Functions
*/
export default class LastFM {
/**
* Creats LastFM with credientials in order to start communicating w/ Last.FM Servers
* @param {String} apiKey
* @param {String} secret
*/
constructor(apiKey, secret) {
if (apiKey && secret) {
if (!settings.has("lastfm.apiKey")) {
//Save API Key
settings.set("lastfm", {
apiKey: apiKey,
secret: secret
});
} else if (settings.get("lastfm.apiKey") !== apiKey) {
//APi Keys are Different
settings.set("lastfm", {
apiKey: apiKey,
secret: secret
});
}
this.api = new API({
api_key: apiKey, //eslint-disable-line camelcase
secret: secret,
useragent: `melodii/v${process.env.npm_package_version} Melodii`
});
}
}
/**
* All In one Function which handles entire Authentication Process
* @async
*/
enable() {
return new Promise(async (res, rej) => {
let token = await this.getToken();
let sessionKey = await this.getSessionKey(token);
this.startSession(this.sessionName, sessionKey);
});
}
/**
* Displays lastFM authentication Page to get Session Key
* @return {Promise<String>}
* @async
*/
getToken() {
return new Promise((res, rej) => {
let key = "";
//create 800x600 chrome window.
let win = new remote.BrowserWindow({
width: 800,
height: 600
});
//Load last.fm signin page to get Session Key. (Therefore Giving acess to user's account)
win.loadURL(
`http://www.last.fm/api/auth/?api_key=${this.api.api_key}`
);
//Don't Show the Window until lastfm is done loading.
win.webContents.on("did-finish-load", () => {
win.show();
win.focus();
});
//Grab the Session Key the second LastFM puts it in the URL.
win.webContents.on("will-navigate", (e, url) => {
let self = this;
if (e) rej(e);
try {
let match = url.match(/token=(.*)/g);
key = match[0].substring(6, 38); //Guaranteed to be the session key
win.close();
if (!settings.has("lastfm.token"))
settings.set("lastfm.token", key);
else if (settings.get("lastfm.token") !== key)
settings.set("lastfm.token", key);
res(key);
} catch (e) {
rej(e);
}
});
});
}
startSession(sessionName, sessionKey) {
this.api.setSessionCredentials(sessionName, sessionKey);
}
/**
* Returns Session Key
* @param {String} token
* @return {Promise<String>} @async
*/
getSessionKey(token) {
return Promise((res, rej) => {
this.api.authenticate(token, (err, session) => {
if (err) rej(err);
if (!settings.has("lastfm.session.name")) {
settings.set("lastfm.session", {
name: session.username,
key: session.key
});
} else if (
settings.get("lastfm.session.name") !== session.username
) {
settings.set("lastfm.session", {
name: session.username,
key: session.key
});
}
this.sessionName = session.username;
res(session.key);
});
});
}
/**
* Updates nowPlaying on LastFM
* @param {String} artist
* @param {String} track
* @param {String} album
* @param {String} albumArtist
*/
nowPlaying(artist, track, album, albumArtist) {
this.api.track.updateNowPlaying(
{
artist: artist,
track: track,
album: album,
albumArtist,
albumArtist
},
(err, nowPlaying) => {
if (err) throw err;
console.log("Now Playing Updated");
}
);
}
/**
* Scrobbles Song to LastFM
* @param {String} artist
* @param {String} track
* @param {String} album
* @param {String} albumArtist
*/
scrobble(artist, track, album, albumArtist) {
this.api.track.scrobble(
{
artist: artist,
track: track,
timestamp: 1,
album: album,
albumArtist,
albumArtist
},
(err, scrobble) => {
if (err) throw err;
console.log("Scrobbling Successful.");
}
);
}
}

View File

@ -0,0 +1,21 @@
const settings = window.require('electron-settings');
class RichPresence {
constructor(clientID, secret) {
this.clientID = "";
this.secret = "";
if (settings.has("richPresence.clientID")) {
this.clientID = settings.get("richPresence.clientID");
this.secret = settings.get("richPresence.secret");
} else if (clientID && secret) {
settings.set("richPresence", {
clientID: clientID,
secret: secret
});
this.clientID = clientID;
this.secret = secret;
}
}
}

6
src/renderer.js Normal file
View File

@ -0,0 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './scss/main.scss';
ReactDOM.render(<App />, document.querySelector('.app'));

291
src/scss/App.scss Normal file
View File

@ -0,0 +1,291 @@
// @font-face {
// font-family: Roboto;
// src: url("./assets/fonts/Roboto-Regular.ttf");
// }
:root {
--ui-bg-colour: #616161;
--bg-colour: gray;
--accent-colour: #512da8;
--table-bg-even: #616161;
--table-bg-odd: #9e9e9e;
--table-bg-active: #64b5f6;
--table-txt-active: white;
--icon-color: white;
--song-info-text: white;
--album-art-bg: #bdbdbd;
}
.melodiiContainer {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
display: flex;
flex-flow: column;
}
/* CSS for Song Information */
.songInfo {
display: flex;
justify-content: center;
align-items: center;
margin-right: 0.75em;
}
.songInfo span {
color: white;
text-align: center;
}
/* CSS for the Header */
header {
-webkit-app-region: drag;
top: 0;
right: 0;
height: 25px;
background: var(--ui-bg-colour);
display: flex;
justify-content: space-between;
}
header svg {
-webkit-app-region: no-drag;
color: var(--icon-color);
cursor: pointer;
text-shadow: 1px 1px 2px #292929;
}
header span {
color: white;
align-self: center;
}
.fa-window-minimize {
font-size: 15px;
}
.fa-times {
font-size: 20px;
margin-right: 0.1em;
margin-left: 0.3em;
}
/* Song Table */
.wrapper table {
cursor: default;
border-spacing: 0;
border-collapse: collapse;
user-select: none;
}
.wrapper table tbody:hover {
cursor: pointer;
}
.active {
background: var(--accent-colour) !important;
color: white !important;
}
.wrapper table thead {
border-bottom: 2px solid #666666;
}
.wrapper table td {
width: calc(100vw / 6);
vertical-align: middle;
}
.wrapper table tr:nth-child(even) {
background: var(--table-bg-even);
color: white;
}
.wrapper table tbody tr td#number {
text-align: right;
padding-right: calc((100vw / 6) / 10);
}
.wrapper table tbody tr td#text {
text-align: left;
padding-left: calc((100vw / 6) / 10);
}
.wrapper table tr:nth-child(odd) {
background: var(--table-bg-odd);
color: white;
}
.wrapper table tr:focus {
outline: none;
}
/*CSS for the Body */
.wrapper {
flex: 2;
overflow: auto;
background: var(--bg-colour);
}
/* CSS for the Footer */
footer {
min-height: 2.75em;
text-align: center;
display: flex;
justify-content: space-between;
background: var(--ui-bg-colour);
}
/*CSS for Media Controls */
.mediaControls {
flex-shrink: 1;
display: flex;
justify-content: center;
padding-left: 8em;
/* margin-right: auto; */
align-items: center;
}
.mediaControls svg {
font-size: 2em;
color: var(--icon-color);
margin-left: 0.3em;
display: block;
cursor: pointer;
}
.mediaControls #SkipFwd {
margin-right: 0.5em;
}
.mediaControls #VolBar {
margin-top: auto;
margin-bottom: auto;
margin-left: 0.4em;
margin-right: 0.1em;
background-size: 100% 100%;
}
/*CSS for Album Art */
#albumContainer {
position: absolute;
align-items: center;
justify-content: center;
display: flex;
bottom: 0;
left: 0;
width: 7em;
height: 7em;
z-index: 2;
background: var(--album-art-bg);
}
#albumImg {
width: auto;
height: 100%;
}
/*CSS for SeekRange */
.seekBar {
background: var(--bg-colour);
z-index: 1;
display: flex;
width: 100%;
}
#seekRange {
margin-left: 8.5em;
width: 100%;
display: flex;
}
/*CSS for Range Input */
input[type="range"] {
margin: auto;
outline: none;
padding: 0;
height: 6px;
background-color: var(--album-art-bg);
background-image: linear-gradient(
var(--accent-colour),
var(--accent-colour)
);
border-radius: 10px;
background-size: 50% 100%;
background-repeat: no-repeat;
cursor: pointer;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-runnable-track {
box-shadow: none;
border: none;
background: transparent;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
width: 14px;
height: 14px;
border: 0;
background: var(--icon-color);
border-radius: 100%;
box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.1);
-webkit-appearance: none;
}
input[type="range"]#seekRange {
border-radius: 0;
background-size: 0% 100%;
}
/* Scrollbar CSS */
:-webkit-scrollbar-button {
display: none;
height: 13px;
border-radius: 0px;
background-color: transparent;
}
::-webkit-scrollbar-button:hover {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #512da8;
box-shadow: rgba(0, 0, 0, 0.5);
}
::-webkit-scrollbar-thumb:hover {
background-color: #5831b3;
}
::-webkit-scrollbar-track {
background-color: rgba(158, 158, 158, 0.25);
}
::-webkit-scrollbar {
width: 13px;
}
// Modal
.modal {
background: rgba(0,0,0, 0.3);
display: none;
align-items: center;
justify-content: center;
position: fixed;
height: 100%; // or vh?
width: 100%; // or vw?
z-index: 3;
.settings-window {
background: var(--table-bg-odd);
height: 50vh;
width: 70vw;
z-index: 4;
}
}

7
src/scss/main.scss Normal file
View File

@ -0,0 +1,7 @@
body {
margin: 0;
padding:0;
/* height: 100vh;
width: 100vw; */
font-family: Arial, Helvetica, sans-serif;
}

3
test/mocha.opts Normal file
View File

@ -0,0 +1,3 @@
--recursive
--require @babel/register
--require @babel/polyfill

45
webpack.config.js Normal file
View File

@ -0,0 +1,45 @@
require('dotenv').config();
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: process.env.NODE_ENV,
entry: ['@babel/polyfill', './src/renderer.js'],
output: {
path: path.resolve(__dirname, 'build'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}, {
test: /\.s?css$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'sass-loader' }
]
}, {
test: /\.(png|jpg|svg)$/,
use: [{ loader: 'file-loader' }]
}
]
},
devtool: 'inline-source-map',
target: 'electron-renderer',
devServer: {
contentBase: path.join(__dirname, 'build'),
compress: true,
port: process.env.PORT
}
};

6340
yarn.lock Normal file

File diff suppressed because it is too large Load Diff