@ -0,0 +1,4 @@ | |||
.vercel | |||
.env | |||
node_modules | |||
.DS_STORE |
@ -1 +1,3 @@ | |||
[Spotify](https://novatorem.vercel.app/api/spotify-playing) | |||
<a href="https://novatorem.vercel.app/now-playing?open"> | |||
<img src="https://novatorem.vercel.app/now-playing" width="256" height="64" alt="Now Playing"> | |||
</a> |
@ -1,11 +0,0 @@ | |||
from http.server import BaseHTTPRequestHandler | |||
from datetime import datetime | |||
class handler(BaseHTTPRequestHandler): | |||
def do_GET(self): | |||
self.send_response(200) | |||
self.send_header('Content-type', 'text/plain') | |||
self.end_headers() | |||
self.wfile.write(str(datetime.now().strftime('%Y-%m-%d %H:%M:%S')).encode()) | |||
return |
@ -0,0 +1,44 @@ | |||
import { NowRequest, NowResponse } from "@vercel/node"; | |||
import { renderToString } from "react-dom/server"; | |||
import { decode } from "querystring"; | |||
import { Player } from "../components/NowPlaying"; | |||
import { nowPlaying } from "../utils/spotify"; | |||
export default async function (req: NowRequest, res: NowResponse) { | |||
const { | |||
item = {}, | |||
is_playing: isPlaying = false, | |||
progress_ms: progress = 0, | |||
} = await nowPlaying(); | |||
const params = decode(req.url.split("?")[1]) as any; | |||
if (params && typeof params.open !== "undefined") { | |||
if (item && item.external_urls) { | |||
res.writeHead(302, { | |||
Location: item.external_urls.spotify, | |||
}); | |||
return res.end(); | |||
} | |||
return res.status(200).end(); | |||
} | |||
res.setHeader("Content-Type", "image/svg+xml"); | |||
res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate"); | |||
const { duration_ms: duration, name: track } = item; | |||
const { images = [] } = item.album || {}; | |||
const cover = images[images.length - 1]?.url; | |||
let coverImg = null; | |||
if (cover) { | |||
const buff = await (await fetch(cover)).arrayBuffer(); | |||
coverImg = `data:image/jpeg;base64,${Buffer.from(buff).toString("base64")}`; | |||
} | |||
const artist = (item.artists || []).map(({ name }) => name).join(", "); | |||
const text = renderToString( | |||
Player({ cover: coverImg, artist, track, isPlaying, progress, duration }) | |||
); | |||
return res.status(200).send(text); | |||
} |
@ -1,3 +0,0 @@ | |||
flask==1.1.2 | |||
requests==2.24.0 | |||
python-dotenv==0.14.0 |
@ -1,216 +0,0 @@ | |||
from flask import Flask, Response, jsonify | |||
from base64 import b64encode | |||
from dotenv import load_dotenv, find_dotenv | |||
load_dotenv(find_dotenv()) | |||
import requests | |||
import json | |||
import os | |||
import random | |||
""" | |||
Inspired from https://github.com/natemoo-re | |||
""" | |||
print("Starting Server") | |||
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") | |||
SPOTIFY_SECRET_ID = os.getenv("SPOTIFY_SECRET_ID") | |||
SPOTIFY_REFRESH_TOKEN = os.getenv("SPOTIFY_REFRESH_TOKEN") | |||
SPOTIFY_URL_REFRESH_TOKEN = "https://accounts.spotify.com/api/token" | |||
SPOTIFY_URL_NOW_PLAYING = "https://api.spotify.com/v1/me/player/currently-playing" | |||
LATEST_PLAY = None | |||
app = Flask(__name__) | |||
def get_authorization(): | |||
return b64encode(f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_SECRET_ID}".encode()).decode("ascii") | |||
def refresh_token(): | |||
data = { | |||
"grant_type": "refresh_token", | |||
"refresh_token": SPOTIFY_REFRESH_TOKEN, | |||
} | |||
headers = {"Authorization": "Basic {}".format(get_authorization())} | |||
response = requests.post(SPOTIFY_URL_REFRESH_TOKEN, data=data, headers=headers) | |||
repsonse_json = response.json() | |||
return repsonse_json["access_token"] | |||
def get_now_playing(): | |||
token = refresh_token() | |||
headers = {"Authorization": f"Bearer {token}"} | |||
response = requests.get(SPOTIFY_URL_NOW_PLAYING, headers=headers) | |||
if response.status_code == 204: | |||
return {} | |||
repsonse_json = response.json() | |||
return repsonse_json | |||
def get_svg_template(): | |||
css_bar = "" | |||
left = 1 | |||
for i in range(1, 76): | |||
anim = random.randint(350, 500) | |||
css_bar += ".bar:nth-child({}) {{{{ left: {}px; animation-duration: {}ms; }}}}".format( | |||
i, left, anim | |||
) | |||
left += 4 | |||
svg = ( | |||
""" | |||
<svg width="320" height="445" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |||
<foreignObject width="320" height="445"> | |||
<div xmlns="http://www.w3.org/1999/xhtml" class="container"> | |||
<style> | |||
div {{font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;}} | |||
.container {{background-color: #121212; border-radius: 10px; padding: 10px 10px}} | |||
.playing {{ font-weight: bold; color: #53b14f; text-align: center; display: flex; justify-content: center; align-items: center;}} | |||
.not-play {{color: #ff1616;}} | |||
.artist {{ font-weight: bold; font-size: 20px; color: #fff; text-align: center; margin-top: 5px; }} | |||
.song {{ font-size: 16px; color: #b3b3b3; text-align: center; margin-top: 5px; margin-bottom: 15px; }} | |||
.logo {{ margin-left: 5px; margin-top: 5px; }} | |||
.cover {{ border-radius: 5px; margin-top: 9px; }} | |||
#bars {{ | |||
height: 30px; | |||
margin: -20px 0 0 0px; | |||
position: absolute; | |||
width: 40px; | |||
}} | |||
.bar {{ | |||
background: #53b14f; | |||
bottom: 1px; | |||
height: 3px; | |||
position: absolute; | |||
width: 3px; | |||
animation: sound 0ms -800ms linear infinite alternate; | |||
}} | |||
@keyframes sound {{ | |||
0% {{ | |||
opacity: .35; | |||
height: 3px; | |||
}} | |||
100% {{ | |||
opacity: 1; | |||
height: 28px; | |||
}} | |||
}} | |||
""" | |||
+ css_bar | |||
+ """ | |||
</style> | |||
{} | |||
</div> | |||
</foreignObject> | |||
</svg> | |||
""" | |||
) | |||
return svg | |||
def load_image_b64(url): | |||
resposne = requests.get(url) | |||
return b64encode(resposne.content).decode("ascii") | |||
def make_svg(data): | |||
global LATEST_PLAY | |||
template = get_svg_template() | |||
text = "Now playing" | |||
content_bar = "".join(["<div class='bar'></div>" for i in range(75)]) | |||
if data == {} and LATEST_PLAY is not None: | |||
data = LATEST_PLAY | |||
text = "Latest play" | |||
content_bar = "" | |||
elif data == {}: | |||
content = """ | |||
<div class="playing not-play">Nothing playing on Spotify</div> | |||
""" | |||
return template.format(content) | |||
content = """ | |||
<div class="playing">{} on <img class="logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAAE5ElEQVRYR81WS08bVxQ+dzweY48fQCCBllJDmqpRSx6gKizSiAUbILZjA21KVEhF20hNlLb/IKu2m3QRKaqQogIRkAXhYQxFSJGCQqVKrVSBQtpCFqEQkrQhxnb8Yjxzp7oTmdjjsWd4LHIlLzz3PL57zvedexG8Qgu9Qlhg22Bqumr0+/L2VdAMOpBvtdgCoedBnhPvsw/YpcFLg9x2DrklMA19DVajkf4B0dQnAMDkSJgQedwfWot9fevcraBWYJrA1HXX5RUWWG8jhGq1Bk7aiVj8PfGfWOc754uq+aqCOXXT8bFOTw2oBVLb5zmhw9s6fj2XXU4wniFnF6LRF2qJtO6LPPQMN3s/zWafFcxuA0kCwILYO+IZO6sESBHMbrUmWwVwQjw70jLWK9/PAEPIuqfQFtNa+u3acU8wKyd1BhjPqPNXJdVgHkP8WRwiqxGI/RsDLsSBiEXQGXRgKDCAqcQk/RgbA4hS1QXx/WPYPVaTepg0LzJHTBZmcy5wQQ5mL89CeCUMIGqvAQFjLjeD/aQdiqqLsoKLh/nCiTMT68nIaWA8Q45riKY6k5szF2YgEU5oR5HFkn2dhUNfHQLjXmOaBRZw34jHRwaotNLANHtdG6mTdaF3AVZvrwJCCNgyFoqOFIHtgA2MxUbQGXXSdyxg4MM8RJ9EYf2vdfDf80PsaUyxkqSNtd+nzU1+yOXVZ4Ahd429pCzjTiG80MIBeVH4GA/Lk8uw/PMyEL4lV9XFKiiuLt78H/2Hy5u8OEmK8LIyjb2Nbxvz9Qu5eiIKokTcuD8OfISXTk+zNOQV5r0grk6ZuIGFAMxdnpMIf+LHE0Dpqc00XCT+nq9t6l4aGOeNpia9iR6XgyHKuXv1rjYSI5BaWN5QDqUflAJFv0ya7ZBCHLeMfuQbSgPTPnG6LcLH+uVOM+dnIBFRIHGyCDlURgh7+JvDYCo1ZS04qzd3Xm/s/0lTZQJ/B2BxYBHK6sug+Ggx6M16Ge1BahdRHSHvytQKhB6E0pITMLXfKV/4QgK3jLbIKqOFM1vROFHW/NX5zdFw7NtjwL7GZoSIB/mqifaJ+bTKtF5qZfBRTmK14hIBuOccRB5GIPIoAhv+DeDjPDBWBsxvmMFaaQVDviGjaut/rkNgMQAVpyoUwyqqiVg2e11E2pu6l7xFgDtf3gEiVS2LgNv/4X4oPV6qxVx5zhBPz5CjG9FU2vUuiiJMd05LcrRWWKHg3QKwvGkBJv/FHUQkHl4Ow9rsGhAJp86USncl2F32rKCwgG+MeHxtSYO0wVDfVW+zlbABLUfKLlUBFvsW4fEvjyWTg50HJZkrrfDT+J6pz6b8imCk6ow4f0MUen8ngKTuCiIsjS+B3WFXnOAiFueG3WNHUvNkjExHl8PElFCRnYJR83/2MGiZPj8dzgmGbLoGT7bTjC7jJaaWQOs+zwmfe1vHr8ntc7yBXd2IBsW3qtakSnYixgPDbt8Zpb2cTzL3sLOH0qGOnSRP9c0FhNipvg/dN5s6KD3ds1NA2Vqjyhl5YkJq/V40gyhUvVVQRDX+R6HjcrJuuU1yh6b+pgLGSF2hdNRpAKBzAOOxgAejfu5C6hxRO4hqm7IFaLjSYKCL8Fs6HfOO1WK1haKhYCKKF3AA30++3NSSa1bTVgPthv22K7MbyeUx/gfIiuIzZiZJFQAAAABJRU5ErkJggg==" /></div> | |||
<div class="artist">{}</div> | |||
<div class="song">{}</div> | |||
<div id='bars'> | |||
{} | |||
</div> | |||
<a href="{}" target="_BLANK"> | |||
<center> | |||
<img src="data:image/png;base64, {}" width="300" height="300" class="cover"/> | |||
</center> | |||
</a> | |||
""" | |||
item = data["item"] | |||
""" | |||
print(json.dumps(item)) | |||
print(item["artists"][0]["name"]) | |||
print(item["external_urls"]["spotify"]) | |||
print(item["album"]["images"][0]["url"]) | |||
""" | |||
img = load_image_b64(item["album"]["images"][1]["url"]) | |||
artist_name = item["artists"][0]["name"].replace("&", "&") | |||
song_name = item["name"].replace("&", "&") | |||
content_rendered = content.format( | |||
text, | |||
artist_name, | |||
song_name, | |||
content_bar, | |||
item["external_urls"]["spotify"], | |||
img, | |||
) | |||
return template.format(content_rendered) | |||
@app.route("/", defaults={"path": ""}) | |||
@app.route("/<path:path>") | |||
def catch_all(path): | |||
global LATEST_PLAY | |||
# TODO: caching | |||
data = get_now_playing() | |||
svg = make_svg(data) | |||
# cache lastest data | |||
if data != {}: | |||
LATEST_PLAY = data | |||
resp = Response(svg, mimetype="image/svg+xml") | |||
resp.headers["Cache-Control"] = "s-maxage=1" | |||
return resp | |||
if __name__ == "__main__": | |||
app.run(debug=True) |
@ -0,0 +1,162 @@ | |||
import React from "react"; | |||
import ReadmeImg from "./ReadmeImg"; | |||
import Text from "./Text"; | |||
export interface Props { | |||
cover?: string; | |||
track: string; | |||
artist: string; | |||
progress: number; | |||
duration: number; | |||
isPlaying: boolean; | |||
} | |||
export const Player: React.FC<Props> = ({ | |||
cover, | |||
track, | |||
artist, | |||
progress, | |||
duration, | |||
isPlaying, | |||
}) => { | |||
return ( | |||
<ReadmeImg width="256" height="64"> | |||
<style> | |||
{` | |||
.paused { | |||
animation-play-state: paused !important; | |||
background: #e1e4e8 !important; | |||
} | |||
img:not([src]) { | |||
content: url("data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="); | |||
border-radius: 6px; | |||
background: #FFF; | |||
border: 1px solid #e1e4e8; | |||
} | |||
p { | |||
display: block; | |||
opacity: 0; | |||
} | |||
.progress-bar { | |||
position: relative; | |||
width: 100%; | |||
height: 4px; | |||
margin: -1px; | |||
border: 1px solid #e1e4e8; | |||
border-radius: 4px; | |||
overflow: hidden; | |||
padding: 2px; | |||
z-index: 0; | |||
} | |||
#progress { | |||
position: absolute; | |||
top: -1px; | |||
left: 0; | |||
width: 100%; | |||
height: 6px; | |||
transform-origin: left center; | |||
background-color: #24292e; | |||
animation: progress ${duration}ms linear; | |||
animation-delay: -${progress}ms; | |||
} | |||
.progress-bar, | |||
#track, | |||
#artist, | |||
#cover { | |||
opacity: 0; | |||
animation: appear 300ms ease-out forwards; | |||
} | |||
#track { | |||
animation-delay: 400ms; | |||
} | |||
#artist { | |||
animation-delay: 500ms; | |||
} | |||
.progress-bar { | |||
animation-delay: 550ms; | |||
margin-top: 4px; | |||
} | |||
#cover { | |||
animation-name: cover-appear; | |||
animation-delay: 300ms; | |||
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 3px 10px rgba(0,0,0,0.05); | |||
} | |||
#cover:not([src]) { | |||
box-shadow: none; | |||
} | |||
@keyframes cover-appear { | |||
from { | |||
opacity: 0; | |||
transform: scale(0.8); | |||
} | |||
to { | |||
opacity: 1; | |||
transform: scale(1); | |||
} | |||
} | |||
@keyframes appear { | |||
from { | |||
opacity: 0; | |||
transform: translateX(-8px); | |||
} | |||
to { | |||
opacity: 1; | |||
transform: translateX(0); | |||
} | |||
} | |||
@keyframes progress { | |||
from { | |||
transform: scaleX(0) | |||
} | |||
to { | |||
transform: scaleX(1) | |||
} | |||
} | |||
`} | |||
</style> | |||
<div | |||
className={isPlaying ? "disabled" : ""} | |||
style={{ | |||
display: "flex", | |||
alignItems: "center", | |||
paddingTop: 8, | |||
paddingLeft: 4, | |||
}} | |||
> | |||
<img id="cover" src={cover ?? null} width="48" height="48" /> | |||
<div | |||
style={{ | |||
display: "flex", | |||
flex: 1, | |||
flexDirection: "column", | |||
marginTop: -4, | |||
marginLeft: 8, | |||
}} | |||
> | |||
<Text id="track" weight="bold"> | |||
{`${track ?? ""} `.trim()} | |||
</Text> | |||
<Text id="artist" color={!track ? "gray" : undefined}> | |||
{artist || "Nothing playing..."} | |||
</Text> | |||
{track && ( | |||
<div className="progress-bar"> | |||
<div id="progress" className={!isPlaying ? "paused" : ""} /> | |||
</div> | |||
)} | |||
</div> | |||
</div> | |||
</ReadmeImg> | |||
); | |||
}; |
@ -0,0 +1,27 @@ | |||
import React from "react"; | |||
const ReadmeImg = ({ width, height, children }) => { | |||
return ( | |||
<svg | |||
fill="none" | |||
width={width} | |||
height={height} | |||
viewBox={`0 0 ${width} ${height}`} | |||
xmlns="http://www.w3.org/2000/svg" | |||
> | |||
<foreignObject width={width} height={height}> | |||
<div {...{ xmlns: "http://www.w3.org/1999/xhtml" }}> | |||
<style>{` | |||
* { | |||
margin: 0; | |||
box-sizing: border-box; | |||
} | |||
`}</style> | |||
{children} | |||
</div> | |||
</foreignObject> | |||
</svg> | |||
); | |||
}; | |||
export default ReadmeImg; |
@ -0,0 +1,51 @@ | |||
import React from "react"; | |||
const sizes = { | |||
default: 14, | |||
small: 12, | |||
}; | |||
const colors = { | |||
default: "#24292e", | |||
"gray-light": "#e1e4e8", | |||
gray: "#586069", | |||
"gray-dark": "#24292e", | |||
}; | |||
const families = { | |||
default: | |||
"-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji", | |||
mono: "SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace", | |||
}; | |||
const weights = { | |||
default: 400, | |||
bold: 600, | |||
}; | |||
const Text: React.FC<any> = ({ | |||
children = "", | |||
weight = "default", | |||
family = "default", | |||
color = "default", | |||
size = "default", | |||
...props | |||
}) => { | |||
return ( | |||
<p | |||
style={{ | |||
whiteSpace: "pre", | |||
fontSize: `${sizes[size]}px`, | |||
lineHeight: 1.5, | |||
fontFamily: families[family], | |||
color: colors[color], | |||
fontWeight: weights[weight], | |||
}} | |||
{...props} | |||
> | |||
{children} | |||
</p> | |||
); | |||
}; | |||
export default Text; |
@ -0,0 +1,43 @@ | |||
{ | |||
"name": "novatorem", | |||
"private": true, | |||
"version": "1.0.0", | |||
"description": "Github profile", | |||
"main": "index.js", | |||
"dependencies": { | |||
"faunadb": "^2.14.2", | |||
"isomorphic-unfetch": "^3.0.0", | |||
"querystring": "^0.2.0", | |||
"react": "^16.13.1", | |||
"react-dom": "^16.13.1", | |||
"typescript": "^3.9.6" | |||
}, | |||
"devDependencies": { | |||
"@types/node": "^14.0.22", | |||
"@types/react": "^16.9.42", | |||
"@types/react-dom": "^16.9.8", | |||
"@vercel/node": "^1.7.2", | |||
"husky": "^4.2.5", | |||
"lint-staged": "^10.2.11", | |||
"prettier": "^2.0.5" | |||
}, | |||
"repository": { | |||
"type": "git", | |||
"url": "git+https://github.com/novatorem/novatorem.git" | |||
}, | |||
"keywords": [], | |||
"author": "Nate Moore", | |||
"license": "ISC", | |||
"bugs": { | |||
"url": "https://github.com/novatorem/novatorem/issues" | |||
}, | |||
"homepage": "https://github.com/novatorem/novatorem#readme", | |||
"husky": { | |||
"hooks": { | |||
"pre-commit": "lint-staged" | |||
} | |||
}, | |||
"lint-staged": { | |||
"*.{js,css,md}": "prettier --write" | |||
} | |||
} |
@ -0,0 +1,11 @@ | |||
{ | |||
"compilerOptions": { | |||
"module": "ES2015", | |||
"moduleResolution": "node", | |||
"target": "ES2017", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"skipLibCheck": true, | |||
"forceConsistentCasingInFileNames": true | |||
} | |||
} |
@ -0,0 +1,46 @@ | |||
import fetch from "isomorphic-unfetch"; | |||
import { stringify } from "querystring"; | |||
const { | |||
SPOTIFY_CLIENT_ID: client_id, | |||
SPOTIFY_CLIENT_SECRET: client_secret, | |||
SPOTIFY_REFRESH_TOKEN: refresh_token, | |||
} = process.env; | |||
const basic = Buffer.from(`${client_id}:${client_secret}`).toString("base64"); | |||
const Authorization = `Basic ${basic}`; | |||
async function getAuthorizationToken() { | |||
const url = new URL("https://accounts.spotify.com/api/token"); | |||
const body = stringify({ | |||
grant_type: "refresh_token", | |||
refresh_token, | |||
}); | |||
const response = await fetch(`${url}`, { | |||
method: "POST", | |||
headers: { | |||
Authorization, | |||
"Content-Type": "application/x-www-form-urlencoded", | |||
}, | |||
body, | |||
}).then((r) => r.json()); | |||
return `Bearer ${response.access_token}`; | |||
} | |||
const NOW_PLAYING_ENDPOINT = `https://api.spotify.com/v1/me/player/currently-playing`; | |||
export async function nowPlaying() { | |||
const Authorization = await getAuthorizationToken(); | |||
const response = await fetch(NOW_PLAYING_ENDPOINT, { | |||
headers: { | |||
Authorization, | |||
}, | |||
}); | |||
const { status } = response; | |||
if (status === 204) { | |||
return {}; | |||
} else if (status === 200) { | |||
const data = await response.json(); | |||
return data; | |||
} | |||
} |
@ -0,0 +1,4 @@ | |||
{ | |||
"version": 2, | |||
"rewrites": [{ "source": "/(.*)", "destination": "/api/$1" }] | |||
} |