commit beb1559cd4cc030e00afe2126d991df5376ecf25 Author: Gleb O. Ivaniczkij Date: Fri Jul 26 00:33:19 2024 +0300 Разработан веб-сервер и расширение для браузеров на базе Chromium diff --git a/extension/main.js b/extension/main.js new file mode 100644 index 0000000..c2b87d2 --- /dev/null +++ b/extension/main.js @@ -0,0 +1,43 @@ +const mutationObserverConfig = { + attributes: true, + childList: false, + characterData: false, +}; + +const connectWebSocket = () => { + const ws = new WebSocket('ws://localhost:8000/ws/v1/plugin'); + const progressElement = document.querySelector('#progress-bar'); + const mutationObserver = new MutationObserver(() => { + const promise = new Promise(resolve => { + const playerBarElement = document.querySelector('ytmusic-player-bar'); + const metadataElement = playerBarElement.querySelector('.middle-controls > .content-info-wrapper'); + const titleElement = metadataElement.querySelector(`yt-formatted-string.title`); + const subtitleElement = metadataElement.querySelector('span.subtitle > yt-formatted-string.byline'); + const imageElement = playerBarElement.querySelector('img'); + if (titleElement.textContent === '' || subtitleElement.length === 0) return; + const data = { + type: 'music', + attributes: { + title: titleElement.textContent, + artists: subtitleElement.title.split('•')[0].trim(), + progress: progressElement.ariaValueNow / progressElement.ariaValueMax, + image: imageElement.src, + }, + }; + ws.send(JSON.stringify(data)); + resolve(); + }); + promise.then(); + }); + + ws.onopen = () => { + mutationObserver.observe(progressElement, mutationObserverConfig); + }; + + ws.onclose = () => { + mutationObserver.disconnect(); + connectWebSocket(); + }; +}; + +connectWebSocket(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..68fbd9a --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "YT Music Live", + "description": "YT Music Live", + "version": "1.0", + "manifest_version": 3, + "action": { + "default_icon": "icon.png" + }, + "content_scripts": [ + { + "js": [ + "main.js" + ], + "matches": [ + "*://music.youtube.com/*" + ] + } + ], + "permissions": [ + "activeTab", + "scripting" + ] +} diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 0000000..e4c7aa5 --- /dev/null +++ b/server/api/__init__.py @@ -0,0 +1 @@ +from .main import app diff --git a/server/api/main.py b/server/api/main.py new file mode 100644 index 0000000..9ebfbe4 --- /dev/null +++ b/server/api/main.py @@ -0,0 +1,151 @@ +import asyncio +import hashlib +import os + +import aiohttp +from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect +from fastapi.exceptions import HTTPException +from fastapi.responses import Response, HTMLResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from jinja2 import Environment, FileSystemLoader + +import config + + +app = FastAPI() +app.mount( + path='/static', + app=StaticFiles( + directory=os.path.join( + config.Main.working_directory, + 'static', + ), + ), +) + +env = Environment( + loader=FileSystemLoader( + searchpath=os.path.join( + '.', + 'templates', + ), + ), + enable_async=True, +) + + +class ConnectionManager: + def __init__(self): + self.connections: list[WebSocket] = list[WebSocket]() + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.connections.remove(websocket) + + async def broadcast(self, data: dict): + for connection in self.connections: + asyncio.ensure_future(connection.send_json(data)) + + +overlay_manager = ConnectionManager() +plugin_manager = ConnectionManager() + + +@app.options( + path='/api/v1/overlay', +) +async def function(): + return Response( + headers={ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + 'Access-Control-Allow-Headers': '*', + }, + status_code=200, + ) + + +@app.get( + path='/overlay', +) +async def function( + justify_content: str = Query( + default='end', + alias='justify-content', + ), + align_items: str = Query( + default='end', + alias='align-items', + ), +): + template = env.get_template('overlay.jinja2') + return HTMLResponse( + content=await template.render_async( + justify_content=justify_content, + align_items=align_items, + ), + status_code=200, + ) + + +@app.get( + path='/image', +) +async def function( + src: str = Query( + default=..., + ), +): + image_name = hashlib.sha1(src.encode('ascii')).hexdigest() + image_path = os.path.join( + config.Main.images_directory, + image_name, + ) + if not os.path.exists(image_path): + os.makedirs( + name=config.Main.images_directory, + exist_ok=True, + ) + async with aiohttp.ClientSession() as session: + async with session.get(src) as response: + if not response.ok: + raise HTTPException( + status_code=response.status, + ) + with open( + file=image_path, + mode='wb', + ) as file: + file.write(await response.read()) + return FileResponse( + path=image_path, + ) + + +@app.websocket('/ws/v1/overlay') +async def function( + websocket: WebSocket, +): + await overlay_manager.connect(websocket) + try: + while True: + data = await websocket.receive_json() + await plugin_manager.broadcast(data) + except WebSocketDisconnect: + overlay_manager.disconnect(websocket) + + +@app.websocket('/ws/v1/plugin') +async def function( + websocket: WebSocket, +): + await plugin_manager.connect(websocket) + try: + while True: + data = await websocket.receive_json() + await overlay_manager.broadcast(data) + except WebSocketDisconnect: + plugin_manager.disconnect(websocket) diff --git a/server/config.ini b/server/config.ini new file mode 100644 index 0000000..c11d3d2 --- /dev/null +++ b/server/config.ini @@ -0,0 +1,3 @@ +[Main] +host = 127.0.0.1 +port = 8000 diff --git a/server/config/__init__.py b/server/config/__init__.py new file mode 100644 index 0000000..0dbf581 --- /dev/null +++ b/server/config/__init__.py @@ -0,0 +1 @@ +from .main import Main diff --git a/server/config/main.py b/server/config/main.py new file mode 100644 index 0000000..84cf58d --- /dev/null +++ b/server/config/main.py @@ -0,0 +1,43 @@ +from configparser import RawConfigParser +from enum import Enum +import os + + +config = RawConfigParser() +config.optionxform = str +config.read( + filenames='./config.ini', +) + + +class Section(Enum): + main = 'Main' + + +class Main: + host = config.get( + section=Section.main.value, + option='host', + fallback='127.0.0.1', + ) + port = config.getint( + section=Section.main.value, + option='port', + fallback=8000, + ) + working_directory = config.get( + section=Section.main.value, + option='working_directory', + fallback=os.getcwd(), + ) + images_directory = config.get( + section=Section.main.value, + option='images_directory', + fallback=os.path.join( + os.getenv('APPDATA'), + 'csasq', + 'YT Music Live', + 'cache', + 'covers', + ), + ) diff --git a/server/icon.ico b/server/icon.ico new file mode 100644 index 0000000..6586b72 Binary files /dev/null and b/server/icon.ico differ diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..3a4356d --- /dev/null +++ b/server/main.py @@ -0,0 +1,17 @@ +import shutil + +import uvicorn + +from api import app +import config + + +if __name__ == '__main__': + try: + uvicorn.run( + app=app, + host=config.Main.host, + port=config.Main.port, + ) + except InterruptedError: + shutil.rmtree(config.Main.images_directory) diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..9429783 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,6 @@ +aiohttp +fastapi +jinja2 +Nuitka +uvicorn +websockets diff --git a/server/static/scripts/color-thief.umd.js b/server/static/scripts/color-thief.umd.js new file mode 100644 index 0000000..705e1de --- /dev/null +++ b/server/static/scripts/color-thief.umd.js @@ -0,0 +1 @@ +!function(t,r){"object"==typeof exports&&"undefined"!=typeof module?module.exports=r():"function"==typeof define&&define.amd?define(r):(t||self).ColorThief=r()}(this,function(){if(!t)var t={map:function(t,r){var n={};return r?t.map(function(t,o){return n.index=o,r.call(n,t)}):t.slice()},naturalOrder:function(t,r){return tr?1:0},sum:function(t,r){var n={};return t.reduce(r?function(t,o,e){return n.index=e,t+r.call(n,o)}:function(t,r){return t+r},0)},max:function(r,n){return Math.max.apply(null,n?t.map(r,n):r)}};var r=function(){var r=5,n=8-r,o=1e3;function e(t,n,o){return(t<<2*r)+(n<l/2){for(e=n.copy(),i=n.copy(),u=(r=a-n[c])<=(o=n[f]-a)?Math.min(n[f]-1,~~(a+o/2)):Math.max(n[c],~~(a-1-r/2));!h[u];)u++;for(s=v[u];!s&&h[u-1];)s=v[--u];return e[f]=u,i[c]=e[f]+1,[e,i]}}(u==o?"r":u==i?"g":"b")}}return u.prototype={volume:function(t){var r=this;return r._volume&&!t||(r._volume=(r.r2-r.r1+1)*(r.g2-r.g1+1)*(r.b2-r.b1+1)),r._volume},count:function(t){var r=this,n=r.histo;if(!r._count_set||t){var o,i,u,a=0;for(o=r.r1;o<=r.r2;o++)for(i=r.g1;i<=r.g2;i++)for(u=r.b1;u<=r.b2;u++)a+=n[e(o,i,u)]||0;r._count=a,r._count_set=!0}return r._count},copy:function(){var t=this;return new u(t.r1,t.r2,t.g1,t.g2,t.b1,t.b2,t.histo)},avg:function(t){var n=this,o=n.histo;if(!n._avg||t){var i,u,a,c,f=0,s=1<<8-r,l=0,h=0,v=0;for(u=n.r1;u<=n.r2;u++)for(a=n.g1;a<=n.g2;a++)for(c=n.b1;c<=n.b2;c++)f+=i=o[e(u,a,c)]||0,l+=i*(u+.5)*s,h+=i*(a+.5)*s,v+=i*(c+.5)*s;n._avg=f?[~~(l/f),~~(h/f),~~(v/f)]:[~~(s*(n.r1+n.r2+1)/2),~~(s*(n.g1+n.g2+1)/2),~~(s*(n.b1+n.b2+1)/2)]}return n._avg},contains:function(t){var r=this,o=t[0]>>n;return gval=t[1]>>n,bval=t[2]>>n,o>=r.r1&&o<=r.r2&&gval>=r.g1&&gval<=r.g2&&bval>=r.b1&&bval<=r.b2}},a.prototype={push:function(t){this.vboxes.push({vbox:t,color:t.avg()})},palette:function(){return this.vboxes.map(function(t){return t.color})},size:function(){return this.vboxes.size()},map:function(t){for(var r=this.vboxes,n=0;n251&&e[1]>251&&e[2]>251&&(r[o].color=[255,255,255])}},{quantize:function(f,s){if(!f.length||s<2||s>256)return!1;var l=function(t){var o,i=new Array(1<<3*r);return t.forEach(function(t){o=e(t[0]>>n,t[1]>>n,t[2]>>n),i[o]=(i[o]||0)+1}),i}(f);l.forEach(function(){});var h=function(t,r){var o,e,i,a=1e6,c=0,f=1e6,s=0,l=1e6,h=0;return t.forEach(function(t){(o=t[0]>>n)c&&(c=o),(e=t[1]>>n)s&&(s=e),(i=t[2]>>n)h&&(h=i)}),new u(a,c,f,s,l,h,r)}(f,l),v=new i(function(r,n){return t.naturalOrder(r.count(),n.count())});function g(t,r){for(var n,e=t.size(),i=0;i=r)return;if(i++>o)return;if((n=t.pop()).count()){var u=c(l,n),a=u[0],f=u[1];if(!a)return;t.push(a),f&&(t.push(f),e++)}else t.push(n),i++}}v.push(h),g(v,.75*s);for(var p=new i(function(r,n){return t.naturalOrder(r.count()*r.volume(),n.count()*n.volume())});v.size();)p.push(v.pop());g(p,s);for(var b=new a;p.size();)b.push(p.pop());return b}}}().quantize,n=function(t){this.canvas=document.createElement("canvas"),this.context=this.canvas.getContext("2d"),this.width=this.canvas.width=t.naturalWidth,this.height=this.canvas.height=t.naturalHeight,this.context.drawImage(t,0,0,this.width,this.height)};n.prototype.getImageData=function(){return this.context.getImageData(0,0,this.width,this.height)};var o=function(){};return o.prototype.getColor=function(t,r){return void 0===r&&(r=10),this.getPalette(t,5,r)[0]},o.prototype.getPalette=function(t,o,e){var i=function(t){var r=t.colorCount,n=t.quality;if(void 0!==r&&Number.isInteger(r)){if(1===r)throw new Error("colorCount should be between 2 and 20. To get one color, call getColor() instead of getPalette()");r=Math.max(r,2),r=Math.min(r,20)}else r=10;return(void 0===n||!Number.isInteger(n)||n<1)&&(n=10),{colorCount:r,quality:n}}({colorCount:o,quality:e}),u=new n(t),a=function(t,r,n){for(var o,e,i,u,a,c=t,f=[],s=0;s=125)&&(e>250&&i>250&&u>250||f.push([e,i,u]));return f}(u.getImageData().data,u.width*u.height,i.quality),c=r(a,i.colorCount);return c?c.palette():null},o.prototype.getColorFromUrl=function(t,r,n){var o=this,e=document.createElement("img");e.addEventListener("load",function(){var i=o.getPalette(e,5,n);r(i[0],t)}),e.src=t},o.prototype.getImageData=function(t,r){var n=new XMLHttpRequest;n.open("GET",t,!0),n.responseType="arraybuffer",n.onload=function(){if(200==this.status){var t=new Uint8Array(this.response);i=t.length;for(var n=new Array(i),o=0;o .progress-bar'); +const titleElement = progressBarElement.querySelector('#music > .progress-bar > .data > .title'); +const artistsElement = progressBarElement.querySelector('#music > .progress-bar > .data > .artists'); +const coverElement = progressBarElement.querySelector('#music > .progress-bar > .cover'); + +const handlers = { + 'target': (attributes) => { + const target = document.getElementById(attributes.id); + target.style.setProperty('--progress-size', `${attributes.progress}`); + }, + 'music': (attributes) => { + titleElement.textContent = attributes.title; + artistsElement.textContent = attributes.artists; + musicElement.style.setProperty('--progress-size', `${(1 - attributes.progress) * 100}%`); + const url = new URL(location); + url.pathname = '/image'; + const urlSearchParams = new URLSearchParams(); + urlSearchParams.set('src', attributes.image); + url.search = urlSearchParams.toString(); + coverElement.src = url; + const colors = colorThief.getPalette(coverElement, 2); + progressBarElement.style.setProperty('--primary-color', `rgb(${colors[0][0]}, ${colors[0][1]}, ${colors[0][2]})`); + progressBarElement.style.setProperty('--secondary-color', `rgb(${colors[1][0]}, ${colors[1][1]}, ${colors[1][2]})`); + musicElement.classList.remove('hidden'); + }, +}; + +const connectWebSocket = () => { + const ws = new WebSocket('ws://localhost:8000/ws/v1/overlay'); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + handlers[data.type](data.attributes); + }; + + ws.onclose = () => connectWebSocket(); +}; + +connectWebSocket(); diff --git a/server/static/styles/main.css b/server/static/styles/main.css new file mode 100644 index 0000000..f990f6d --- /dev/null +++ b/server/static/styles/main.css @@ -0,0 +1,130 @@ +@property --primary-color { + syntax: ''; + initial-value: white; + inherits: false; +} + +@property --secondary-color { + syntax: ''; + initial-value: white; + inherits: false; +} + +:root { + --text-shadow: 0 0 12px rgba(0, 0, 0, 0.4); + --box-shadow: 0 .5rem .75rem .375rem rgba(0, 0, 0, .15), 0 .25rem .25rem rgba(0, 0, 0, .3); +} + +* { + font-family: 'Exo 2', sans-serif; + user-select: none; +} + +html { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +body { + padding: 2rem; +} + +#root { + display: flex; + flex-direction: column; + align-items: center; +} + +#root > * { + width: 30rem; +} + +#root > *:not(:first-child) { + margin-top: 2rem; +} + +#root > *.hidden { + display: none; +} + +#root > * > .title { + margin-bottom: .5rem; + color: white; + text-shadow: var(--text-shadow); + font-size: 1.5rem; + font-weight: 600; +} + +#music { + display: flex; + flex-direction: column; + --progress: 100%; +} + +#music > .progress-bar { + position: relative; + display: flex; + align-items: center; + margin-top: .5rem; + padding: 1rem; + width: 28rem; + background-image: linear-gradient(225deg, var(--primary-color), var(--secondary-color)); + box-shadow: var(--box-shadow); + border-radius: .5rem; + overflow: hidden; + transition: + opacity .4s, + --primary-color 1s, + --secondary-color 1s; + --progress-size: inherit; + --progress-color: inherit; +} + +#music > .progress-bar::before { + content: ''; + position: absolute; + top: 0; + right: var(--progress-size); + bottom: 0; + left: 0; + background-color: rgba(0, 0, 0, .3); + transition: + right 1s, + background-color 4s; +} + +#music > .progress-bar > .data { + display: flex; + flex-direction: column; + margin-left: 1rem; + overflow-x: hidden; + z-index: 1; +} + +#music > .progress-bar > .data > .title, +#music > .progress-bar > .data > .artists { + color: white; + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; +} + +#music > .progress-bar > .data > .title { + font-size: 1.2rem; + font-weight: 600; +} + +#music > .progress-bar > .data > .artists { + font-size: 1rem; + font-weight: 400; +} + +#music > .progress-bar > .cover { + flex-shrink: 0; + width: 3rem; + height: 3rem; + object-fit: cover; + z-index: 1; +} diff --git a/server/templates/overlay.jinja2 b/server/templates/overlay.jinja2 new file mode 100644 index 0000000..68ab9f7 --- /dev/null +++ b/server/templates/overlay.jinja2 @@ -0,0 +1,30 @@ + + + + + + + + + + +
+ +
+ + +