Merge branch 'main' of github.com:monochrome-music/monochrome

This commit is contained in:
Samidy 2026-04-05 16:58:54 +03:00
commit 9ccc7c748b
14 changed files with 305 additions and 104 deletions

View file

@ -1,39 +1,38 @@
name: "Copilot Setup Steps"
name: 'Copilot Setup Steps'
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
runs-on: ubuntu-latest
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
runs-on: ubuntu-latest
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission.
# If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
contents: read
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
contents: read
workflows: write
steps:
- name: Checkout code
uses: actions/checkout@v6
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Install dependencies
run: bun install
- name: Install playwright dependencies
run: bun run install:playwright
- name: Install playwright dependencies
run: bun run install:playwright

View file

@ -25,7 +25,7 @@ jobs:
echo "Files changed in this commit:"
echo "$CHANGED"
if echo "$CHANGED" | grep -qE '^public/editors-picks\.json$|^public/editors-picks-old/'; then
echo "Detected changes to generated files in this commit backing off to avoid overwriting manual edits."
echo "Detected changes to generated files in this commit - backing off to avoid overwriting manual edits."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
@ -37,6 +37,10 @@ jobs:
with:
python-version: '3.x'
- name: Install webp
if: steps.backoff.outputs.skip == 'false'
run: sudo apt-get update && sudo apt-get install -y webp
- name: Archive current editors picks
if: steps.backoff.outputs.skip == 'false'
run: |
@ -60,9 +64,19 @@ jobs:
label = m.group(1).strip()
break
# Copy current picks to archive
# Copy current picks to archive, replacing monochrome URLs with UUIDs
with open("public/editors-picks.json") as f:
current = json.load(f)
# Replace cover URLs with original UUIDs
url_pattern = re.compile(r'^https://monochrome\.tf/editors-picks-images/([a-f0-9-]+)\.webp$')
for item in current:
for field in ['cover', 'picture', 'image']:
if field in item and item[field]:
match = url_pattern.match(item[field])
if match:
item[field] = match.group(1)
with open(archive_path, "w") as f:
json.dump(current, f, indent=4)
@ -89,7 +103,7 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/editors-picks.json public/editors-picks-old/
git add public/editors-picks.json public/editors-picks-old/ public/editors-picks-images/
git diff --staged --quiet && echo "No changes to commit." && exit 0
git commit -m "chore: update editors picks"
git push

View file

@ -20,4 +20,5 @@ album:423471869
album:250986538
album:509761344
album:15621057
album:103897783
album:103897783
album:151728406

View file

@ -7,12 +7,17 @@ import re
import sys
import hashlib
import time
import os
import subprocess
import shutil
import tempfile
INPUT_FILE = "editors-picks-input.txt"
IMAGES_DIR = "public/editors-picks-images"
COUNTRY = "US"
# Tidal internal token replace when expired
TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MjQ2NzQ2LCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.ksUE4yhQ39IG7oHWk8DyJ91dwIoDVWGzvTAnpeDJ5p-_Gp0F_yO858xDO11AINBaahQCq0jlbqWqIaTqCTOjqg"
# Tidal internal token replace when expired
TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MzY0MTQwLCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.6ui6itHVQ-OXPF0F9mbf5KcKz1fKYJNsa1vBAj60upXpcN-DQG8JPKBlqJN6RuBEH8yhwYj2wh4YJ-TOOuO8DA"
TIDAL_HEADERS = {
"accept": "*/*",
@ -83,6 +88,61 @@ def fetch_podcast(feed_id):
return podcast_get(f"/podcasts/byfeedid?id={feed_id}&pretty")
# ── Image processing ───────────────────────────────────────────────────────────
def clear_images_dir():
if os.path.exists(IMAGES_DIR):
shutil.rmtree(IMAGES_DIR)
os.makedirs(IMAGES_DIR, exist_ok=True)
def is_uuid_cover(cover_value):
if not cover_value:
return False
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', cover_value))
def uuid_to_path_segments(uuid):
return uuid.replace('-', '/')
def download_and_process_cover(cover_uuid):
url = f"https://resources.tidal.com/images/{uuid_to_path_segments(cover_uuid)}/320x320.jpg"
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
tmp_path = tmp.name
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req) as resp:
with open(tmp_path, 'wb') as f:
shutil.copyfileobj(resp, f)
output_path = os.path.join(IMAGES_DIR, f"{cover_uuid}.webp")
subprocess.run(
['cwebp', '-q', '50', tmp_path, '-o', output_path],
check=True,
capture_output=True
)
return f"https://monochrome.tf/editors-picks-images/{cover_uuid}.webp"
except Exception as e:
print(f"Error processing cover {cover_uuid}: {e}", file=sys.stderr)
return None
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def process_cover(cover_value):
if not cover_value:
return cover_value
if is_uuid_cover(cover_value):
return download_and_process_cover(cover_value)
return cover_value
# ── Transformers ──────────────────────────────────────────────────────────────
def transform_album(d):
@ -95,7 +155,7 @@ def transform_album(d):
"name": d.get("artist", {}).get("name"),
},
"releaseDate": d.get("releaseDate"),
"cover": d.get("cover"),
"cover": process_cover(d.get("cover")),
"explicit": d.get("explicit"),
"audioQuality": d.get("audioQuality"),
"mediaMetadata": d.get("mediaMetadata"),
@ -107,7 +167,7 @@ def transform_artist(d):
"type": "artist",
"id": d.get("id"),
"name": d.get("name"),
"picture": d.get("picture"),
"picture": process_cover(d.get("picture")),
}
@ -124,7 +184,7 @@ def transform_track(d):
"album": {
"id": album.get("id"),
"title": album.get("title"),
"cover": album.get("cover"),
"cover": process_cover(album.get("cover")),
},
"duration": d.get("duration"),
"explicit": d.get("explicit"),
@ -140,7 +200,7 @@ def transform_playlist(d):
"type": "playlist",
"id": d.get("uuid"),
"title": d.get("title"),
"cover": cover,
"cover": process_cover(cover),
"numberOfTracks": d.get("numberOfTracks", 0),
}
@ -153,7 +213,7 @@ def transform_userplaylist(d):
"type": "user-playlist",
"id": d.get("uuid"),
"name": d.get("title"),
"cover": cover,
"cover": process_cover(cover),
"numberOfTracks": d.get("numberOfTracks", 0),
"username": creator.get("name"),
}
@ -198,6 +258,8 @@ def read_items(path):
# ── Main ──────────────────────────────────────────────────────────────────────
clear_images_dir()
FETCHERS = {
"album": (fetch_album, transform_album),
"artist": (fetch_artist, transform_artist),
@ -212,7 +274,7 @@ picks = []
for item_type, item_id in items:
if item_type not in FETCHERS:
print(f"Unknown type '{item_type}' for id {item_id!r} skipping", file=sys.stderr)
print(f"Unknown type '{item_type}' for id {item_id!r} - skipping", file=sys.stderr)
continue
fetch_fn, transform_fn = FETCHERS[item_type]
data = fetch_fn(item_id)

View file

@ -3,24 +3,79 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Monochrome Music</title>
<title>Monochrome</title>
<link rel="canonical" href="https://monochrome.tf/" />
<link rel="alternate" hreflang="en" href="https://monochrome.tf/" />
<meta name="theme-color" content="#000000" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<base href="/" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Monochrome" />
<meta name="description" content="A minimalist music streaming application" />
<meta
name="description"
content="Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
/>
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta name="googlebot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="Monochrome" />
<meta property="og:title" content="Monochrome" />
<meta
property="og:description"
content="Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
/>
<meta property="og:url" content="https://monochrome.tf/" />
<meta property="og:image" content="https://monochrome.tf/assets/banner.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content="Monochrome banner" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://monochrome.tf/" />
<meta name="twitter:title" content="Monochrome" />
<meta
name="twitter:description"
content="Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
/>
<meta name="twitter:image" content="https://monochrome.tf/assets/banner-twitter.jpg" />
<meta name="twitter:image:alt" content="Monochrome banner" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Monochrome",
"url": "https://monochrome.tf/",
"description": "Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome.",
"potentialAction": {
"@type": "SearchAction",
"target": "https://monochrome.tf/search/{search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Monochrome",
"url": "https://monochrome.tf/",
"applicationCategory": "MusicApplication",
"operatingSystem": "Web Browser",
"image": "https://monochrome.tf/assets/logo.svg",
"description": "Stream and download millions of Hi-Res FLACs, unreleased songs and music videos, all for free on Monochrome."
}
</script>
<!-- bini's seo thing -->
<meta
name="ahrefs-site-verification"
content="889c6a5c1584832256b87bb36beaa665b40bb6e201582d416f04d074794044ef"
/>
<meta name="google-site-verification" content="ChAPlKFJ5Dk2YbWhiUfnH5sHgCC8SnNdCBtjVcahArY" />
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
<link rel="preconnect" href="https://ws.audioscrobbler.com" crossorigin />
<link rel="preconnect" href="https://libre.fm" crossorigin />
<link rel="preconnect" href="https://api.listenbrainz.org" crossorigin />
<link rel="preconnect" href="https://resources.tidal.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://unpkg.com" crossorigin />
<link rel="apple-touch-icon" href="/assets/logo.svg" />
<link rel="manifest" href="/manifest.json" />

View file

@ -127,32 +127,36 @@ async function loadDownloadsModule() {
}
async function fetchcontributors() {
const response = await fetch('https://api.samidy.com/api/contributors');
const data1 = await response.json();
try {
const response = await fetch('https://api.samidy.com/api/contributors');
if (!response.ok) return;
const data1 = await response.json();
const data = data1.filter(
(user) => user.type !== 'Bot' && user.login !== 'edidealt' && user.login !== 'satanyahoo'
);
const data = data1.filter(
(user) => user.type !== 'Bot' && user.login !== 'edidealt' && user.login !== 'satanyahoo'
);
const edideaur = data.find((user) => user.login === 'edideaur');
if (edideaur) {
edideaur.contributions += data1.find((u) => u.login === 'edidealt')?.contributions || 0;
edideaur.contributions += data1.find((u) => u.login === 'satanyahoo')?.contributions || 0;
}
const edideaur = data.find((user) => user.login === 'edideaur');
if (edideaur) {
edideaur.contributions += data1.find((u) => u.login === 'edidealt')?.contributions || 0;
edideaur.contributions += data1.find((u) => u.login === 'satanyahoo')?.contributions || 0;
}
const con = document.querySelector('.about-contributors');
const con = document.querySelector('.about-contributors');
if (!con) return;
data.forEach((user) => {
const userDIV = document.createElement('div');
userDIV.innerHTML = `
<a href="${user.html_url}" target="_blank">
<img src="${user.avatar_url}" alt="${user.login}" width="50" style="border-radius: 50%;">
<span>${user.login}</span>
<span class="contrib">Contributions: ${user.contributions}</span>
</a>
`;
con.appendChild(userDIV);
});
data.forEach((user) => {
const userDIV = document.createElement('div');
userDIV.innerHTML = `
<a href="${user.html_url}" target="_blank">
<img src="${user.avatar_url}" alt="${user.login}" width="50" style="border-radius: 50%;">
<span>${user.login}</span>
<span class="contrib">Contributions: ${user.contributions}</span>
</a>
`;
con.appendChild(userDIV);
});
} catch (e) {}
}
async function loadMetadataModule() {
@ -500,7 +504,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize tracker
initTracker().catch(console.error);
fetchcontributors();
await fetchcontributors();
const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);

View file

@ -278,13 +278,13 @@ export class LyricsManager {
// Load Kuroshiro from CDN
if (!window.Kuroshiro) {
await this.loadScript('https://unpkg.com/kuroshiro@1.2.0/dist/kuroshiro.min.js');
await this.loadScript('https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js');
}
// Load Kuromoji analyzer from CDN
if (!window.KuromojiAnalyzer) {
await this.loadScript(
'https://unpkg.com/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js'
'https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js'
);
}

View file

@ -487,7 +487,7 @@ export class Player {
const timeRemaining = duration - currentTime;
// Preload if we are in last 30 seconds of song
const shouldPreload = (duration > 0 && timeRemaining <= 30);
const shouldPreload = duration > 0 && timeRemaining <= 30;
if (shouldPreload) {
this._pendingPreload = false;
@ -537,7 +537,7 @@ export class Player {
albumPeakAmplitude: trackData.info.albumPeakAmplitude,
};
}
} catch(e) {} // Fail silently
} catch (_e) {} // Fail silently
}
this.preloadCache.set(track.id, streamInfo);
@ -546,30 +546,45 @@ export class Player {
// Warm connection and pre-fetch
if (!streamUrl.startsWith('blob:')) {
if (streamUrl.includes('.mpd') || streamUrl.includes('.m3u8')) {
if (this.shakaInitialized && this.shakaPlayer && typeof this.shakaPlayer.preload === 'function') {
if (
this.shakaInitialized &&
this.shakaPlayer &&
typeof this.shakaPlayer.preload === 'function'
) {
try {
let preloadConfig = undefined;
if (typeof this.shakaPlayer.getConfiguration === 'function') {
preloadConfig = this.shakaPlayer.getConfiguration();
const stats = typeof this.shakaPlayer.getStats === 'function' ? this.shakaPlayer.getStats() : null;
const stats =
typeof this.shakaPlayer.getStats === 'function'
? this.shakaPlayer.getStats()
: null;
if (stats && stats.estimatedBandwidth) {
preloadConfig.abr.defaultBandwidthEstimate = stats.estimatedBandwidth;
}
// Lock the preload to the exact current audio codec to prevent ABR mismatch,
// which forces the player to discard and re-fetch chunks on slow connections.
preloadConfig.abr.enabled = false;
try {
const variants = typeof this.shakaPlayer.getVariantTracks === 'function' ? this.shakaPlayer.getVariantTracks() : [];
const activeVariant = variants.find(v => v.active);
const variants =
typeof this.shakaPlayer.getVariantTracks === 'function'
? this.shakaPlayer.getVariantTracks()
: [];
const activeVariant = variants.find((v) => v.active);
if (activeVariant && activeVariant.audioCodec) {
preloadConfig.preferredAudioCodecs = [activeVariant.audioCodec];
}
} catch (e) {}
} catch (_e) {}
}
const preloadManager = await this.shakaPlayer.preload(streamUrl, null, null, preloadConfig);
const preloadManager = await this.shakaPlayer.preload(
streamUrl,
null,
null,
preloadConfig
);
streamInfo.preloadManager = preloadManager;
} catch (e) {
} catch (_e) {
// Ignore preload errors, will just load fresh
}
} else {
@ -801,7 +816,7 @@ export class Player {
this.hls.destroy();
this.hls = null;
}
// Retain the initialized Shaka player if we are remaining on the same HTMLMediaElement
if (this.shakaInitialized && this.shakaPlayer) {
if (this.shakaPlayer.getMediaElement() !== activeElement) {
@ -1015,14 +1030,17 @@ export class Player {
} else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) {
await this.shakaPlayer.attach(activeElement);
const loadTarget = track.type == 'video' && this.preloadCache.has(track.id) ?
(this.preloadCache.get(track.id).preloadManager || streamUrl) : streamUrl;
const loadTarget =
track.type == 'video' && this.preloadCache.has(track.id)
? this.preloadCache.get(track.id).preloadManager || streamUrl
: streamUrl;
try {
await this.shakaPlayer.load(loadTarget);
} catch (e) {
console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
else throw e;
console.error('PreloadManager load Error:', e);
if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
else throw e;
}
this.shakaInitialized = true;
@ -1097,8 +1115,9 @@ export class Player {
await this.shakaPlayer.load(loadTarget);
}
} catch (e) {
console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
else throw e;
console.error('PreloadManager load Error:', e);
if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl);
else throw e;
}
this.shakaInitialized = true;
@ -1109,7 +1128,7 @@ export class Player {
this.updateAdaptiveQualityBadge();
// Instantly trigger playback rather than explicitly waiting for 'canplay'
// Instantly trigger playback rather than explicitly waiting for 'canplay'
// which delays the event loop and natively adds gap/latency
await this.safePlay(activeElement);
} else {

View file

@ -145,7 +145,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
if (explicit !== undefined) {
if (isMp4) {
// rtng is a byte item must be set directly on the Mp4Tag
// rtng is a byte item - must be set directly on the Mp4Tag
const mp4Tag = underlying.tag();
mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0));
} else {

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/assets/banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

11
public/robots.txt Normal file
View file

@ -0,0 +1,11 @@
# robots.txt for https://monochrome.tf/
User-agent: *
Allow: /
# Avoid indexing internal endpoints and auth flows.
Disallow: /functions/
Disallow: /api/
Disallow: /auth/
Sitemap: https://monochrome.tf/sitemap.xml

43
public/sitemap.xml Normal file
View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://monochrome.tf/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://monochrome.tf/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://monochrome.tf/library</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://monochrome.tf/recent</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://monochrome.tf/podcasts</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://monochrome.tf/unreleased</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://monochrome.tf/parties</loc>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://monochrome.tf/donate</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

View file

@ -1763,6 +1763,10 @@ input[type='search']::-webkit-search-cancel-button {
.settings-tab-content {
display: none;
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
}
.settings-tab-content.active {
@ -1770,14 +1774,6 @@ input[type='search']::-webkit-search-cancel-button {
animation: fade-in-slide-up 0.4s var(--ease-out-back);
}
/* Prevent settings tab content from overflowing on small displays (fixes #313) */
.settings-tab-content {
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@ -4301,6 +4297,7 @@ input:checked + .slider::before {
justify-content: center;
gap: 1rem;
margin-top: 1rem;
position: relative;
}
.fs-volume-btn {
@ -5910,10 +5907,6 @@ img[src=''] {
color: white;
}
#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container {
margin-top: 0.5rem;
}
#fullscreen-cover-overlay.ui-hidden .fullscreen-main-view,
#fullscreen-cover-overlay.ui-hidden .fullscreen-controls,
#fullscreen-cover-overlay.ui-hidden #fullscreen-next-track,
@ -9881,10 +9874,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
.chat-msg {
margin-bottom: 0.5rem;
animation: fadeIn 0.2s ease-out;
animation: fade-in 0.2s ease-out;
}
@keyframes fadeIn {
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(5px);