diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a835337 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/* +vendor/* +public/app.* +config.ini +[Tt]humbs.db +desktop.ini +$RECYCLE.BIN/ diff --git a/LICENSE b/LICENSE index 4cb818e..7380327 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 Julian van de Groep +Copyright (c) 2016 Julian van de Groep Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 1931f65..81d41ff 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ -# now-listening +# now listening A replacement for the old /now page on lastfm profiles + +## Usage +https://now.flash.moe/#/{last.fm username} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6b1f6e9 --- /dev/null +++ b/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "matto1990/lastfm-api": "^1.2" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..86372c4 --- /dev/null +++ b/composer.lock @@ -0,0 +1,70 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "This file is @generated automatically" + ], + "hash": "0447259d6060720f3c189f0fdca5c123", + "content-hash": "94d9635fa82a3525da82ed39e8471a8b", + "packages": [ + { + "name": "matto1990/lastfm-api", + "version": "v1.2", + "source": { + "type": "git", + "url": "https://github.com/matto1990/PHP-Last.fm-API.git", + "reference": "1cf9a8947bf756beb876d5f8e2af398058805e08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/matto1990/PHP-Last.fm-API/zipball/1cf9a8947bf756beb876d5f8e2af398058805e08", + "reference": "1cf9a8947bf756beb876d5f8e2af398058805e08", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "php": ">=5.3.3", + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "LastFmApi\\": "src/lastfmapi/", + "Tests\\": "tests" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marcos", + "email": "devilcius@gmail.com" + }, + { + "name": "Matt", + "email": "matt@oakes.ws" + } + ], + "description": "Last.fm webservice client", + "homepage": "https://github.com/matto1990/PHP-Last.fm-API", + "keywords": [ + "api", + "last.fm", + "webservice client" + ], + "time": "2016-03-17 16:41:24" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/config.example.ini b/config.example.ini new file mode 100644 index 0000000..fca056c --- /dev/null +++ b/config.example.ini @@ -0,0 +1,2 @@ +api_key = your api key here +endpoint = https://ws.audioscrobbler.com/2.0/ diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..6bcde5d --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,53 @@ +// aliases +var gulp = require('gulp'), + less = require('gulp-less'), + ts = require('gulp-typescript'), + concat = require('gulp-concat'), + jsmin = require('gulp-minify'), + path = require('path'); + +// variables +var destination = './public', + less_sources = './src/less/**/*.less', + less_watch = less_sources, + ts_config = './tsconfig.json', + ts_sources = './src/typescript/**/*.ts', + ts_watch = ts_sources; + +// default task +gulp.task('default', ['less', 'typescript']); + +// watcher +gulp.task('watch', function () { + gulp.watch(less_watch, ['less']); + gulp.watch(ts_watch, ['typescript']); +}); + +// less +gulp.task('less', function () { + return gulp.src(less_sources) + .pipe(less({ + paths: [ + path.join(__dirname, 'less', 'includes') + ], + compress: true + })) + .pipe(concat('app.css')) + .pipe(gulp.dest(destination)); +}); + +// typescript +gulp.task('typescript', function () { + var tsProject = ts.createProject(ts_config); + + return gulp.src(ts_sources) + .pipe(ts(tsProject)) + .pipe(jsmin({ + ext: { + src: '-', + min: '.js' + }, + noSource: true + })) + .pipe(gulp.dest(destination)); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..7faec99 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "dependencies": { + "gulp": "^3.9.1", + "gulp-concat": "^2.6.0", + "gulp-less": "^3.0.5", + "gulp-minify": "0.0.11", + "gulp-typescript": "^2.13.0" + } +} diff --git a/public/get.php b/public/get.php new file mode 100644 index 0000000..f1af208 --- /dev/null +++ b/public/get.php @@ -0,0 +1,31 @@ + 'Please run "composer install" in the main directory first!'])); +} + +require_once '../vendor/autoload.php'; + +if (!file_exists('../config.ini')) { + die(view(['error' => 'Configuration missing! Make a copy of config.example.ini named config.ini and set your API key.'])); +} + +$config = parse_ini_file('../config.ini'); + +$auth = new AuthApi('setsession', ['apiKey' => $config['api_key']]); +$user = new UserApi($auth); +$now = $user->getRecentTracks(['user' => (isset($_GET['u']) ? $_GET['u'] : ''), 'limit' => '1']); + +if ($now === false) { + $now = ['error' => 'User not found.']; +} + +echo view($now); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..f0d2d84 --- /dev/null +++ b/public/index.html @@ -0,0 +1,45 @@ + + + + + + Now Listening + + + + + + +
+
+ + +
+ + + diff --git a/public/resources/grid.png b/public/resources/grid.png new file mode 100644 index 0000000..c5fd92b Binary files /dev/null and b/public/resources/grid.png differ diff --git a/public/resources/no-cover.png b/public/resources/no-cover.png new file mode 100644 index 0000000..596814b Binary files /dev/null and b/public/resources/no-cover.png differ diff --git a/src/less/app.less b/src/less/app.less new file mode 100644 index 0000000..9a74bc4 --- /dev/null +++ b/src/less/app.less @@ -0,0 +1,174 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + height: 100%; + width: 100%; +} + +a { + color: inherit; + text-decoration: none; +} + +.hidden { + display: none !important; +} + +.container { + background: url('resources/grid.png') transparent; + height: 100%; + width: 100%; + color: #fff; + font: 12px/20px "Exo 2", sans-serif; + + display: flex; + align-items: center; + justify-content: center; + + @media (max-width: 600px) { + &__index, + &__user { + width: 100%; + height: 100%; + } + } + + &__index { + text-align: center; + background: fade(#111, 80%); + box-shadow: 0 1px 4px #111; + padding: 6px 10px; + display: flex; + flex-flow: column; + justify-content: center; + } + + &__user { + display: flex; + flex-flow: row; + box-shadow: 0 1px 4px #111; + } +} + +.background { + background: none no-repeat center / cover #000; + z-index: -1; + position: absolute; + height: 100%; + width: 100%; + transition: background-color 2.1s; +} + +.index { + &__title { + font-weight: 200; + font-style: italic; + font-size: 3em; + line-height: 1.5em; + } + + &__form { + margin: 5px; + display: flex; + justify-content: center; + box-shadow: 0 1px 4px #111; + } + + &__username, + &__submit { + border: 1px solid #111; + color: #fff; + padding: 2px; + font-size: 1.5em; + height: 30px; + } + + &__username { + background: fade(#111, 50%); + border-right: 0; + font-family: "Exo 2", sans-serif; + width: 100%; + } + + &__submit { + border-left: 0; + background: #111; + width: 30px; + } + + &__dev { + font-size: 1.1em; + font-weight: 200; + } +} + +.cover { + flex-shrink: 0; + flex-grow: 0; + + @media (max-width: 600px) { + display: none; + } + + &__image { + background: none no-repeat center / cover fade(#111, 80%); + width: 300px; + height: 300px; + } +} + +.info { + flex-grow: 1; + flex-shrink: 0; + + background: fade(#111, 80%); + padding: 6px 8px; + + display: flex; + flex-flow: column; + + @media (min-width: 601px) { + width: 300px; + height: 300px; + } + + &__title { + font-size: 3em; + line-height: 1.2em; + font-style: italic; + font-weight: 200; + } + + &__artist { + font-size: 1.5em; + line-height: 1.2em; + font-weight: 200; + } + + &__flags { + line-height: 1.5em; + font-size: 2em; + flex-grow: 1; // fill space + } + + &__user { + align-self: end; + + display: inline-flex; + width: 100%; + + &-back { + cursor: pointer; + } + + &-name { + flex-grow: 1; + text-align: right; + } + } +} diff --git a/src/typescript/AJAX.ts b/src/typescript/AJAX.ts new file mode 100644 index 0000000..c888720 --- /dev/null +++ b/src/typescript/AJAX.ts @@ -0,0 +1,137 @@ +namespace NP +{ + export class AJAX + { + // XMLHTTPRequest container + private Request: XMLHttpRequest; + private Callbacks: any; + private Headers: any; + private URL: string; + private Send: string = null; + private Asynchronous: boolean = true; + + // Prepares the XMLHttpRequest and stuff + constructor(async: boolean = true) { + this.Request = new XMLHttpRequest(); + this.Callbacks = new Object(); + this.Headers = new Object(); + this.Asynchronous = async; + } + + // Start + public Start(method: HTTPMethod, avoidCache: boolean = false): void { + // Open the connection + this.Request.open(HTTPMethod[method], this.URL + (avoidCache ? "?no-cache=" + Date.now() : ""), this.Asynchronous); + + // Set headers + this.PrepareHeaders(); + + // Watch the ready state + this.Request.onreadystatechange = () => { + // Only invoke when complete + if (this.Request.readyState === 4) { + // Check if a callback if present + if ((typeof this.Callbacks[this.Request.status]).toLowerCase() === 'function') { + this.Callbacks[this.Request.status](); + } else { // Else check if there's a generic fallback present + if ((typeof this.Callbacks['0']).toLowerCase() === 'function') { + // Call that + this.Callbacks['0'](); + } + } + } + } + + this.Request.send(this.Send); + } + + // Stop + public Stop(): void { + this.Request = null; + } + + // Add post data + public SetSend(data: any): void { + // Storage array + var store: Array = new Array(); + + // Iterate over the object and them in the array with an equals sign inbetween + for (var item in data) { + store.push(encodeURIComponent(item) + "=" + encodeURIComponent(data[item])); + } + + // Assign to send + this.Send = store.join('&'); + } + + // Set raw post + public SetRawSend(data: string): void { + this.Send = data; + } + + // Get response + public Response(): string { + return this.Request.responseText; + } + + // Get all headers + public ResponseHeaders(): string { + return this.Request.getAllResponseHeaders(); + } + + // Get a header + public ResponseHeader(name: string): string { + return this.Request.getResponseHeader(name); + } + + // Set charset + public ContentType(type: string, charset: string = null): void { + this.AddHeader('Content-Type', type + ';charset=' + (charset ? charset : 'utf-8')); + } + + // Add a header + public AddHeader(name: string, value: string): void { + // Attempt to remove a previous instance + this.RemoveHeader(name); + + // Add the new header + this.Headers[name] = value; + } + + // Remove a header + public RemoveHeader(name: string): void { + if ((typeof this.Headers[name]).toLowerCase() !== 'undefined') { + delete this.Headers[name]; + } + } + + // Prepare Request headers + public PrepareHeaders(): void { + for (var header in this.Headers) { + this.Request.setRequestHeader(header, this.Headers[header]); + } + } + + // Adds a callback + public AddCallback(status: number, callback: Function): void { + // Attempt to remove previous instances + this.RemoveCallback(status); + + // Add the new callback + this.Callbacks[status] = callback; + } + + // Delete a callback + public RemoveCallback(status: number): void { + // Delete the callback if present + if ((typeof this.Callbacks[status]).toLowerCase() === 'function') { + delete this.Callbacks[status]; + } + } + + // Sets the URL + public SetUrl(url: string): void { + this.URL = url; + } + } +} diff --git a/src/typescript/Background.ts b/src/typescript/Background.ts new file mode 100644 index 0000000..282c651 --- /dev/null +++ b/src/typescript/Background.ts @@ -0,0 +1,8 @@ +namespace NP +{ + export enum Background + { + IMAGE, + COLOURFADE + } +} diff --git a/src/typescript/DOM.ts b/src/typescript/DOM.ts new file mode 100644 index 0000000..eeb70ab --- /dev/null +++ b/src/typescript/DOM.ts @@ -0,0 +1,134 @@ +namespace NP +{ + export class DOM + { + // Block Element Modifier class name generator + public static BEM(block: string, element: string = null, modifiers: string[] = [], firstModifierOnly: boolean = false): string + { + var className: string = ""; + + if (firstModifierOnly && modifiers.length === 0) { + return null; + } + + className += block; + + if (element !== null) { + className += "__" + element; + } + + var baseName: string = className; + + for (var _i in modifiers) { + if (firstModifierOnly) { + return baseName + "--" + modifiers[_i]; + } + + className += " " + baseName + "--" + modifiers[_i]; + } + + return className; + } + + // Shorthand for creating an element with a class and string + public static Element(name: string, className: string = null, id: string = null): HTMLElement { + var element = document.createElement(name); + + if (className !== null) { + element.className = className; + } + + if (id !== null) { + element.id = id; + } + + return element; + } + + // Shorthand for textnode + public static Text(text: string): Text { + return document.createTextNode(text); + } + + // Shorthand for getElementById (i'm lazy) + public static ID(id: string): HTMLElement { + return document.getElementById(id); + } + + // Shorthand for removing an element + public static Remove(element: HTMLElement): void { + element.parentNode.removeChild(element); + } + + // Shorthand for the first element of getElementsByClassName + public static Class(className: string): NodeListOf { + return >document.getElementsByClassName(className); + } + + // Shorthand for prepending + public static Prepend(target: HTMLElement, element: HTMLElement | Text): void { + if (target.children.length) { + target.insertBefore(element, target.firstChild); + } else { + this.Append(target, element); + } + } + + // Shorthand for appending + public static Append(target: HTMLElement, element: HTMLElement | Text): void { + target.appendChild(element); + } + + // Getting all classes of an element + public static ClassNames(target: HTMLElement): string[] { + var className: string = target.className, + classes: string[] = []; + + if (className.length > 1) { + classes = className.split(' '); + } + + return classes; + } + + // Adding classes to an element + public static AddClass(target: HTMLElement, classes: string[]): void { + for (var _i in classes) { + var current: string[] = this.ClassNames(target), + index: number = current.indexOf(classes[_i]); + + if (index >= 0) { + continue; + } + + current.push(classes[_i]); + + target.className = current.join(' '); + } + } + + // Removing classes + public static RemoveClass(target: HTMLElement, classes: string[]): void { + for (var _i in classes) { + var current: string[] = this.ClassNames(target), + index: number = current.indexOf(classes[_i]); + + if (index < 0) { + continue; + } + + current.splice(index, 1); + + target.className = current.join(' '); + } + } + + public static Clone(subject: HTMLElement): HTMLElement { + return (subject.cloneNode(true)); + } + + public static Query(query: string): NodeListOf { + return document.querySelectorAll(query); + } + } +} diff --git a/src/typescript/HTTPMethod.ts b/src/typescript/HTTPMethod.ts new file mode 100644 index 0000000..c161b57 --- /dev/null +++ b/src/typescript/HTTPMethod.ts @@ -0,0 +1,11 @@ +namespace NP +{ + export enum HTTPMethod + { + GET, + HEAD, + POST, + PUT, + DELETE + } +} diff --git a/src/typescript/Initialisation.ts b/src/typescript/Initialisation.ts new file mode 100644 index 0000000..96b3b89 --- /dev/null +++ b/src/typescript/Initialisation.ts @@ -0,0 +1,13 @@ +window.addEventListener("load", () => { + var user: string = location.hash.substring(2); + + NP.UI.RegisterHooks(); + + if (user.length < 1) { + NP.UI.Mode(NP.Mode.INDEX); + NP.UI.Background(NP.Background.COLOURFADE); + } else { + NP.UI.Mode(NP.Mode.USER); + NP.Watcher.Start(user); + } +}); diff --git a/src/typescript/Mode.ts b/src/typescript/Mode.ts new file mode 100644 index 0000000..c0f2d4d --- /dev/null +++ b/src/typescript/Mode.ts @@ -0,0 +1,8 @@ +namespace NP +{ + export enum Mode + { + INDEX, + USER + } +} diff --git a/src/typescript/UI.ts b/src/typescript/UI.ts new file mode 100644 index 0000000..2a4a42d --- /dev/null +++ b/src/typescript/UI.ts @@ -0,0 +1,114 @@ +namespace NP +{ + export class UI + { + private static IndexElem: HTMLDivElement = DOM.ID('index'); + private static UserElem: HTMLDivElement = DOM.ID('user'); + + private static BGElem: HTMLDivElement = DOM.ID('background'); + + private static FormUsername: HTMLInputElement = DOM.ID('username'); + private static FormSubmit: HTMLButtonElement = DOM.ID('submit'); + + private static InfoCover: HTMLDivElement = DOM.ID('np_cover'); + private static InfoTitle: HTMLDivElement = DOM.ID('np_title'); + private static InfoArtist: HTMLDivElement = DOM.ID('np_artist'); + private static InfoUser: HTMLDivElement = DOM.ID('np_user'); + private static InfoBack: HTMLDivElement = DOM.ID('np_back'); + private static InfoFlags: HTMLDivElement = DOM.ID('np_flags'); + + private static ColourFadeInterval: number = null; + + public static Mode(mode: Mode): void { + switch (mode) { + case Mode.INDEX: + DOM.AddClass(this.UserElem, ['hidden']); + DOM.RemoveClass(this.IndexElem, ['hidden']); + break; + + case Mode.USER: + DOM.AddClass(this.IndexElem, ['hidden']); + DOM.RemoveClass(this.UserElem, ['hidden']); + break; + } + } + + public static Background(mode: Background, property: string = null): void { + switch (mode) { + case Background.COLOURFADE: + if (this.ColourFadeInterval !== null) { + break; + } + + this.BGElem.style.backgroundImage = null; + + var fader: Function = () => { + var colour: string = Math.floor(Math.random() * 16777215).toString(16); + + if (colour.length !== 6 && colour.length !== 3) { + colour = "000000".substring(colour.length) + colour; + } + + UI.BGElem.style.backgroundColor = "#" + colour; + }; + + this.ColourFadeInterval = setInterval(fader, 2000); + fader.call(this); + break; + + case Background.IMAGE: + if (property === null) { + break; + } + + if (this.ColourFadeInterval !== null) { + clearInterval(this.ColourFadeInterval); + this.ColourFadeInterval = null; + } + + this.BGElem.style.backgroundColor = null; + this.BGElem.style.backgroundImage = "url('{0}')".replace('{0}', property); + break; + } + } + + public static SetInfo(cover: string, title: string, artist: string, user: string, now: boolean = false): void { + this.Background(Background.IMAGE, cover); + this.InfoCover.style.backgroundImage = "url('{0}')".replace('{0}', cover); + this.InfoTitle.innerText = title; + this.InfoArtist.innerText = artist; + this.InfoUser.innerText = user; + this.InfoFlags.innerHTML = ''; + + if (now) { + var nowIcon: HTMLSpanElement = DOM.Element('span', 'fa fa-music'); + nowIcon.title = 'Now playing'; + this.InfoFlags.appendChild(nowIcon); + } + } + + public static RegisterHooks(): void { + UI.InfoBack.addEventListener('click', () => { + Watcher.Stop(); + UI.Mode(Mode.INDEX); + NP.UI.Background(NP.Background.COLOURFADE); + location.hash = ''; + }); + + var enter: Function = () => { + location.hash = '#/' + UI.FormUsername.value; + Watcher.Start(UI.FormUsername.value); + }; + + UI.FormSubmit.addEventListener('click', () => { + enter.call(this); + }); + + UI.FormUsername.addEventListener('keydown', (ev) => { + if (ev.keyCode === 13) { + enter.call(this); + } + }); + } + } +} diff --git a/src/typescript/Watcher.ts b/src/typescript/Watcher.ts new file mode 100644 index 0000000..deac7ea --- /dev/null +++ b/src/typescript/Watcher.ts @@ -0,0 +1,53 @@ +namespace NP +{ + export class Watcher + { + private static Fetcher: AJAX = null; + private static CheckIntervalId: number = null; + private static CheckTimeout: number = 60 * 1000; + private static GetPath: string = "/get.php"; + private static User: string = null; + + public static Start(user: string): void { + this.User = user; + + this.Fetcher = new AJAX; + + this.Fetcher.AddCallback(200, () => { + var data: any = JSON.parse(Watcher.Fetcher.Response()); + console.log(data); + + if ((typeof data.error).toLowerCase() !== 'undefined') { + Watcher.Stop(); + UI.Mode(Mode.INDEX); + NP.UI.Background(NP.Background.COLOURFADE); + alert(data.error); + return; + } + + UI.Mode(Mode.USER); + + var image: string = data[0].images.large.length < 1 ? '/resources/no-cover.png' : data[0].images.large.replace('/174s/', '/300s/'), + now: boolean = (typeof data[0].nowplaying).toLowerCase() === 'undefined' ? false : data[0].nowplaying; + + UI.SetInfo(image, data[0].name, data[0].artist.name, this.User, now); + }); + + this.Check(); + + this.CheckIntervalId = setInterval(this.Check, this.CheckTimeout); + } + + public static Stop(): void { + clearInterval(this.CheckIntervalId); + this.CheckIntervalId = null; + this.Fetcher = null; + this.User = null; + } + + public static Check(): void { + Watcher.Fetcher.SetUrl(Watcher.GetPath + "?u=" + Watcher.User); + Watcher.Fetcher.Start(HTTPMethod.GET); + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a1ae177 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": false, + "removeComments": true, + "preserveConstEnums": true, + "outFile": "app.js", + "sourceMap": true, + "declaration": true + }, + "exclude": [ + "public" + ], + "compileOnSave": true +}