Compare commits

..

10 Commits

Author SHA1 Message Date
a280495e09 Незначительные правки 2024-07-29 11:50:41 +03:00
5d48dd8d95 Добавлена ссылка на расширение в интернет-магазине Chrome 2024-07-29 11:41:30 +03:00
a15dcf5ce6 Добавлен раздел «Снимки экрана» 2024-07-28 22:14:12 +03:00
c820e2ad3d Создан файл 2024-07-28 22:04:56 +03:00
b338074ce4 Удалены лишние разрешения расширения 2024-07-28 17:23:09 +03:00
c176e57ccf Изменен фон всплывающего окна расширения 2024-07-28 02:44:14 +03:00
d3d59c2e61 Исправлена опечатка в названии ключа URL сервера по умолчанию 2024-07-28 02:21:26 +03:00
f414964a06 В качестве хоста по умолчанию установлен localhost 2024-07-28 01:52:52 +03:00
5b4a8c767e Добавлено динамическое построение URL для подключения к WebSocket 2024-07-28 01:49:21 +03:00
333ae3d9d2 Добавлены настройки расширения, введена поддержка локализации, изменены и адаптированы иконки приложений 2024-07-27 23:39:28 +03:00
27 changed files with 768 additions and 128 deletions

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# YT Music Live
Используйте расширение для браузера Chrome вместе с приложением для Windows, чтобы запустить виджет. Его можно встроить в программу для проведения прямых эфиров (например, OBS Studio).
Виджет отображает текущее состояние воспроизведения песни на сайте YouTube Music:
- название песни,
- имена исполнителей,
- позиция воспроизведения,
- изображение обложки альбома.
Виджет выглядит современно и эффектно, подстраивается под цвета обложки трека.
## Ссылки
- [YT Music Live](https://chromewebstore.google.com/detail/yt-music-live/epjiaolflhijpankmodpeejblpjejigf) в интернет-магазине Chrome.
## Снимки экрана
![Снимок экрана 1](https://cdn.csasq.ru/git.csasq.ru/images/8b7dcaf0-6a90-4ae0-91c4-0ff44c5c3288.png)

View File

@ -0,0 +1,17 @@
{
"description": {
"message": "Show viewers what song is playing on your broadcast"
},
"popup_title": {
"message": "Connection Parameters"
},
"server_url_field_label": {
"message": "Server URL"
},
"connect_button": {
"message": "Connect"
},
"default_server_url": {
"message": "ws://localhost:8000"
}
}

View File

@ -0,0 +1,14 @@
{
"description": {
"message": "Покажите зрителям, какая песня играет на вашей трансляции"
},
"popup_title": {
"message": "Параметры подключения"
},
"server_url_field_label": {
"message": "URL сервера"
},
"connect_button": {
"message": "Подключиться"
}
}

93
extension/fonts/OFL.txt Normal file
View File

@ -0,0 +1,93 @@
Copyright 2013 The Exo 2 Project Authors (https://github.com/NDISCOVER/Exo-2.0)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
extension/fonts/latin.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

21
extension/main.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8" />
<title>YT Music Live</title>
<link rel="stylesheet" href="/styles/main.css" />
<script src="/scripts/main.js"></script>
</head>
<body>
<h1 id="popup-title"></h1>
<form id="settings-form">
<div class="field">
<input id="server-url-field" type="url" minlength="0" />
<label for="server-url-field"></label>
</div>
<div class="field">
<input id="connect-button" type="submit" value="" />
</div>
</form>
</body>
</html>

View File

@ -1,43 +0,0 @@
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();

View File

@ -1,23 +1,33 @@
{ {
"name": "YT Music Live",
"description": "YT Music Live",
"version": "1.0",
"manifest_version": 3, "manifest_version": 3,
"action": { "name": "YT Music Live",
"default_icon": "icon.png" "version": "1.0.0",
"description": "__MSG_description__",
"icons": {
"16": "/images/icon/16x16.png",
"48": "/images/icon/48x48.png",
"128": "/images/icon/128x128.png"
}, },
"action": {
"default_popup": "/main.html"
},
"author": "csasq@csasq.ru",
"content_scripts": [ "content_scripts": [
{ {
"js": [ "js": [
"main.js" "/scripts/observer.js"
], ],
"matches": [ "matches": [
"*://music.youtube.com/*" "*://music.youtube.com/*"
] ]
} }
], ],
"default_locale": "en",
"incognito": "not_allowed",
"permissions": [ "permissions": [
"activeTab", "storage"
"scripting" ],
] "storage": {
"managed_schema": "/storage-schema.json"
}
} }

41
extension/scripts/main.js Normal file
View File

@ -0,0 +1,41 @@
const updateFields = () => {
document.querySelectorAll('input, textarea').forEach(field => {
const focus = () => field.classList.add('focus')
if (field.value.trim() !== '') focus()
field.onfocus = focus
field.onblur = () => {
if (field.value === '') field.classList.remove('focus')
}
})
}
window.addEventListener('DOMContentLoaded', () => {
const popupTitle = document.querySelector('#popup-title')
const settingsForm = document.querySelector('#settings-form')
const serverURLField = document.querySelector('#server-url-field')
const serverURLFieldLabel = document.querySelector('label[for="server-url-field"]')
const connectButton = document.querySelector('#connect-button')
const defaultServerURL = chrome.i18n.getMessage('default_server_url')
popupTitle.textContent = chrome.i18n.getMessage('popup_title')
serverURLFieldLabel.textContent = chrome.i18n.getMessage('server_url_field_label')
connectButton.value = chrome.i18n.getMessage('connect_button')
chrome.storage.local.get([
'server_url',
]).then((data) => {
serverURLField.placeholder = defaultServerURL
serverURLField.value = data.server_url || defaultServerURL
updateFields()
})
settingsForm.addEventListener('submit', (event) => {
event.preventDefault()
chrome.storage.local.set({
'server_url': serverURLField.value.trim() || defaultServerURL,
}).then(() => {
window.close()
})
})
})

View File

@ -0,0 +1,71 @@
const uiLocale = chrome.i18n.getMessage('@@ui_locale')
const defaultServerURL = chrome.i18n.getMessage('default_server_url')
const getURL = () => {
return new Promise(resolve => {
chrome.storage.local.get([
'server_url',
]).then((data) => {
const serverURL = data.server_url || defaultServerURL
const url = new URL(serverURL)
url.pathname = '/ws/v1/server'
resolve(url)
})
})
}
const mutationObserverConfig = {
attributes: true,
childList: false,
characterData: false,
}
const connectWebSocket = async () => {
const url = await getURL()
const ws = new WebSocket(url)
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: {
locale: uiLocale,
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()
wsPromise = connectWebSocket()
}
return ws
}
chrome.storage.onChanged.addListener((changes) => {
const settings = {}
for (let [key, { oldValue, newValue }] of Object.entries(changes)) {
settings[key] = newValue || oldValue
}
chrome.storage.local.set(settings).then(() => {
wsPromise.then(ws => ws.close())
})
})
let wsPromise = connectWebSocket()

View File

@ -0,0 +1,8 @@
{
"type": "object",
"properties": {
"server_url": {
"type": "string"
}
}
}

331
extension/styles/main.css Normal file
View File

@ -0,0 +1,331 @@
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('/fonts/cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('/fonts/cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('/fonts/vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('/fonts/latin-ext.woff2') format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Exo 2';
font-style: normal;
font-weight: 100 900;
src: url('/fonts/latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--color-1: rgb(150, 15, 38);
--dark-1: rgba(0, 0, 0, .1);
--dark-2: rgba(0, 0, 0, .2);
--box-shadow-1:
0 .375rem .625rem .25rem rgba(0, 0, 0, .15),
0 .1875rem .1875rem rgba(0, 0, 0, .3);
--box-shadow-2:
0 .5rem .75rem .375rem rgba(0, 0, 0, .15),
0 .25rem .25rem rgba(0, 0, 0, .3);
}
*:not(input, textarea, label) {
color: white;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
html {
width: 24rem;
background-color: #000000;
background-image:
radial-gradient(45% 85% at 65% 65%, rgba(13, 58, 216, 0.8) 0%, rgba(0, 0, 0, 0) 100%),
radial-gradient(35% 70% at 35% 50%, #9d0303 0%, rgba(0, 0, 0, 0) 79.09%);
background-position: center;
background-attachment: fixed;
background-size: cover;
background-repeat: no-repeat;
font-family: 'Exo 2', sans-serif;
font-size: 16px;
scroll-behavior: smooth;
overflow-x: hidden;
}
body {
margin: 0;
padding: 1rem;
max-width: 100%;
overflow-x: hidden;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
h1 {
margin: 0 0 1.25rem 0;
font-size: 1.25rem;
font-weight: 500;
}
div.field {
position: relative;
}
input,
textarea {
box-sizing: border-box;
width: 100%;
height: 100%;
border: none;
outline: none;
background: white;
color: black;
box-shadow: var(--box-shadow-2);
border-radius: 1rem;
font-family: 'Exo 2', sans-serif;
font-size: 1rem;
transition: box-shadow, font-size .4s;
}
label {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
input:read-only {
cursor: not-allowed;
}
input[type='text'],
input[type='email'],
input[type='tel'],
input[type='url'],
input[type='password'],
input[type='number'],
input[type='search'],
input[type='datetime-local'],
input[type='date'],
input[type='month'],
input[type='week'],
input[type='time'],
textarea {
padding: 1.5rem 1rem .75rem 1rem;
}
textarea {
width: 100%;
min-height: 8rem;
resize: vertical;
}
input[type='text'] + label,
input[type='email'] + label,
input[type='tel'] + label,
input[type='url'] + label,
input[type='password'] + label,
input[type='number'] + label,
input[type='search'] + label,
input[type='datetime-local'] + label,
input[type='date'] + label,
input[type='month'] + label,
input[type='week'] + label,
input[type='time'] + label,
textarea + label {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 1rem;
color: black;
font-size: 1rem;
font-weight: 400;
pointer-events: none;
transition: all .1s;
}
input[type='text'].focus + label,
input[type='email'].focus + label,
input[type='tel'].focus + label,
input[type='url'].focus + label,
input[type='password'].focus + label,
input[type='number'].focus + label,
input[type='search'].focus + label,
input[type='datetime-local'].focus + label,
input[type='date'].focus + label,
input[type='month'].focus + label,
input[type='week'].focus + label,
input[type='time'].focus + label,
textarea.focus + label {
padding-top: .5rem;
font-size: .75rem;
font-weight: 600;
}
input[type='text']:required + label::after,
input[type='email']:required + label::after,
input[type='tel']:required + label::after,
input[type='url']:required + label::after,
input[type='password']:required + label::after,
input[type='number']:required + label::after,
input[type='search']:required + label::after,
input[type='datetime-local']:required + label::after,
input[type='date']:required + label::after,
input[type='month']:required + label::after,
input[type='week']:required + label::after,
input[type='time']:required + label::after,
textarea:required + label::after {
content: '*';
color: var(--color-1);
font-weight: bold;
}
input[type='text']::placeholder,
input[type='email']::placeholder,
input[type='tel']::placeholder,
input[type='url']::placeholder,
input[type='password']::placeholder,
input[type='number']::placeholder,
input[type='search']::placeholder,
input[type='datetime-local']::placeholder,
input[type='date']::placeholder,
input[type='month']::placeholder,
input[type='week']::placeholder,
input[type='time']::placeholder,
textarea.focus + label {
opacity: 0;
transition: all .4s;
}
input[type='text'].focus::placeholder,
input[type='email'].focus::placeholder,
input[type='tel'].focus::placeholder,
input[type='url'].focus::placeholder,
input[type='password'].focus::placeholder,
input[type='number'].focus::placeholder,
input[type='search'].focus::placeholder,
input[type='datetime-local'].focus::placeholder,
input[type='date'].focus::placeholder,
input[type='month'].focus::placeholder,
input[type='week'].focus::placeholder,
input[type='time'].focus::placeholder,
textarea.focus + label {
opacity: 1;
}
input[type='datetime-local']:not(.focus)::-webkit-datetime-edit-fields-wrapper,
input[type='date']:not(.focus)::-webkit-datetime-edit-fields-wrapper,
input[type='month']:not(.focus)::-webkit-datetime-edit-fields-wrapper,
input[type='week']:not(.focus)::-webkit-datetime-edit-fields-wrapper,
input[type='time']:not(.focus)::-webkit-datetime-edit-fields-wrapper {
opacity: 0;
}
input[type='button'],
input[type='submit'] {
padding: 1.125rem 1rem;
background: var(--dark-1);
box-shadow: var(--box-shadow-2);
color: white;
cursor: pointer;
transition: all .4s;
}
input[type='button']:not(:disabled):hover,
input[type='submit']:not(:disabled):hover {
background: var(--dark-2);
}
input[type='button']:not(:disabled):focus,
input[type='submit']:not(:disabled):focus {
background: var(--dark-2);
box-shadow: var(--box-shadow-1);
}
input[type='button']:not(:disabled):active,
input[type='submit']:not(:disabled):active {
background: var(--dark-2);
box-shadow: var(--box-shadow-1);
}
input[type='button']:disabled,
input[type='submit']:disabled {
cursor: not-allowed;
}
input[type='text'][data-error],
input[type='email'][data-error],
input[type='number'][data-error],
input[type='password'][data-error],
textarea[data-error] {
background: var(--color-1);
}
input[type='text'][data-error] + label,
input[type='email'][data-error] + label,
input[type='number'][data-error] + label,
input[type='password'][data-error] + label,
textarea[data-error] + label {
color: white;
}
input[type="checkbox"],
input[type="radio"] {
position: absolute;
z-index: -1;
opacity: 0;
}
input[type="checkbox"] + label,
input[type="radio"] + label {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: 1rem;
cursor: pointer;
}
input[type="checkbox"]:checked + label,
input[type="radio"]:checked + label {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" fill="white" opacity=".3" /%3E%3C/svg%3E');
background-repeat: no-repeat no-repeat;
background-size: 4rem;
background-attachment: local;
background-position: right bottom;
}
input[type='checkbox']:disabled + label,
input[type='radio']:disabled + label {
cursor: not-allowed;
}

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
from contextlib import asynccontextmanager
import hashlib import hashlib
import os import os
@ -12,7 +13,61 @@ from jinja2 import Environment, FileSystemLoader
import config import config
app = FastAPI() class TextStyle:
PURPLE = '\033[95m'
CYAN = '\033[96m'
DARKCYAN = '\033[36m'
BLUE = '\033[94m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
END = '\033[0m'
@asynccontextmanager
async def lifespan(app: FastAPI):
bold = (TextStyle.BOLD, TextStyle.END)
print('%sProgram Name:%s\tYT Music Live' % bold)
print('%sVersion:%s\t1.0.0' % bold)
print('%sAuthor:%s\t\tcsasq' % bold)
print('%sEmail:%s\t\tcsasq@csasq.ru' % bold)
print('%sWebsite:%s\thttps://csasq.ru/' % bold)
print('%sLicense:%s\tCreative Commons Attribution-NonCommercial 4.0 International' % bold)
print()
print("%sYou are free to:%s" % bold)
print("\t%sShare%s — copy and redistribute the material in any medium or format." % bold)
print("\t%sAdapt%s — remix, transform, and build upon the material." % bold)
print('\tThe licensor cannot revoke these freedoms as long as you follow the license terms.')
print("%sUnder the following terms:%s" % bold)
print("\t%sAttribution%s — You must give appropriate credit , provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use." % bold)
print('\t%sNonCommercial%s — You may not use the material for commercial purposes.' % bold)
print('\t%sNo additional restrictions%s — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.' % bold)
print()
print('%sSee the legal code:%s' % bold)
print('https://creativecommons.org/licenses/by-nc/4.0/')
print()
print('%sSee the Git repository:%s' % bold)
print('https://git.csasq.ru/csasq/yt-music-live')
print()
print('Application startup complete (Press CTRL+C to quit)')
print('Widget is available at %shttp://%s:%d/overlay%s' % (
TextStyle.BOLD,
config.Main.host,
config.Main.port,
TextStyle.END,
))
yield
print('Application shutdown complete')
app = FastAPI(
openapi_url=None,
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
app.mount( app.mount(
path='/static', path='/static',
app=StaticFiles( app=StaticFiles(
@ -51,42 +106,16 @@ class ConnectionManager:
overlay_manager = ConnectionManager() overlay_manager = ConnectionManager()
plugin_manager = ConnectionManager() server_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( @app.get(
path='/overlay', path='/overlay',
) )
async def function( async def _():
justify_content: str = Query(
default='end',
alias='justify-content',
),
align_items: str = Query(
default='end',
alias='align-items',
),
):
template = env.get_template('overlay.jinja2') template = env.get_template('overlay.jinja2')
return HTMLResponse( return HTMLResponse(
content=await template.render_async( content=await template.render_async(),
justify_content=justify_content,
align_items=align_items,
),
status_code=200, status_code=200,
) )
@ -94,7 +123,7 @@ async def function(
@app.get( @app.get(
path='/image', path='/image',
) )
async def function( async def _(
src: str = Query( src: str = Query(
default=..., default=...,
), ),
@ -125,27 +154,41 @@ async def function(
) )
@app.options(
path='/api/v1/overlay',
)
async def _():
return Response(
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*',
},
status_code=200,
)
@app.websocket('/ws/v1/overlay') @app.websocket('/ws/v1/overlay')
async def function( async def _(
websocket: WebSocket, websocket: WebSocket,
): ):
await overlay_manager.connect(websocket) await overlay_manager.connect(websocket)
try: try:
while True: while True:
data = await websocket.receive_json() data = await websocket.receive_json()
await plugin_manager.broadcast(data) await server_manager.broadcast(data)
except WebSocketDisconnect: except WebSocketDisconnect:
overlay_manager.disconnect(websocket) overlay_manager.disconnect(websocket)
@app.websocket('/ws/v1/plugin') @app.websocket('/ws/v1/server')
async def function( async def _(
websocket: WebSocket, websocket: WebSocket,
): ):
await plugin_manager.connect(websocket) await server_manager.connect(websocket)
try: try:
while True: while True:
data = await websocket.receive_json() data = await websocket.receive_json()
await overlay_manager.broadcast(data) await overlay_manager.broadcast(data)
except WebSocketDisconnect: except WebSocketDisconnect:
plugin_manager.disconnect(websocket) server_manager.disconnect(websocket)

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -1,3 +1,9 @@
__version__ = "1.0.0"
__author__ = "csasq"
__license__ = "CC BY-NC 4.0"
import logging
import shutil import shutil
import uvicorn import uvicorn
@ -12,6 +18,7 @@ if __name__ == '__main__':
app=app, app=app,
host=config.Main.host, host=config.Main.host,
port=config.Main.port, port=config.Main.port,
log_level=logging.WARNING,
) )
except InterruptedError: except InterruptedError:
shutil.rmtree(config.Main.images_directory) shutil.rmtree(config.Main.images_directory)

View File

@ -1,41 +1,53 @@
const colorThief = new ColorThief(); const locales = {
const musicElement = document.querySelector('#music'); playing_now_title: {
const progressBarElement = musicElement.querySelector('#music > .progress-bar'); en: 'Now Playing',
const titleElement = progressBarElement.querySelector('#music > .progress-bar > .data > .title'); ru: 'Сейчас играет',
const artistsElement = progressBarElement.querySelector('#music > .progress-bar > .data > .artists'); },
const coverElement = progressBarElement.querySelector('#music > .progress-bar > .cover'); }
const colorThief = new ColorThief()
const musicElement = document.querySelector('#music')
const titleElement = musicElement.querySelector('#music > .title')
const musicProgressBarElement = musicElement.querySelector('#music > .progress-bar')
const musicTitleElement = musicProgressBarElement.querySelector('#music > .progress-bar > .data > .title')
const musicArtistsElement = musicProgressBarElement.querySelector('#music > .progress-bar > .data > .artists')
const musicCoverElement = musicProgressBarElement.querySelector('#music > .progress-bar > .cover')
const handlers = { const handlers = {
'target': (attributes) => { 'target': (attributes) => {
const target = document.getElementById(attributes.id); const target = document.getElementById(attributes.id)
target.style.setProperty('--progress-size', `${attributes.progress}`); target.style.setProperty('--progress-size', `${attributes.progress}`)
}, },
'music': (attributes) => { 'music': (attributes) => {
titleElement.textContent = attributes.title; titleElement.textContent = locales.playing_now_title[attributes.locale] || locales.playing_now_title.en
artistsElement.textContent = attributes.artists; musicTitleElement.textContent = attributes.title
musicElement.style.setProperty('--progress-size', `${(1 - attributes.progress) * 100}%`); musicArtistsElement.textContent = attributes.artists
const url = new URL(location); musicElement.style.setProperty('--progress-size', `${(1 - attributes.progress) * 100}%`)
url.pathname = '/image'; const url = new URL(location)
const urlSearchParams = new URLSearchParams(); url.pathname = '/image'
urlSearchParams.set('src', attributes.image); const urlSearchParams = new URLSearchParams()
url.search = urlSearchParams.toString(); urlSearchParams.set('src', attributes.image)
coverElement.src = url; url.search = urlSearchParams.toString()
const colors = colorThief.getPalette(coverElement, 2); musicCoverElement.src = url
progressBarElement.style.setProperty('--primary-color', `rgb(${colors[0][0]}, ${colors[0][1]}, ${colors[0][2]})`); const colors = colorThief.getPalette(musicCoverElement, 2)
progressBarElement.style.setProperty('--secondary-color', `rgb(${colors[1][0]}, ${colors[1][1]}, ${colors[1][2]})`); musicProgressBarElement.style.setProperty('--primary-color', `rgb(${colors[0][0]}, ${colors[0][1]}, ${colors[0][2]})`)
musicElement.classList.remove('hidden'); musicProgressBarElement.style.setProperty('--secondary-color', `rgb(${colors[1][0]}, ${colors[1][1]}, ${colors[1][2]})`)
musicElement.classList.remove('hidden')
}, },
}; }
const connectWebSocket = () => { const connectWebSocket = () => {
const ws = new WebSocket('ws://localhost:8000/ws/v1/overlay'); const url = new URL(location)
url.protocol = 'ws'
url.pathname = '/ws/v1/overlay'
const ws = new WebSocket(url)
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data)
handlers[data.type](data.attributes); handlers[data.type](data.attributes)
}; }
ws.onclose = () => connectWebSocket(); ws.onclose = () => connectWebSocket()
}; }
connectWebSocket(); connectWebSocket()

View File

@ -23,6 +23,8 @@
html { html {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }

View File

@ -2,22 +2,16 @@
<html lang="ru-RU"> <html lang="ru-RU">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title></title> <title>YT Music Live</title>
<link rel="stylesheet" href="/static/styles/main.css" /> <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> <script src="/static/scripts/color-thief.umd.js"></script>
</head> </head>
<body> <body>
<div id="root"> <div id="root">
<div id="music" class="hidden"> <div id="music" class="hidden">
<span class="title">Сейчас играет</span> <span class="title"></span>
<div class="progress-bar"> <div class="progress-bar">
<img class="cover" /> <img class="cover" alt="" />
<div class="data"> <div class="data">
<span class="title"></span> <span class="title"></span>
<span class="artists"></span> <span class="artists"></span>