Разработан веб-сервер и расширение для браузеров на базе Chromium
Этот коммит содержится в:
Коммит
beb1559cd4
43
extension/main.js
Обычный файл
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
Обычный файл
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
Обычный файл
1
server/api/__init__.py
Обычный файл
@ -0,0 +1 @@
|
|||||||
|
from .main import app
|
151
server/api/main.py
Обычный файл
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
Обычный файл
3
server/config.ini
Обычный файл
@ -0,0 +1,3 @@
|
|||||||
|
[Main]
|
||||||
|
host = 127.0.0.1
|
||||||
|
port = 8000
|
1
server/config/__init__.py
Обычный файл
1
server/config/__init__.py
Обычный файл
@ -0,0 +1 @@
|
|||||||
|
from .main import Main
|
43
server/config/main.py
Обычный файл
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
Обычный файл
Двоичные данные
server/icon.ico
Обычный файл
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 3.5 KiB |
17
server/main.py
Обычный файл
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
Обычный файл
6
server/requirements.txt
Обычный файл
@ -0,0 +1,6 @@
|
|||||||
|
aiohttp
|
||||||
|
fastapi
|
||||||
|
jinja2
|
||||||
|
Nuitka
|
||||||
|
uvicorn
|
||||||
|
websockets
|
1
server/static/scripts/color-thief.umd.js
Обычный файл
1
server/static/scripts/color-thief.umd.js
Обычный файл
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
41
server/static/scripts/main.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
Обычный файл
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
Обычный файл
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>
|
Загрузка…
Ссылка в новой задаче
Block a user