Разработан веб-сервер и расширение для браузеров на базе Chromium

Этот коммит содержится в:
Глеб Иваницкий 2024-07-26 00:33:19 +03:00
Коммит beb1559cd4
14 изменённых файлов: 490 добавлений и 0 удалений

43
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();

23
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"
]
}

1
server/api/__init__.py Обычный файл
Просмотреть файл

@ -0,0 +1 @@
from .main import app

151
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)

3
server/config.ini Обычный файл
Просмотреть файл

@ -0,0 +1,3 @@
[Main]
host = 127.0.0.1
port = 8000

1
server/config/__init__.py Обычный файл
Просмотреть файл

@ -0,0 +1 @@
from .main import Main

43
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',
),
)

Двоичные данные
server/icon.ico Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 3.5 KiB

17
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)

6
server/requirements.txt Обычный файл
Просмотреть файл

@ -0,0 +1,6 @@
aiohttp
fastapi
jinja2
Nuitka
uvicorn
websockets

1
server/static/scripts/color-thief.umd.js Обычный файл

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

41
server/static/scripts/main.js Обычный файл
Просмотреть файл

@ -0,0 +1,41 @@
const colorThief = new ColorThief();
const musicElement = document.querySelector('#music');
const progressBarElement = musicElement.querySelector('#music > .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();

130
server/static/styles/main.css Обычный файл
Просмотреть файл

@ -0,0 +1,130 @@
@property --primary-color {
syntax: '<color>';
initial-value: white;
inherits: false;
}
@property --secondary-color {
syntax: '<color>';
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;
}

30
server/templates/overlay.jinja2 Обычный файл
Просмотреть файл

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="ru-RU">
<head>
<meta charset="UTF-8" />
<title></title>
<link rel="stylesheet" href="/static/styles/main.css" />
<style>
html {
justify-content: {{ justify_content }};
align-items: {{ align_items }};
}
</style>
<script src="/static/scripts/color-thief.umd.js"></script>
</head>
<body>
<div id="root">
<div id="music" class="hidden">
<span class="title">Сейчас играет</span>
<div class="progress-bar">
<img class="cover" />
<div class="data">
<span class="title"></span>
<span class="artists"></span>
</div>
</div>
</div>
</div>
</body>
<script src="/static/scripts/main.js"></script>
</html>