Разработан веб-сервер и расширение для браузеров на базе 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