├── static ├── cache │ └── .gitignore ├── fetch.js ├── style.css ├── script.js └── particles.js ├── .vscode ├── extensions.json └── settings.json ├── serve.py ├── config.example.py ├── requirements.txt ├── .editorconfig ├── now-playing-cc.service ├── .gitignore ├── serve.service ├── setup.cfg ├── .pre-commit-config.yaml ├── README.md ├── templates └── index.html └── now-playing-cc.py /static/cache/.gitignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.jpg 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "mikestead.dotenv", 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /serve.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template 2 | app = Flask(__name__) 3 | 4 | 5 | @app.route('/') 6 | def hello_world(): 7 | return render_template('index.html') 8 | -------------------------------------------------------------------------------- /config.example.py: -------------------------------------------------------------------------------- 1 | # set chromecast name 2 | CHROMECAST = "chromecast name" 3 | DEBUG = False 4 | 5 | LAST_FM_API_KEYS = [] 6 | 7 | SPOTIFY_CLIENT_ID = '' 8 | SPOTIFY_CLIENT_SECRET = '' 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==3.7.5 2 | flask==3.1.0 3 | isort==4.3.4 4 | pre-commit==1.14.3 5 | PyChromecast==14.0.5 6 | requests==2.25.0 7 | seed-isort-config==1.5.0 8 | spotipy==2.25.0 9 | yapf==0.26.0 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.py] 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /now-playing-cc.service: -------------------------------------------------------------------------------- 1 | Description=NowPlayingCC 2 | 3 | [Service] 4 | ExecStart=/bin/bash -c '/home/pi/now-playing-cc/env/bin/python3 -u now-playing-cc.py' 5 | WorkingDirectory=/home/pi/now-playing-cc 6 | Restart=always 7 | User=pi 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | 4 | # Mac 5 | .DS_Store 6 | *~ 7 | *.swp 8 | *.swo 9 | 10 | # Python 11 | *.py[cod] 12 | __pycache__ 13 | 14 | # env 15 | .env 16 | 17 | # Distribution / packaging 18 | env/ 19 | 20 | # config 21 | config.py 22 | 23 | # data 24 | data.json 25 | 26 | # cache 27 | .cache 28 | -------------------------------------------------------------------------------- /serve.service: -------------------------------------------------------------------------------- 1 | Description=Serve 2 | 3 | [Service] 4 | Environment="FLASK_APP=serve.py" 5 | ExecStart=/bin/bash -c '/home/pi/now-playing-cc/env/bin/python3 -u /home/pi/now-playing-cc/env/bin/flask run --host=0.0.0.0' 6 | WorkingDirectory=/home/pi/now-playing-cc 7 | Restart=always 8 | User=pi 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = env 3 | max-line-length = 120 4 | 5 | [isort] 6 | include_trailing_comma = True 7 | multi_line_output = 3 8 | skip = env 9 | known_first_party = spot_check 10 | # known_third_party is populated automatically by seed-isort-config: 11 | known_third_party =pychromecast 12 | 13 | [yapf] 14 | based_on_style = pep8 15 | column_limit = 120 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.formatOnSave": true 4 | }, 5 | "python.formatting.provider": "yapf", 6 | "python.linting.enabled": true, 7 | "python.linting.lintOnSave": true, 8 | "python.linting.pylintEnabled": false, 9 | "python.linting.flake8Enabled": true, 10 | "python.linting.pycodestyleEnabled": false, 11 | "python.pythonPath": "env/bin/python" 12 | } 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: https://github.com/asottile/seed-isort-config 2 | rev: v1.5.0 3 | hooks: 4 | - id: seed-isort-config 5 | # args: [--application-directories=core] 6 | - repo: https://github.com/pre-commit/mirrors-isort 7 | rev: "v4.3.4" 8 | hooks: 9 | - id: isort 10 | args: [--check-only, --diff, --quiet] 11 | - repo: https://gitlab.com/pycqa/flake8 12 | rev: "3.7.5" 13 | hooks: 14 | - id: flake8 15 | args: [--config=setup.cfg] 16 | exclude: env 17 | - repo: https://github.com/pre-commit/mirrors-yapf 18 | rev: 'v0.26.0' 19 | hooks: 20 | - id: yapf 21 | args: [--diff] 22 | -------------------------------------------------------------------------------- /static/fetch.js: -------------------------------------------------------------------------------- 1 | // https://github.com/developit/unfetch 2 | self.fetch||(self.fetch=function(e,n){return n=n||{},new Promise(function(t,r){var s=new XMLHttpRequest;for(var o in s.open(n.method||"get",e,!0),n.headers)s.setRequestHeader(o,n.headers[o]);function u(){var e,n=[],t=[],r={};return s.getAllResponseHeaders().replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm,function(s,o,u){n.push(o=o.toLowerCase()),t.push([o,u]),r[o]=(e=r[o])?e+","+u:u}),{ok:2==(s.status/100|0),status:s.status,statusText:s.statusText,url:s.responseURL,clone:u,text:function(){return Promise.resolve(s.responseText)},json:function(){return Promise.resolve(s.responseText).then(JSON.parse)},blob:function(){return Promise.resolve(new Blob([s.response]))},headers:{keys:function(){return n},entries:function(){return t},get:function(e){return r[e.toLowerCase()]},has:function(e){return e.toLowerCase()in r}}}}s.withCredentials="include"==n.credentials,s.onload=function(){t(u())},s.onerror=r,s.send(n.body||null)})}); 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Now Playing - Chromecast 2 | 3 | Display in a web page details of the current Spotify/BBC 6music track that's playing on a Chromecast / Google Home on the same local network. 4 | 5 | 6 | ## Setup 7 | 8 | Clone this repo, e.g. onto a raspberry pi. 9 | 10 | 11 | Create a virtualenv: 12 | ``` 13 | virtualenv env --python=/path/to/python3 14 | ``` 15 | 16 | or 17 | 18 | ``` 19 | python -m venv env 20 | ``` 21 | 22 | Activate virtualenv: 23 | 24 | ``` 25 | source env/bin/activate 26 | ``` 27 | 28 | Install requirements: 29 | 30 | ``` 31 | pip install -r requirements.txt 32 | ``` 33 | 34 | Duplicate the `config.example.py` file, call it `config.py`, and add the name of the name of your Chromecast, plus last.fm API key(s) which are used to get the latest BBC 6music track and artwork. 35 | 36 | Start chromecast listener 37 | ``` 38 | python now-playing-cc.py 39 | ``` 40 | 41 | Start web server 42 | ``` 43 | FLASK_APP=serve.py flask run --host=0.0.0.0 44 | ``` 45 | 46 | The previous two commands could be created as services to start automatically when a Pi turns on: 47 | 48 | ``` 49 | sudo cp now-playing-cc.service /lib/systemd/system/ 50 | sudo cp serve.service /lib/systemd/system/ 51 | sudo systemctl enable now-playing-cc.service 52 | sudo systemctl enable serve.service 53 | ``` 54 | 55 | To manually start/stop these services, run: 56 | 57 | ``` 58 | sudo service now-playing-cc start 59 | sudo service now-playing-cc stop 60 | sudo service now-playing-cc status 61 | (etc) 62 | ``` 63 | 64 | Open http://localhost:5000 in a web browser to see it! 65 | 66 | 67 | --- 68 | 69 | ## Maintenance and support 70 | 71 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 72 | 73 | --- 74 | 75 | ## License 76 | 77 | This work is free. You can redistribute it and/or modify it under the 78 | terms of the Do What The Fuck You Want To Public License, Version 2, 79 | as published by Sam Hocevar. See http://www.wtfpl.net/ for more details. 80 | 81 | ``` 82 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 83 | Version 2, December 2004 84 | 85 | Copyright (C) 2004 Sam Hocevar 86 | 87 | Everyone is permitted to copy and distribute verbatim or modified 88 | copies of this license document, and changing it is allowed as long 89 | as the name is changed. 90 | 91 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 92 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 93 | 94 | 0. You just DO WHAT THE FUCK YOU WANT TO. 95 | 96 | ``` 97 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Now Playing 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

15 |

16 |
17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 | 46 |
47 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | /* elements */ 2 | 3 | *, 4 | *:before, 5 | *:after { 6 | box-sizing: border-box; 7 | } 8 | 9 | html { 10 | font-size: 20px; 11 | height: 100%; 12 | pointer-events: none; 13 | width: 100%; 14 | } 15 | 16 | @media (min-width: 767px) { 17 | html { 18 | font-size: 36px; 19 | } 20 | } 21 | 22 | body { 23 | background: #101030; 24 | color: #eee; 25 | font-family: Georgia, serif; 26 | height: 100%; 27 | margin: 0; 28 | width: 100%; 29 | } 30 | 31 | .Wrapper { 32 | padding: 1rem; 33 | position: relative; 34 | } 35 | 36 | /* Header */ 37 | 38 | .Header { 39 | opacity: 0.1; 40 | } 41 | 42 | .Header-title { 43 | font-size: 0.6rem; 44 | margin: 0; 45 | position: absolute; 46 | left: 0.5rem; 47 | top: 0.25rem; 48 | } 49 | 50 | .Header-subtitle { 51 | font-size: 0.5rem; 52 | margin: 0; 53 | position: absolute; 54 | right: 0.5rem; 55 | top: 0.25rem; 56 | } 57 | 58 | /* Debug */ 59 | 60 | .Debug { 61 | opacity: 0.2; 62 | } 63 | 64 | .Debug-datetime { 65 | bottom: 0rem; 66 | font-size: 0.6rem; 67 | margin: 0; 68 | position: absolute; 69 | left: 0.5rem; 70 | } 71 | 72 | .Debug-datetimeText { 73 | white-space: nowrap; 74 | } 75 | 76 | .Debug-datetimeTextRevealer { 77 | position: absolute; 78 | bottom: 0; 79 | left: 0; 80 | color: #f90; 81 | overflow: hidden; 82 | height: 100%; 83 | } 84 | 85 | /* Content */ 86 | 87 | .Content { 88 | align-items: center; 89 | display: flex; 90 | flex-direction: column; 91 | height: 100%; 92 | } 93 | 94 | @media (orientation: landscape) { 95 | .Content { 96 | flex-direction: row; 97 | } 98 | } 99 | 100 | /* Artwork */ 101 | 102 | .Artwork { 103 | align-items: center; 104 | display: flex; 105 | flex: 1; 106 | justify-content: center; 107 | margin-top: 1.5rem; 108 | perspective: 1000px; 109 | width: 100%; 110 | } 111 | 112 | @media (min-width: 800px) { 113 | .Artwork { 114 | flex: 2; 115 | margin-top: 0; 116 | width: inherit; 117 | } 118 | } 119 | 120 | .Artwork-container { 121 | border: 5px solid rgba(255, 255, 255, 0.2); 122 | /* height: 100%; */ 123 | max-width: 50vh; 124 | position: relative; 125 | width: 60%; 126 | 127 | /* animation: slow-move 10s ease-in-out alternate infinite; */ 128 | box-shadow: 0 0 25px 0 rgba(255,255,255,0.2); 129 | transform-style: preserve-3d; 130 | } 131 | 132 | @media (min-width: 800px) { 133 | .Artwork-container { 134 | max-width: 60vh; 135 | } 136 | } 137 | 138 | @keyframes slow-move { 139 | 0% { 140 | transform: rotateX(5deg) rotateY(25deg); 141 | } 142 | 143 | 100% { 144 | transform: rotateX(5deg) rotateY(-25deg); 145 | } 146 | } 147 | 148 | .Artwork-container::after { 149 | content: ""; 150 | display: block; 151 | padding-bottom: 100%; 152 | } 153 | 154 | .Artwork-canvas { 155 | background-color: #000; 156 | position: absolute; 157 | height: 100%; 158 | left: 0; 159 | top: 0; 160 | width: 100%; 161 | z-index: 1; 162 | } 163 | 164 | .Artwork-image { 165 | background: transparent no-repeat right bottom; 166 | background-size: cover; 167 | position: absolute; 168 | height: 100%; 169 | left: 0; 170 | top: 0; 171 | width: 100%; 172 | z-index: 2; 173 | } 174 | 175 | /* Info */ 176 | 177 | .Info { 178 | flex: 1; 179 | padding: 0 1.5rem; 180 | text-align: center; 181 | } 182 | 183 | 184 | @media (min-width: 800px) { 185 | .Info { 186 | padding: 1.5rem; 187 | text-align: left; 188 | } 189 | } 190 | 191 | .Info-label { 192 | display: block; 193 | font-size: 50%; 194 | } 195 | 196 | .Info-title, 197 | .Info-artist, 198 | .Info-album, 199 | .Info-release, 200 | .Info-playlist { 201 | margin: 0.5rem 0; 202 | } 203 | 204 | /* utility */ 205 | 206 | .u-willFade { 207 | opacity: 1; 208 | transition: opacity 0.5s; 209 | } 210 | 211 | .u-fadeOut { 212 | opacity: 0; 213 | } 214 | 215 | .u-revealer { 216 | animation: revealer 4800ms linear infinite; 217 | } 218 | 219 | @keyframes revealer { 220 | from { 221 | width: 0; 222 | } 223 | 224 | to { 225 | width: 100%; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var lastData = null; 3 | 4 | function poll() { 5 | fetch(`static/data.json?${Math.random()}`) 6 | .then(function(response) { return response.json(); }) 7 | .then(function(json) { updateDisplay(json); }) 8 | .catch(function(e) { handleError(e); }); 9 | 10 | setTimeout(poll, 5000); 11 | } 12 | 13 | function updateDisplay(data) { 14 | updateTitle(data.title); 15 | updateArtist(data.artist); 16 | updateAlbum(data.album_name); 17 | updateReleaseDate(data.release_date); 18 | updatePlaylist(data.playlist, data.album_name); 19 | updateDatetime(data.last_updated); 20 | updateIp(data.ip); 21 | updateState(data); 22 | updateArtwork(data); 23 | 24 | lastData = data; 25 | } 26 | 27 | function updateTitle(title) { 28 | updateFieldValue('title', title); 29 | } 30 | 31 | function updateArtist(artist) { 32 | updateFieldValue('artist', artist); 33 | } 34 | 35 | function updateAlbum(album_name) { 36 | updateFieldValue('album_name', album_name); 37 | } 38 | 39 | function updateIp(ip) { 40 | updateFieldValue('ip', ip); 41 | } 42 | 43 | function updateReleaseDate(release_date) { 44 | const el = document.querySelector('.Info-release'); 45 | if (!release_date) { 46 | el.classList.add('u-fadeOut'); 47 | } else { 48 | el.classList.remove('u-fadeOut'); 49 | updateFieldValue('release_date', release_date); 50 | } 51 | } 52 | 53 | function updatePlaylist(playlist, album_name) { 54 | const el = document.querySelector('.Info-playlist'); 55 | if (playlist === album_name) { 56 | el.classList.add('u-fadeOut'); 57 | } else { 58 | el.classList.remove('u-fadeOut'); 59 | updateFieldValue('playlist', playlist); 60 | } 61 | } 62 | 63 | function updateDatetime(last_updated) { 64 | updateFieldValue('last_updated', last_updated); 65 | const el = document.querySelector('.Debug-datetimeTextRevealer'); 66 | el.classList.add('u-revealer'); 67 | setTimeout(function() { 68 | el.classList.remove('u-revealer'); 69 | }, 4800); 70 | } 71 | 72 | function updateState(data) { 73 | updateFieldValue('player_state', data.player_state); 74 | const el = document.querySelector('.Info'); 75 | if (data.player_state === 'PLAYING' || data.player_state === 'BUFFERING') { 76 | el.classList.remove('u-fadeOut'); 77 | } else { 78 | el.classList.add('u-fadeOut'); 79 | } 80 | } 81 | 82 | function updateArtwork(data) { 83 | console.log(`Image: ${data.image || 'none'}`); 84 | const el = document.querySelector(`[data-field="image"]`); 85 | 86 | // same image as before, do nothing 87 | if (lastData && lastData.image && data.image === lastData.image) return; 88 | 89 | // no image, show particles 90 | if (!data || !data.image || data.image.length === 0) { 91 | el.classList.add('u-fadeOut'); 92 | particles.restart(); 93 | return; 94 | } 95 | 96 | // new image 97 | el.classList.add('u-fadeOut'); 98 | const val = data.image; 99 | setTimeout(function() { 100 | updateAndFadeInImageEl(el, val); 101 | }, 500); 102 | } 103 | 104 | function updateFieldValue(field, val) { 105 | console.log(`${field}: ${val}`); 106 | if (lastData && val === lastData[field]) return; 107 | var els = document.querySelectorAll(`[data-field="${field}"]`); 108 | [].forEach.call(els, function(el) { 109 | el.classList.add('u-fadeOut'); 110 | setTimeout(function() { 111 | updateAndFadeInTextEl(el, val); 112 | }, 500); 113 | }); 114 | } 115 | 116 | function updateAndFadeInTextEl(el, val) { 117 | el.textContent = val; 118 | el.classList.remove('u-fadeOut'); 119 | } 120 | 121 | function updateAndFadeInImageEl(el, val) { 122 | el.style.backgroundImage = `url(static/cache/${val})`; 123 | el.classList.remove('u-fadeOut'); 124 | particles.stop(); 125 | } 126 | 127 | function handleError(e) { 128 | console.log('ERROR', e); 129 | } 130 | 131 | particles.init(); 132 | poll(); 133 | })(); 134 | 135 | window.addEventListener("touchmove", function(event) { 136 | event.preventDefault(); 137 | }, false); 138 | -------------------------------------------------------------------------------- /static/particles.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Particles 3 | * https://github.com/orangespaceman/js-particles 4 | */ 5 | var particles = function() { 6 | 7 | "use strict"; 8 | 9 | /* 10 | * global variables 11 | */ 12 | var 13 | // HTML canvas element 14 | canvas, 15 | 16 | // canvas draw context 17 | ctx, 18 | 19 | // collection of existing particles 20 | particles = [], 21 | 22 | // run? 23 | isRunning = true, 24 | 25 | // configurable options 26 | config = { 27 | 28 | // number of particles to draw 29 | particleCount : 40, 30 | 31 | // minimum distance for each particle to affect another 32 | minimumAffectingDistance : 50 33 | }; 34 | 35 | /* 36 | * init 37 | */ 38 | function init () { 39 | drawCanvas(); 40 | createParticles(); 41 | loop(); 42 | 43 | // resize canvas on page resize 44 | window.addEventListener("resize", function (event) { 45 | drawCanvas(); 46 | }); 47 | } 48 | 49 | 50 | /* 51 | * start redraw loop logic 52 | */ 53 | function loop () { 54 | clear(); 55 | update(); 56 | draw(); 57 | queue(); 58 | } 59 | 60 | /* 61 | * wipe canvas ready for next redraw 62 | */ 63 | function clear () { 64 | if (!isRunning) return; 65 | ctx.clearRect(0, 0, canvas.width, canvas.height); 66 | } 67 | 68 | /* 69 | * update particle positions 70 | */ 71 | function update () { 72 | if (!isRunning) return; 73 | 74 | // update each particle's position 75 | for (var count = 0; count < particles.length; count++) { 76 | 77 | var p = particles[count]; 78 | 79 | // Change the velocities 80 | p.x += p.vx; 81 | p.y += p.vy; 82 | 83 | // Bounce a particle that hits the edge 84 | if(p.x + p.radius > canvas.width || p.x - p.radius < 0) { 85 | p.vx = -p.vx; 86 | } 87 | 88 | if(p.y + p.radius > canvas.height || p.y - p.radius < 0) { 89 | p.vy = -p.vy; 90 | } 91 | 92 | // Check particle attraction 93 | for (var next = count + 1; next < particles.length; next++) { 94 | var p2 = particles[next]; 95 | calculateDistanceBetweenParticles(p, p2); 96 | } 97 | } 98 | } 99 | 100 | /* 101 | * update visual state - draw each particle 102 | */ 103 | function draw () { 104 | if (!isRunning) return; 105 | for (var count = 0; count < particles.length; count++) { 106 | var p = particles[count]; 107 | p.draw(); 108 | } 109 | } 110 | 111 | /* 112 | * prepare next redraw when the browser is ready 113 | */ 114 | function queue () { 115 | window.requestAnimationFrame(loop); 116 | } 117 | 118 | function stop () { 119 | isRunning = false; 120 | clear(); 121 | } 122 | 123 | function restart () { 124 | if (!isRunning) { 125 | particles = []; 126 | createParticles(); 127 | isRunning = true; 128 | } 129 | } 130 | 131 | // go! 132 | return { 133 | init, 134 | stop, 135 | restart 136 | }; 137 | 138 | 139 | /* 140 | * Objects 141 | */ 142 | 143 | /* 144 | * Particle 145 | */ 146 | function Particle () { 147 | 148 | // Position particle 149 | this.x = Math.random() * canvas.width; 150 | this.y = Math.random() * canvas.height; 151 | 152 | // Give particle velocity, between -1 and 1 153 | this.vx = -1 + Math.random() * 2; 154 | this.vy = -1 + Math.random() * 2; 155 | 156 | // Give particle a radius 157 | this.radius = 4; 158 | 159 | // draw particle 160 | this.draw = function () { 161 | ctx.fillStyle = "white"; 162 | ctx.beginPath(); 163 | ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false); 164 | ctx.fill(); 165 | } 166 | } 167 | 168 | /* 169 | * Draw canvas 170 | */ 171 | function drawCanvas () { 172 | canvas = document.querySelector("canvas"); 173 | ctx = canvas.getContext("2d"); 174 | 175 | // set canvas to full page dimensions 176 | // canvas.width = window.innerWidth; 177 | // canvas.height = window.innerHeight; 178 | canvas.width = canvas.offsetWidth; 179 | canvas.height = canvas.offsetHeight; 180 | } 181 | 182 | /* 183 | * Create particles 184 | */ 185 | function createParticles () { 186 | for(var i = 0; i < config.particleCount; i++) { 187 | particles.push(new Particle()); 188 | } 189 | } 190 | 191 | 192 | /* 193 | * Distance calculator between two particles 194 | */ 195 | function calculateDistanceBetweenParticles (p1, p2) { 196 | 197 | var dist, 198 | dx = p1.x - p2.x, 199 | dy = p1.y - p2.y; 200 | 201 | dist = Math.sqrt(dx*dx + dy*dy); 202 | 203 | // Check whether distance is smaller than the min distance 204 | if(dist <= config.minimumAffectingDistance) { 205 | 206 | // set line opacity 207 | var opacity = 1 - dist/config.minimumAffectingDistance; 208 | 209 | // Draw connecting line 210 | ctx.beginPath(); 211 | ctx.strokeStyle = "rgba(255, 255, 255, " + opacity +")"; 212 | ctx.moveTo(p1.x, p1.y); 213 | ctx.lineTo(p2.x, p2.y); 214 | ctx.stroke(); 215 | ctx.closePath(); 216 | 217 | // Calculate particle acceleration 218 | var ax = dx / 2000, 219 | ay = dy / 2000; 220 | 221 | // Apply particle acceleration 222 | p1.vx -= ax; 223 | p1.vy -= ay; 224 | 225 | p2.vx += ax; 226 | p2.vy += ay; 227 | } 228 | } 229 | }(); 230 | -------------------------------------------------------------------------------- /now-playing-cc.py: -------------------------------------------------------------------------------- 1 | try: 2 | import config 3 | except Exception: 4 | print("You must create a config.py file") 5 | import sys 6 | 7 | sys.exit(0) 8 | 9 | import sys 10 | import datetime 11 | import itertools 12 | import json 13 | import os 14 | import shutil 15 | import time 16 | import requests 17 | import socket 18 | 19 | import spotipy 20 | from spotipy.oauth2 import SpotifyClientCredentials 21 | 22 | import pychromecast 23 | from pychromecast.controllers.media import MediaStatusListener 24 | from pychromecast.controllers.receiver import CastStatusListener 25 | 26 | 27 | class NowPlayingListener: 28 | data_fields = [ 29 | "title", 30 | "artist", 31 | "album_name", 32 | "playlist", 33 | "release_date", 34 | "player_state", 35 | ] 36 | 37 | cache_dir = "./static/cache/" 38 | 39 | def __init__(self, name, cast): 40 | self.name = name 41 | self.cast = cast 42 | 43 | def handle_data(self): 44 | refresh_manually = False 45 | data = {} 46 | self.update_data(data, self.cast.status) 47 | self.update_data(data, self.cast.media_controller.status) 48 | 49 | if hasattr( 50 | self.cast.media_controller.status, "player_state" 51 | ) and self.cast.media_controller.status.player_state in [ 52 | "PLAYING", 53 | "BUFFERING", 54 | ]: 55 | if any( 56 | station in ["Radio 6 Music", "BBC Radio 6 Music"] 57 | for station in [data["playlist"], data["title"]] 58 | ): 59 | self.check_6music_state(data) 60 | refresh_manually = True 61 | elif "FIP" in [data["title"], data["playlist"]] or ( 62 | "Radio France" in self.cast.status.display_name 63 | ): 64 | self.check_fip_state(data) 65 | refresh_manually = True 66 | 67 | self.request_release_date(data, self.cast.media_controller.status) 68 | self.update_json(data) 69 | 70 | if refresh_manually: 71 | self.debug("manually refreshing", "-") 72 | time.sleep(60) 73 | self.handle_data() 74 | 75 | def update_data(self, data, chromecast_data): 76 | self.debug("Chromcast Data: ", chromecast_data) 77 | 78 | for data_field in self.data_fields: 79 | if hasattr(chromecast_data, data_field): 80 | data[data_field] = getattr(chromecast_data, data_field) 81 | 82 | if hasattr(chromecast_data, "images") and len(chromecast_data.images) > 0: 83 | image_url = chromecast_data.images[0].url 84 | file_name = "{}.jpg".format(image_url.split("/")[-1]) 85 | data["image"] = file_name 86 | self.cache_image(image_url, file_name) 87 | 88 | if hasattr(chromecast_data, "media_custom_data") and self.has_key_deep( 89 | chromecast_data.media_custom_data, 90 | "playerPlaybackState", 91 | "context", 92 | "metadata", 93 | "context_description", 94 | ): 95 | data["playlist"] = chromecast_data.media_custom_data["playerPlaybackState"][ 96 | "context" 97 | ]["metadata"]["context_description"] 98 | else: 99 | data["playlist"] = None 100 | 101 | def request_release_date(self, data, chromecast_data): 102 | if data["title"] == "" or data["artist"] == "" or data["album_name"] == "": 103 | return 104 | 105 | if ( 106 | hasattr(chromecast_data, "content_id") 107 | and chromecast_data.content_id 108 | and "spotify:track:" in chromecast_data.content_id 109 | ): 110 | try: 111 | sp = spotipy.Spotify( 112 | auth_manager=SpotifyClientCredentials( 113 | client_id=config.SPOTIFY_CLIENT_ID, 114 | client_secret=config.SPOTIFY_CLIENT_SECRET, 115 | ) 116 | ) 117 | 118 | track = sp.track(chromecast_data.content_id) 119 | if self.has_key_deep(track, "album", "release_date"): 120 | try: 121 | date_string = track["album"]["release_date"] 122 | date_precision = track["album"]["release_date_precision"] 123 | if date_precision == "year": 124 | data["release_date"] = date_string 125 | elif date_precision == "month": 126 | date_obj = datetime.datetime.strptime(date_string, "%Y-%m") 127 | data["release_date"] = date_obj.strftime("%B %Y") 128 | else: 129 | date_obj = datetime.datetime.strptime( 130 | date_string, "%Y-%m-%d" 131 | ) 132 | data["release_date"] = date_obj.strftime("%-d %B %Y") 133 | except Exception as e: 134 | self.debug("Date exception", e) 135 | except Exception as e: 136 | self.debug("Spotify API exception", e) 137 | 138 | def check_6music_state(self, data): 139 | self.debug("checking 6music state!", "-") 140 | 141 | current_track = self.request_6music_data() 142 | 143 | if ( 144 | current_track is None 145 | or "error" in current_track 146 | or "data" not in current_track 147 | ): 148 | self.debug("no current 6music track!", "-") 149 | return 150 | 151 | image_url = current_track["data"][0]["image_url"].replace("{recipe}", "640x640") 152 | file_name = "{}.jpg".format(image_url.split("/")[-1]) 153 | data["image"] = file_name 154 | self.cache_image(image_url, file_name) 155 | 156 | data["playlist"] = data["title"] 157 | data["album_name"] = "" 158 | data["artist"] = current_track["data"][0]["titles"]["primary"] 159 | data["title"] = current_track["data"][0]["titles"]["secondary"] 160 | 161 | def request_6music_data(self): 162 | request = ( 163 | "https://rms.api.bbc.co.uk/v2/services/bbc_6music/segments/latest?limit=1" 164 | ) 165 | try: 166 | response = requests.get(request) 167 | self.debug("6music response", response.json()) 168 | return response.json() 169 | except requests.exceptions.RequestException as e: 170 | self.debug("Requests exception", e) 171 | 172 | def check_fip_state(self, data): 173 | self.debug("checking fip state!", "-") 174 | 175 | current_track = self.request_fip_data() 176 | 177 | if ( 178 | current_track is None 179 | or "error" in current_track 180 | or "now" not in current_track 181 | ): 182 | self.debug("no current fip track!", "-") 183 | return 184 | 185 | image_url = current_track["now"]["visuals"]["card"]["src"] 186 | file_name = "{}.jpg".format(image_url.split("/")[-1]) 187 | data["image"] = file_name 188 | self.cache_image(image_url, file_name) 189 | 190 | data["playlist"] = data["title"] if data["title"] is not None else "FIP" 191 | data["album_name"] = current_track["now"]["song"]["release"]["title"] 192 | data["release_date"] = current_track["now"]["song"]["year"] 193 | data["artist"] = current_track["now"]["secondLine"]["title"] 194 | data["title"] = current_track["now"]["firstLine"]["title"] 195 | 196 | def request_fip_data(self): 197 | request = "https://www.radiofrance.fr/fip/api/live" 198 | try: 199 | response = requests.get(request) 200 | self.debug("Fip response", response.json()) 201 | return response.json() 202 | except requests.exceptions.RequestException as e: 203 | self.debug("Requests exception", e) 204 | 205 | def cache_image(self, url, file_name): 206 | file_path = "{}{}".format(self.cache_dir, file_name) 207 | if os.path.isfile(file_path): 208 | return 209 | 210 | self.debug("caching image", file_name) 211 | 212 | response = requests.get(url, stream=True) 213 | with open("{}{}".format(self.cache_dir, file_name), "wb") as out_file: 214 | shutil.copyfileobj(response.raw, out_file) 215 | del response 216 | 217 | def update_json(self, data): 218 | data["last_updated"] = str(datetime.datetime.now()).split(".")[0] 219 | data["ip"] = self.get_ip() 220 | with open("./static/data.json", "w") as outfile: 221 | json.dump(data, outfile, ensure_ascii=False, indent=4, sort_keys=True) 222 | self.debug("JSON file updated", data) 223 | 224 | def get_ip(self): 225 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 226 | s.settimeout(0) 227 | try: 228 | s.connect(("10.254.254.254", 1)) 229 | IP = s.getsockname()[0] 230 | except Exception: 231 | IP = "127.0.0.1" 232 | finally: 233 | s.close() 234 | return IP 235 | 236 | def has_key_deep(self, dict, *names): 237 | for name in names: 238 | if name not in dict: 239 | return False 240 | dict = dict[name] 241 | return True 242 | 243 | def debug(self, title, message): 244 | if config.DEBUG: 245 | print("{}:\n{}".format(title, message)) 246 | print("\n-------\n") 247 | 248 | 249 | class MyCastStatusListener(CastStatusListener, NowPlayingListener): 250 | def new_cast_status(self, status): 251 | self.handle_data() 252 | 253 | 254 | class MyMediaStatusListener(MediaStatusListener, NowPlayingListener): 255 | def new_media_status(self, status): 256 | try: 257 | self.handle_data() 258 | except Exception as e: 259 | self.debug("Exception in media status callback", e) 260 | 261 | def load_media_failed(self, item, error_code): 262 | try: 263 | self.handle_data() 264 | except Exception as e: 265 | self.debug("Exception in media load failure", e) 266 | 267 | 268 | class NowPlayingCC: 269 | def __init__(self): 270 | self.init_chromecast() 271 | 272 | def init_chromecast(self): 273 | chromecasts, browser = pychromecast.get_listed_chromecasts( 274 | friendly_names=[config.CHROMECAST], 275 | ) 276 | 277 | if not chromecasts: 278 | print(f'No chromecast with name "{config.CHROMECAST}" discovered') 279 | sys.exit(1) 280 | 281 | chromecast = chromecasts[0] 282 | chromecast.wait() 283 | 284 | print(f'Found chromecast with name "{config.CHROMECAST}"') 285 | 286 | listenerCast = MyCastStatusListener(chromecast.name, chromecast) 287 | chromecast.register_status_listener(listenerCast) 288 | 289 | listenerMedia = MyMediaStatusListener(chromecast.name, chromecast) 290 | chromecast.media_controller.register_status_listener(listenerMedia) 291 | 292 | browser.stop_discovery() 293 | 294 | while True: 295 | pass 296 | 297 | 298 | np = NowPlayingCC() 299 | --------------------------------------------------------------------------------