This commit is contained in:
edideaur 2026-04-14 22:06:13 +03:00
commit 13e5afd345
34 changed files with 1666 additions and 287 deletions

6
.gitignore vendored
View file

@ -16,4 +16,8 @@ bin/
auth_storage/
www
neutralino.js
package-lock.json
package-lock.json
# Direnv
.envrc
# vim
.lazy.lua

4
android/.gitignore vendored
View file

@ -99,3 +99,7 @@ app/src/main/assets/public
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
# Generated by LS
.settings/
.project

View file

@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-haptics')
implementation project(':capgo-capacitor-media-session')
}

View file

@ -32,7 +32,7 @@ public class BackgroundAudioPlugin extends Plugin {
}
}
`@PluginMethod`
@PluginMethod
public void stop(PluginCall call) {
try {
Intent intent = new Intent(getContext(), AudioPlaybackService.class);

View file

@ -7,3 +7,6 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capgo-capacitor-media-session'
project(':capgo-capacitor-media-session').projectDir = new File('../node_modules/@capgo/capacitor-media-session/android')

View file

@ -10,6 +10,7 @@
"@capacitor/core": "^8.2.0",
"@capacitor/haptics": "^8.0.1",
"@capacitor/ios": "^8.2.0",
"@capgo/capacitor-media-session": "^8.0.19",
"@dantheman827/taglib-ts": "^0.1.5",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
@ -18,7 +19,7 @@
"@svta/common-media-library": "^0.18.1",
"@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@uimaxbai/am-lyrics": "^1.1.8",
"@uimaxbai/am-lyrics": "^1.2.8",
"@vitest/web-worker": "^4.1.2",
"appwrite": "^23.0.0",
"butterchurn": "^2.6.7",
@ -277,6 +278,8 @@
"@capacitor/ios": ["@capacitor/ios@8.3.0", "", { "peerDependencies": { "@capacitor/core": "^8.3.0" } }, "sha512-5Rtwv8SITKlYTt8lAZG+khnVIdzPtqbocH3eP+JkEmX1vpSMwx4TOKtT8OBz8gpQ+pUJDRp7DBYOv3U6l/obCw=="],
"@capgo/capacitor-media-session": ["@capgo/capacitor-media-session@8.0.19", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-m0eCJvnuYpxz3wj3Snc1kIHCr45XQOBgPwOhkId/xXZ01DzXsOpw/lIT8Fl/Y1AscpNxs2fF5Qoj4E8QMIyJJg=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260401.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZSmceM70jH6k+/62VkEcmMNzrpr4kSctkX5Lsgqv38KktfhPY/hsh75y1lRoPWS3H3kgMa4p2pUSlidZR1u2hw=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260401.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7UKWF+IUZ3NXMVPsDg8Cjg0r58b+uYlfvs5Yt8bvtU+geCtW4P2MxRHmRSEo8SryckXOJjb/b8tcncgCykFu8g=="],
@ -675,7 +678,7 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="],
"@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-VcbrlB2cOmkOjElmivf2SZujDmj8UAUaBkXyIfJ8dYq/Iv4H3PxmQY/s9VaRfF6UTnCgfix8ZPll1T1MA8eS4A=="],
"@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.2.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-aR8kxqIYcVlsMCH6bbH8ANG+bN/2OAw66ZFjYD1a25hkMTyxtULWgWwAZlUfreP9V47bFvNgXIKvOqhO5JFpeg=="],
"@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="],

BIN
bun.lockb Executable file

Binary file not shown.

View file

@ -1,34 +1,46 @@
export async function onRequest(context) {
const { request } = context;
const pageUrl = request.url;
const { request, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot =
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
userAgent
);
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | About</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
if (isBot) {
const pageUrl = request.url;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | About">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | About</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | About">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | About</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | About">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | About">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | About</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View file

@ -1,34 +1,46 @@
export async function onRequest(context) {
const { request } = context;
const pageUrl = request.url;
const { request, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot =
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
userAgent
);
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Donate</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
if (isBot) {
const pageUrl = request.url;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Donate">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Donate</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Donate">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Donate</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Donate">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Donate">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Donate</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View file

@ -1,34 +1,46 @@
export async function onRequest(context) {
const { request } = context;
const pageUrl = request.url;
const { request, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot =
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
userAgent
);
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Library</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
if (isBot) {
const pageUrl = request.url;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Library">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Library</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Library">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Library</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Library">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Library">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Library</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View file

@ -1,34 +1,46 @@
export async function onRequest(context) {
const { request } = context;
const pageUrl = request.url;
const { request, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot =
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
userAgent
);
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Listening Parties</title>
<meta name="description" content="Listen to music with your friends">
<meta name="theme-color" content="#000000">
if (isBot) {
const pageUrl = request.url;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Listening Parties">
<meta property="og:description" content="Listen to music with your friends">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Listening Parties</title>
<meta name="description" content="Listen to music with your friends">
<meta name="theme-color" content="#000000">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Listening Parties">
<meta name="twitter:description" content="Listen to music with your friends">
</head>
<body>
<h1>Monochrome Music | Listening Parties</h1>
<p>Listen to music with your friends</p>
</body>
</html>
`;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Listening Parties">
<meta property="og:description" content="Listen to music with your friends">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Listening Parties">
<meta name="twitter:description" content="Listen to music with your friends">
</head>
<body>
<h1>Monochrome Music | Listening Parties</h1>
<p>Listen to music with your friends</p>
</body>
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View file

@ -1,34 +1,46 @@
export async function onRequest(context) {
const { request } = context;
const pageUrl = request.url;
const { request, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot =
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
userAgent
);
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Recent</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
if (isBot) {
const pageUrl = request.url;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Recent">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Recent</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Recent">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Recent</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Recent">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Recent">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Recent</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View file

@ -1,34 +1,46 @@
export async function onRequest(context) {
const { request } = context;
const pageUrl = request.url;
const { request, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot =
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
userAgent
);
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Settings</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
if (isBot) {
const pageUrl = request.url;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Settings">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Monochrome Music | Settings</title>
<meta name="description" content="A minimalist music streaming application">
<meta name="theme-color" content="#000000">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Settings">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Settings</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
<meta property="og:site_name" content="Monochrome">
<meta property="og:title" content="Monochrome Music | Settings">
<meta property="og:description" content="A minimalist music streaming application">
<meta property="og:type" content="website">
<meta property="og:url" content="${pageUrl}">
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Monochrome Music | Settings">
<meta name="twitter:description" content="A minimalist music streaming application">
</head>
<body>
<h1>Monochrome Music | Settings</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View file

@ -1,8 +1,15 @@
export async function onRequest(context) {
const { request } = context;
const pageUrl = request.url;
const { request, env } = context;
const userAgent = request.headers.get('User-Agent') || '';
const isBot =
/discordbot|twitterbot|facebookexternalhit|bingbot|googlebot|slurp|whatsapp|pinterest|slackbot|telegrambot|linkedinbot|mastodon|signal|snapchat|redditbot|skypeuripreview|viberbot|linebot|embedly|quora|outbrain|tumblr|duckduckbot|yandexbot|rogerbot|showyoubot|kakaotalk|naverbot|seznambot|mediapartners|adsbot|petalbot|applebot|ia_archiver/i.test(
userAgent
);
const metaHtml = `
if (isBot) {
const pageUrl = request.url;
const metaHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
@ -28,7 +35,12 @@ export async function onRequest(context) {
</html>
`;
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' },
});
}
const url = new URL(request.url);
url.pathname = '/';
return env.ASSETS.fetch(new Request(url, request));
}

View file

@ -75,6 +75,8 @@
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
<link rel="preconnect" href="https://resources.tidal.com" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="apple-touch-icon" href="/assets/logo.svg" />
<link rel="manifest" href="/manifest.json" />
@ -96,6 +98,18 @@
rel="stylesheet"
/>
</noscript>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&family=Noto+Sans+HK:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;600;700&family=Noto+Sans+Hebrew:wght@400;500;600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&family=Noto+Sans+Devanagari:wght@400;500;600;700&family=Noto+Sans+Bengali:wght@400;500;600;700&family=Noto+Sans+Thai:wght@400;500;600;700&family=Noto+Sans+Tamil:wght@400;500;600;700&family=Noto+Sans+Telugu:wght@400;500;600;700&family=Noto+Sans+Gujarati:wght@400;500;600;700&family=Noto+Sans+Kannada:wght@400;500;600;700&family=Noto+Sans+Malayalam:wght@400;500;600;700&family=Noto+Sans+Sinhala:wght@400;500;600;700&family=Noto+Sans+Khmer:wght@400;500;600;700&family=Noto+Sans+Lao:wght@400;500;600;700&family=Noto+Sans+Myanmar:wght@400;500;600;700&family=Noto+Sans+Georgian:wght@400;500;600;700&family=Noto+Sans+Armenian:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap"
media="print"
onload="this.media = 'all'"
/>
<noscript>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&family=Noto+Sans+HK:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;600;700&family=Noto+Sans+Hebrew:wght@400;500;600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&family=Noto+Sans+Devanagari:wght@400;500;600;700&family=Noto+Sans+Bengali:wght@400;500;600;700&family=Noto+Sans+Thai:wght@400;500;600;700&family=Noto+Sans+Tamil:wght@400;500;600;700&family=Noto+Sans+Telugu:wght@400;500;600;700&family=Noto+Sans+Gujarati:wght@400;500;600;700&family=Noto+Sans+Kannada:wght@400;500;600;700&family=Noto+Sans+Malayalam:wght@400;500;600;700&family=Noto+Sans+Sinhala:wght@400;500;600;700&family=Noto+Sans+Khmer:wght@400;500;600;700&family=Noto+Sans+Lao:wght@400;500;600;700&family=Noto+Sans+Myanmar:wght@400;500;600;700&family=Noto+Sans+Georgian:wght@400;500;600;700&family=Noto+Sans+Armenian:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap"
/>
</noscript>
<link rel="stylesheet" href="/styles.css" />
</head>
@ -2603,6 +2617,7 @@
<div id="page-artist" class="page">
<header class="detail-header">
<div id="artist-detail-banner-container" class="detail-header-banner"></div>
<img
id="artist-detail-image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
@ -3392,6 +3407,16 @@
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Artist Banners</span>
<span class="description">Display video banners on artist pages</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="artist-banners-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Compact Albums</span>
@ -5919,6 +5944,111 @@
</div>
</div>
</div>
<div id="page-reset-password" class="page">
<div
class="reset-password-container"
style="text-align: center; padding: var(--space-8); max-width: 400px; margin: 0 auto"
>
<h2 class="section-title" style="margin-bottom: var(--space-10)">Reset your Password</h2>
<div
id="reset-password-error"
class="message error-msg"
style="
display: none;
padding: var(--space-3) 0;
border-radius: var(--radius-md);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
background: transparent;
color: #ff6b6b;
text-align: left;
"
></div>
<div
id="reset-password-success"
class="message success-msg"
style="
display: none;
padding: var(--space-3) 0;
border-radius: var(--radius-md);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
background: transparent;
color: var(--foreground);
text-align: left;
"
></div>
<form id="reset-password-form">
<div style="margin-bottom: var(--space-4)">
<input
type="password"
id="reset-password-input"
placeholder="New Password"
required
minlength="8"
style="
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
color: var(--foreground);
font-size: 0.925rem;
outline: none;
transition: border-color 0.15s;
"
/>
</div>
<div style="margin-bottom: var(--space-6)">
<input
type="password"
id="reset-password-confirm"
placeholder="Confirm New Password"
required
minlength="8"
style="
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
color: var(--foreground);
font-size: 0.925rem;
outline: none;
transition: border-color 0.15s;
"
/>
</div>
<button
type="submit"
id="reset-password-submit-btn"
class="btn-primary"
style="width: 100%; justify-content: center"
>
<span id="reset-password-btn-text">Reset Password</span>
<div
id="reset-password-btn-spinner"
class="animate-spin"
style="display: none; width: 18px; height: 18px"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="width: 100%; height: 100%"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</div>
</button>
</form>
</div>
</div>
</main>
<footer class="now-playing-bar">

View file

@ -11,9 +11,10 @@ let package = Package(
targets: ["CapApp-SPM"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics")
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapgoCapacitorMediaSession", path: "../../../node_modules/@capgo/capacitor-media-session")
],
targets: [
.target(
@ -22,7 +23,8 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics")
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapgoCapacitorMediaSession", package: "CapgoCapacitorMediaSession")
]
)
]

View file

@ -15,12 +15,14 @@ export class AuthManager {
const isOAuthRedirect = params.get('oauth') === '1';
if (userId && secret && userId !== 'null' && secret !== 'null') {
try {
await auth.createSession(userId, secret);
window.history.replaceState({}, '', window.location.pathname);
} catch (error) {
console.warn('OAuth session handoff failed:', error.message);
window.history.replaceState({}, '', window.location.pathname);
if (window.location.pathname !== '/reset-password') {
try {
await auth.createSession(userId, secret);
window.history.replaceState({}, '', window.location.pathname);
} catch (error) {
console.warn('OAuth session handoff failed:', error.message);
window.history.replaceState({}, '', window.location.pathname);
}
}
} else if (isOAuthRedirect) {
await new Promise((resolve) => setTimeout(resolve, 500));
@ -137,6 +139,18 @@ export class AuthManager {
}
}
async resetPassword(userId, secret, password, confirmPassword) {
if (password !== confirmPassword) {
throw new Error('Passwords do not match');
}
try {
await auth.updateRecovery(userId, secret, password, password);
} catch (error) {
console.error('Password reset failed:', error);
throw error;
}
}
async signOut() {
try {
await auth.deleteSession('current');

View file

@ -660,7 +660,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
});
initializePlayerEvents(Player.instance, audioPlayer, scrobbler, UIRenderer.instance);
await initializePlayerEvents(Player.instance, audioPlayer, scrobbler, UIRenderer.instance);
initializeTrackInteractions(
Player.instance,
MusicAPI.instance,
@ -1087,6 +1087,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
Player.instance.setQueue(sortedTracks, 0);
Player.instance.enableAutoplay();
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false;
@ -1118,6 +1119,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (tracks && tracks.length > 0) {
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
Player.instance.setQueue(shuffledTracks, 0);
Player.instance.enableAutoplay();
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false;
@ -1186,6 +1188,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5);
Player.instance.setQueue(shuffledTracks, 0);
Player.instance.enableAutoplay();
const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false;
@ -2730,7 +2733,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const headerAccountIcon = document.getElementById('header-account-icon');
// Temporarily disable accounts - show popup
const isAccountsDisabled = false;
const isAccountsDisabled = true;
if (headerAccountBtn && headerAccountDropdown) {
if (isAccountsDisabled) {
@ -2740,7 +2743,7 @@ document.addEventListener('DOMContentLoaded', async () => {
headerAccountBtn.addEventListener('click', (e) => {
e.stopPropagation();
alert(
"We're moving authentication and data storing systems.\n\nAccounts, profiles, playlists, and community themes will not work during this period (approximately 2 days).\n\nYou will need to re-login after the migration is complete."
'(april 14, 2026) The server hosting the database has unfortunately fried. we are working extremely hard to recover all data and host the database & auth on a much more reliable server. please be patient with us in the time being.'
);
});
} else {

View file

@ -826,6 +826,7 @@ class AudioContextManager {
if (this.volumeNode && this.audioContext) {
const now = this.audioContext.currentTime;
this.volumeNode.gain.setTargetAtTime(this.currentVolume, now, 0.01);
window.dispatchEvent(new CustomEvent('volume-change'));
}
}

View file

@ -375,7 +375,7 @@ async function handleSelectionAction(action) {
}
}
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
export async function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (homeStartRadioBtn) {
homeStartRadioBtn.addEventListener('click', async () => {
await player.enableRadio();
@ -384,9 +384,13 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const sleepTimerBtnMobile = document.getElementById('sleep-timer-btn');
// History tracking
let historyLoggedTrackId = null;
const { listeningTracker } = await import('./listening-tracker.js');
let _previousTrackId = null;
let _trackPlayStartTime = null;
const setupMediaListeners = (element) => {
element.addEventListener('loadstart', () => {
if (player.activeElement === element) {
@ -397,14 +401,32 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
element.addEventListener('play', async () => {
if (player.activeElement !== element) return;
// Initialize audio context manager for EQ (only once)
if (!audioContextManager.isReady()) {
audioContextManager.init(element);
}
await audioContextManager.resume();
if (player.currentTrack) {
// Scrobble
const currentId = player.currentTrack.id;
if (currentId !== _previousTrackId) {
if (_previousTrackId !== null) {
const prevSignal = listeningTracker.getSessionSignals();
const prevPlayTime = prevSignal.accumulatedPlayTime || 0;
const prevDuration = prevSignal.trackDuration || 0;
listeningTracker.onSkip();
const prevTrack =
player.getCurrentQueue()[player.currentQueueIndex - 1] ||
player.getCurrentQueue().find((t) => t.id === _previousTrackId);
if (prevTrack && prevPlayTime > 0) {
listeningTracker.updateArtistAffinity(prevTrack, prevPlayTime, prevDuration, true);
}
listeningTracker.forceFlush();
}
_previousTrackId = currentId;
listeningTracker.onTrackStart(player.currentTrack);
_trackPlayStartTime = Date.now();
}
if (scrobbler.isAuthenticated()) {
scrobbler.updateNowPlaying(player.currentTrack);
}
@ -433,6 +455,15 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
element.addEventListener('ended', () => {
if (player.activeElement !== element) return;
const elapsedPlayTime = listeningTracker.getSessionSignals().accumulatedPlayTime || 0;
const trackDur = listeningTracker.getSessionSignals().trackDuration || 0;
listeningTracker.onTrackEnd();
if (player.currentTrack) {
const effectivePlayTime = elapsedPlayTime || (Date.now() - _trackPlayStartTime) / 1000;
listeningTracker.updateArtistAffinity(player.currentTrack, effectivePlayTime, trackDur, false);
}
listeningTracker.forceFlush();
_previousTrackId = null;
player.playNext();
});
@ -446,7 +477,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime);
// Log to history after 10 seconds of playback
listeningTracker.onTimeUpdate(currentTime, duration);
if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
historyLoggedTrackId = player.currentTrack.id;
const historyEntry = await db.addToHistory(player.currentTrack);
@ -518,6 +550,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
});
};
window.addEventListener('volume-change', updateVolumeUI);
setupMediaListeners(audioPlayer);
if (player.video) {
setupMediaListeners(player.video);
@ -2145,10 +2179,25 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
player.playVideo(clickedTrack);
} else {
player.setQueue([clickedTrack], 0);
player.enableAutoplay();
document.getElementById('shuffle-btn').classList.remove('active');
player.playTrackFromQueue();
api.getTrackRecommendations(clickedTrack.id).then((recs) => {
const { autoplaySettings } = await import('./storage.js');
const fetchRecs = autoplaySettings.isSmartRecsEnabled()
? (async () => {
const { smartRecommendations } = await import('./smart-recommendations.js');
const recs = await api.getTrackRecommendations(clickedTrack.id);
if (recs && recs.length > 0) {
const filtered = smartRecommendations.filterRecommendations(recs);
const ranked = smartRecommendations.rankRecommendations(filtered);
return ranked;
}
return [];
})()
: api.getTrackRecommendations(clickedTrack.id);
fetchRecs.then((recs) => {
if (recs && recs.length > 0) {
player.addToQueue(recs);
}
@ -2164,13 +2213,8 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
player.setQueue(trackList, startIndex);
player.enableAutoplay();
// Set artist popular tracks context if on artist page
console.log('[Events] Setting context:', {
page: ui.currentPage,
artistId: ui.currentArtistId,
trackCount: trackList.length,
});
if (ui.currentPage === 'artist' && ui.currentArtistId) {
player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true);
}
@ -2220,6 +2264,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (trackList.length === 0) return;
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
player.setQueue(trackList, startIndex);
player.enableAutoplay();
if (ui.currentPage === 'artist' && ui.currentArtistId) {
player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true);
}

282
js/listening-tracker.js Normal file
View file

@ -0,0 +1,282 @@
const STORAGE_KEY = 'monochrome-listening-data';
const MAX_TRACKS = 2000;
const MAX_ARTISTS = 500;
const SKIP_THRESHOLD_S = 5;
const COMPLETION_RATIO_THRESHOLD = 0.3;
class ListeningTracker {
constructor() {
this._data = null;
this._currentTrackId = null;
this._playStartTime = null;
this._lastTimeUpdate = 0;
this._accumulatedPlayTime = 0;
this._trackDuration = 0;
this._flushTimer = null;
}
_load() {
if (this._data) return this._data;
try {
const raw = localStorage.getItem(STORAGE_KEY);
this._data = raw ? JSON.parse(raw) : this._empty();
} catch {
this._data = this._empty();
}
return this._data;
}
_empty() {
return { tracks: {}, artists: {}, version: 1 };
}
_save() {
try {
const d = this._data || this._load();
const trackEntries = Object.entries(d.tracks);
if (trackEntries.length > MAX_TRACKS) {
trackEntries.sort((a, b) => (b[1].lastPlayed || 0) - (a[1].lastPlayed || 0));
d.tracks = Object.fromEntries(trackEntries.slice(0, MAX_TRACKS));
}
const artistEntries = Object.entries(d.artists);
if (artistEntries.length > MAX_ARTISTS) {
artistEntries.sort((a, b) => (b[1].affinity || 0) - (a[1].affinity || 0));
d.artists = Object.fromEntries(artistEntries.slice(0, MAX_ARTISTS));
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(d));
} catch (e) {
console.warn('ListeningTracker: save failed', e);
}
}
_flush() {
if (this._flushTimer) return;
this._flushTimer = setTimeout(() => {
this._save();
this._flushTimer = null;
}, 2000);
}
onTrackStart(track) {
if (!track || !track.id) return;
this._finalizeCurrent();
this._currentTrackId = track.id;
this._playStartTime = Date.now();
this._lastTimeUpdate = 0;
this._accumulatedPlayTime = 0;
this._trackDuration = (track.duration || 0) / 1000;
}
onTimeUpdate(currentTime, duration) {
if (!this._currentTrackId || this._playStartTime === null) return;
if (duration > 0) this._trackDuration = duration;
if (this._lastTimeUpdate > 0 && currentTime > this._lastTimeUpdate) {
const delta = currentTime - this._lastTimeUpdate;
if (delta < 5) {
this._accumulatedPlayTime += delta;
}
}
this._lastTimeUpdate = currentTime;
}
onTrackEnd() {
this._finalizeCurrent();
}
onSkip() {
if (!this._currentTrackId || this._playStartTime === null) return;
const elapsed = this._accumulatedPlayTime;
this._recordTrackSignal(this._currentTrackId, elapsed, this._trackDuration, true);
if (this._currentTrackId) {
const currentData = this._load();
const trackMeta = this._findTrackMeta(this._currentTrackId);
if (trackMeta) {
this._updateArtistAffinityFromData(currentData, trackMeta, elapsed, this._trackDuration, true);
}
}
this._currentTrackId = null;
this._playStartTime = null;
this._accumulatedPlayTime = 0;
this._lastTimeUpdate = 0;
this._flush();
}
_finalizeCurrent() {
if (!this._currentTrackId || this._playStartTime === null) return;
const elapsed = this._accumulatedPlayTime;
this._recordTrackSignal(this._currentTrackId, elapsed, this._trackDuration, false);
if (this._currentTrackId) {
const currentData = this._load();
const trackMeta = this._findTrackMeta(this._currentTrackId);
if (trackMeta) {
this._updateArtistAffinityFromData(currentData, trackMeta, elapsed, this._trackDuration, false);
}
}
this._currentTrackId = null;
this._playStartTime = null;
this._accumulatedPlayTime = 0;
this._lastTimeUpdate = 0;
this._flush();
}
_findTrackMeta(_trackId) {
return null;
}
_recordTrackSignal(trackId, playTimeS, durationS, wasSkipped) {
const d = this._load();
if (!d.tracks[trackId]) {
d.tracks[trackId] = {
playCount: 0,
skipCount: 0,
totalPlayTime: 0,
completionCount: 0,
lastPlayed: 0,
avgCompletionRatio: 0,
};
}
const t = d.tracks[trackId];
t.playCount++;
t.totalPlayTime += playTimeS;
t.lastPlayed = Date.now();
const completionRatio = durationS > 0 ? Math.min(playTimeS / durationS, 1) : 0;
t.avgCompletionRatio =
t.avgCompletionRatio === 0 ? completionRatio : t.avgCompletionRatio * 0.8 + completionRatio * 0.2;
if (wasSkipped || playTimeS < SKIP_THRESHOLD_S) {
t.skipCount++;
} else if (playTimeS >= durationS * 0.9 || completionRatio >= 0.9) {
t.completionCount++;
}
}
updateArtistAffinity(track, playTimeS, durationS, wasSkipped) {
if (!track) return;
const d = this._load();
this._updateArtistAffinityFromData(d, track, playTimeS, durationS, wasSkipped);
this._flush();
}
_updateArtistAffinityFromData(d, track, playTimeS, durationS, wasSkipped) {
const artistIds = [];
if (track.artist && track.artist.id) artistIds.push(track.artist.id);
if (track.artists && Array.isArray(track.artists)) {
for (const a of track.artists) {
if (a.id) artistIds.push(a.id);
}
}
if (artistIds.length === 0) return;
const completionRatio = durationS > 0 ? Math.min(playTimeS / durationS, 1) : 0;
const weight = wasSkipped
? -0.5
: completionRatio > 0.8
? 1.0
: completionRatio > 0.5
? 0.5
: completionRatio > COMPLETION_RATIO_THRESHOLD
? 0.2
: -0.2;
for (const artistId of artistIds) {
const name = track.artists?.find((a) => a.id === artistId)?.name || track.artist?.name || '';
if (!d.artists[artistId]) {
d.artists[artistId] = { name, affinity: 0, playCount: 0, skipCount: 0, totalPlayTime: 0 };
}
const a = d.artists[artistId];
a.affinity = a.affinity * 0.9 + weight;
a.playCount++;
a.totalPlayTime += playTimeS;
if (wasSkipped) a.skipCount++;
if (name) a.name = name;
}
}
getTrackSignal(trackId) {
const d = this._load();
return d.tracks[trackId] || null;
}
getTrackScore(trackId) {
const signal = this.getTrackSignal(trackId);
if (!signal) return 0;
const skipRate = signal.playCount > 0 ? signal.skipCount / signal.playCount : 0;
const completionRate = signal.playCount > 0 ? signal.completionCount / signal.playCount : 0;
return (
signal.avgCompletionRatio * 2 + completionRate * 3 - skipRate * 4 + Math.log2(signal.playCount + 1) * 0.5
);
}
getArtistAffinity(artistId) {
const d = this._load();
return d.artists[artistId]?.affinity || 0;
}
getTopArtists(limit = 20) {
const d = this._load();
return Object.entries(d.artists)
.filter(([, v]) => v.playCount >= 2)
.sort((a, b) => b[1].affinity - a[1].affinity)
.slice(0, limit)
.map(([id, v]) => ({ id, name: v.name, affinity: v.affinity, playCount: v.playCount }));
}
getDislikedArtists(limit = 20) {
const d = this._load();
return Object.entries(d.artists)
.filter(([, v]) => v.playCount >= 2 && v.affinity < -0.3)
.sort((a, b) => a[1].affinity - b[1].affinity)
.slice(0, limit)
.map(([id, v]) => ({ id, name: v.name, affinity: v.affinity }));
}
getHighlyPlayedTracks(limit = 50) {
const d = this._load();
return Object.entries(d.tracks)
.filter(([, v]) => v.playCount >= 2 && v.avgCompletionRatio > 0.6)
.sort((a, b) => b[1].playCount - a[1].playCount)
.slice(0, limit)
.map(([id]) => id);
}
getFrequentlySkippedTrackIds(limit = 50) {
const d = this._load();
return Object.entries(d.tracks)
.filter(([, v]) => v.playCount >= 2 && v.skipCount / v.playCount > 0.5)
.sort((a, b) => b[1].skipCount / b[1].playCount - a[1].skipCount / a[1].playCount)
.slice(0, limit)
.map(([id]) => id);
}
getShortPlayTrackIds(limit = 50) {
const d = this._load();
return Object.entries(d.tracks)
.filter(([, v]) => v.playCount >= 2 && v.avgCompletionRatio < COMPLETION_RATIO_THRESHOLD)
.sort((a, b) => a[1].avgCompletionRatio - b[1].avgCompletionRatio)
.slice(0, limit)
.map(([id]) => id);
}
getDislikedArtistIds() {
return this.getDislikedArtists(30).map((a) => a.id);
}
getSessionSignals() {
return {
currentTrackId: this._currentTrackId,
accumulatedPlayTime: this._accumulatedPlayTime,
trackDuration: this._trackDuration,
};
}
forceFlush() {
if (this._flushTimer) {
clearTimeout(this._flushTimer);
this._flushTimer = null;
}
this._save();
}
}
export const listeningTracker = new ListeningTracker();

View file

@ -1080,7 +1080,7 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage
const artist = getTrackArtists(track);
const album = track.album?.title;
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined;
const isrc = track.isrc || '';
const isrc = (track.isrc || track.mediaMetadata?.isrc || track.audioQuality?.isrc || '').trim();
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
let queryTitle = title;
@ -1101,7 +1101,7 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage
if (isrc) amLyrics.setAttribute('isrc', isrc);
amLyrics.setAttribute('highlight-color', getLyricsHighlightColor());
amLyrics.setAttribute('hover-background-color', 'rgba(59, 130, 246, 0.14)');
amLyrics.setAttribute('hover-background-color', 'color-mix(in srgb, var(--primary) 16%, transparent)');
amLyrics.setAttribute('autoscroll', '');
amLyrics.setAttribute('interpolate', '');
amLyrics.style.height = '100%';

View file

@ -245,12 +245,9 @@ export class MusicAPI {
if (this.videoArtworkCache.has(cacheKey)) {
return this.videoArtworkCache.get(cacheKey);
}
// artwork.boidu.dev developer asked us to disable his API for the time being due to rate limits.
/*
try {
/*
Maintainer of artwork.boidu.dev has asked for his API to be removed for the time being due to spam
*/
/*
const url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`;
const response = await fetch(url);
if (!response.ok) return null;
@ -261,12 +258,12 @@ export class MusicAPI {
};
this.videoArtworkCache.set(cacheKey, result);
return result;
*/
throw new Error('Video artwork is disabled for now.');
} catch (error) {
console.warn('Failed to fetch video artwork:', error);
return null;
}
*/
}
getArtistPictureUrl(id, size = '320') {
@ -277,6 +274,47 @@ export class MusicAPI {
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
}
async getArtistBanner(artistName) {
const cacheKey = `banner-${artistName}`.toLowerCase();
if (this.videoArtworkCache.has(cacheKey)) {
return this.videoArtworkCache.get(cacheKey);
}
try {
const url = `https://artwork-boidu-dev.samidy.workers.dev/artist?a=${encodeURIComponent(artistName)}`;
const response = await fetch(url);
if (!response.ok) return null;
const data = await response.json();
let hlsUrl = null;
if (data.animated) {
if (typeof data.animated === 'string') {
hlsUrl = data.animated;
} else if (typeof data.animated === 'object') {
hlsUrl = data.animated.hls || data.animated.url || data.animated.hlsUrl || data.animated.videoUrl;
if (!hlsUrl) {
for (const key in data.animated) {
if (typeof data.animated[key] === 'string' && data.animated[key].includes('.m3u8')) {
hlsUrl = data.animated[key];
break;
}
}
}
}
}
const result = {
hlsUrl: hlsUrl,
};
this.videoArtworkCache.set(cacheKey, result);
return result;
} catch (error) {
console.warn('Failed to fetch artist banner:', error);
return null;
}
}
extractStreamUrlFromManifest(manifest) {
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
}

View file

@ -16,6 +16,7 @@ import {
exponentialVolumeSettings,
audioEffectsSettings,
radioSettings,
autoplaySettings,
binauralDspSettings,
} from './storage.js';
import { audioContextManager } from './audio-context.js';
@ -24,6 +25,7 @@ import { db } from './db.js';
import { SVG_CLOCK, SVG_ATMOS } from './icons.js';
import { UIRenderer } from './ui.js';
import { MediaSession } from '@capgo/capacitor-media-session';
export class Player {
static #instance = null;
@ -161,10 +163,23 @@ export class Player {
this.isFetchingRadio = false;
this.radioFetchPromise = null;
this.autoplayEnabled = autoplaySettings.isEnabled();
this.autoplaySeeds = [];
this.isFetchingAutoplay = false;
this.autoplayFetchPromise = null;
this._recentlyPlayedIds = [];
this._maxRecentlyPlayed = 100;
this.playbackSequence = 0;
window.addEventListener('beforeunload', async () => {
await this.saveQueueState();
import('./listening-tracker.js')
.then(({ listeningTracker }) => {
listeningTracker.onTrackEnd();
listeningTracker.forceFlush();
})
.catch(() => {});
});
// Handle visibility change - AudioContext can be suspended when backgrounded
@ -188,6 +203,29 @@ export class Player {
});
this._setupVideoSync();
this._setupAnimatedCoverSync();
}
_setupAnimatedCoverSync() {
const syncPlayPause = () => {
const isPaused = this.activeElement.paused;
document.querySelectorAll('.cover, #fullscreen-cover-image').forEach((el) => {
if (el.tagName === 'VIDEO' && el !== this.video) {
if (isPaused) {
el.pause();
} else {
el.play().catch(() => {});
}
}
});
};
this.audio.addEventListener('play', syncPlayPause);
this.audio.addEventListener('pause', syncPlayPause);
if (this.video) {
this.video.addEventListener('play', syncPlayPause);
this.video.addEventListener('pause', syncPlayPause);
}
}
_setupVideoSync() {
@ -423,10 +461,8 @@ export class Player {
}
async setupMediaSession() {
if (!('mediaSession' in navigator)) return;
const setHandlers = async () => {
navigator.mediaSession.setActionHandler('play', async () => {
await MediaSession.setActionHandler({ action: 'play' }, async () => {
const el = this.activeElement;
// Initialize and resume audio context first (required for iOS lock screen)
// Must happen before audio.play() or audio won't route through Web Audio
@ -445,11 +481,11 @@ export class Player {
}
});
navigator.mediaSession.setActionHandler('pause', () => {
await MediaSession.setActionHandler({ action: 'pause' }, () => {
this.activeElement.pause();
});
navigator.mediaSession.setActionHandler('previoustrack', async () => {
await MediaSession.setActionHandler({ action: 'previoustrack' }, async () => {
// Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) {
audioContextManager.init(this.activeElement);
@ -459,7 +495,7 @@ export class Player {
this.playPrev();
});
navigator.mediaSession.setActionHandler('nexttrack', async () => {
await MediaSession.setActionHandler({ action: 'nexttrack' }, async () => {
// Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) {
audioContextManager.init(this.activeElement);
@ -470,24 +506,24 @@ export class Player {
});
if (!this.isIOS) {
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
await MediaSession.setActionHandler({ action: 'seekbackward' }, (details) => {
const skipTime = details.seekOffset || 10;
this.seekBackward(skipTime);
});
navigator.mediaSession.setActionHandler('seekforward', (details) => {
await MediaSession.setActionHandler({ action: 'seekforward' }, (details) => {
const skipTime = details.seekOffset || 10;
this.seekForward(skipTime);
});
}
navigator.mediaSession.setActionHandler('seekto', (details) => {
await MediaSession.setActionHandler({ action: 'seekto' }, (details) => {
if (details.seekTime !== undefined) {
this.activeElement.currentTime = Math.max(0, details.seekTime);
this.updateMediaSessionPositionState();
}
});
navigator.mediaSession.setActionHandler('stop', () => {
await MediaSession.setActionHandler({ action: 'stop' }, () => {
this.activeElement.pause();
this.activeElement.currentTime = 0;
this.updateMediaSessionPlaybackState();
@ -780,6 +816,46 @@ export class Player {
await this.playTrackFromQueue();
}
async updateVideoCovers(videoUrl) {
if (!videoUrl) return;
const syncCover = async (el) => {
if (!el) return;
const isPaused = this.activeElement.paused;
let videoEl;
if (el.tagName === 'IMG') {
videoEl = document.createElement('video');
videoEl.autoplay = !isPaused;
videoEl.loop = true;
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.className = el.className;
videoEl.id = el.id;
videoEl.style.objectFit = 'cover';
el.replaceWith(videoEl);
} else if (el.tagName === 'VIDEO') {
videoEl = el;
} else {
return;
}
if (UIRenderer.instance) {
await UIRenderer.instance.setupHlsVideo(videoEl, videoUrl, null);
if (isPaused) {
videoEl.pause();
} else {
videoEl.play().catch(() => {});
}
}
};
const playerBarCover = document.querySelector('.now-playing-bar .cover');
if (playerBarCover) await syncCover(playerBarCover);
const fullscreenCover = document.getElementById('fullscreen-cover-image');
if (fullscreenCover) await syncCover(fullscreenCover);
}
async playTrackFromQueue(startTime = 0, recursiveCount = 0, isRetry = false) {
if (!isRetry) {
this.isFallbackRetry = false;
@ -836,11 +912,28 @@ export class Player {
await this.saveQueueState();
this.currentTrack = track;
this.addToRecentlyPlayed(track.id);
const trackTitle = getTrackTitle(track);
const artistName = getTrackArtists(track);
const trackArtistsHTML = getTrackArtistsHTML(track);
const yearDisplay = getTrackYearDisplay(track);
if (!track.videoUrl && !track.videoCoverUrl && !track.album?.videoCoverUrl) {
this.api.getVideoArtwork(trackTitle, artistName).then((result) => {
if (this.currentTrack?.id === track.id && result && (result.videoUrl || result.hlsUrl)) {
track.videoCoverUrl = result.videoUrl || result.hlsUrl;
this.updateVideoCovers(track.videoCoverUrl);
if (
UIRenderer.instance &&
document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex'
) {
UIRenderer.instance.updateFullscreenMetadata(track, this.getNextTrack());
}
}
});
}
const trackInfo = document.querySelector('.now-playing-bar .track-info');
const coverEl = trackInfo?.querySelector('.cover:not(#audio-player):not(#video-player)');
@ -905,17 +998,31 @@ export class Player {
} else {
if (coverEl) {
coverEl.style.display = 'block';
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
const coverId = track.image || track.cover || track.album?.cover;
const coverUrl = this.api.getCoverUrl(coverId);
const coverSrcset = this.api.getCoverSrcset(coverId);
if (coverEl.getAttribute('src') !== coverUrl) {
coverEl.src = coverUrl;
if (coverSrcset) {
coverEl.setAttribute('srcset', coverSrcset);
coverEl.setAttribute('sizes', '(max-width: 640px) 160px, (max-width: 1024px) 320px, 640px');
} else {
coverEl.removeAttribute('srcset');
coverEl.removeAttribute('sizes');
const coverUrl = videoCoverUrl || this.api.getCoverUrl(coverId);
const coverSrcset = videoCoverUrl ? null : this.api.getCoverSrcset(coverId);
if (videoCoverUrl) {
this.updateVideoCovers(videoCoverUrl);
} else {
let imgEl = coverEl;
if (coverEl.tagName === 'VIDEO') {
imgEl = document.createElement('img');
imgEl.className = coverEl.className;
imgEl.id = coverEl.id;
coverEl.replaceWith(imgEl);
}
if (imgEl.getAttribute('src') !== coverUrl) {
imgEl.src = coverUrl;
if (coverSrcset) {
imgEl.setAttribute('srcset', coverSrcset);
imgEl.setAttribute('sizes', '(max-width: 640px) 160px, (max-width: 1024px) 320px, 640px');
} else {
imgEl.removeAttribute('srcset');
imgEl.removeAttribute('sizes');
}
}
}
}
@ -1243,6 +1350,15 @@ export class Player {
});
return;
}
if (this.autoplayEnabled && isLastTrack) {
this.fetchAutoplayRecommendations().then(async () => {
const updatedQueue = this.getCurrentQueue();
if (this.currentQueueIndex < updatedQueue.length - 1) {
await this.playNext(0);
}
});
return;
}
if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
if (newTracks && newTracks.length > 0) {
@ -1283,12 +1399,19 @@ export class Player {
}
});
return;
} else if (this.autoplayEnabled) {
this.fetchAutoplayRecommendations().then(async () => {
const updatedQueue = this.getCurrentQueue();
if (this.currentQueueIndex < updatedQueue.length - 1) {
await this.playNext(0);
}
});
return;
} else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) {
await this.fetchMoreArtistPopularTracks().then(async (newTracks) => {
if (newTracks && newTracks.length > 0) {
await this.addToQueue(newTracks);
}
// Now play the next track (which is now at currentQueueIndex + 1 if tracks were added)
this.currentQueueIndex++;
await this.playTrackFromQueue(0, recursiveCount);
});
@ -1374,12 +1497,20 @@ export class Player {
...favorites.map((t) => t.id),
...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
...history.map((t) => t.id),
...this._recentlyPlayedIds,
]);
const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
let recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
knownTrackIds: knownTrackIds,
});
const { autoplaySettings: _autoplaySettings } = await import('./storage.js');
if (_autoplaySettings.isSmartRecsEnabled()) {
const { smartRecommendations } = await import('./smart-recommendations.js');
recommendations = smartRecommendations.filterRecommendations(recommendations);
recommendations = smartRecommendations.rankRecommendations(recommendations);
}
if (recommendations && recommendations.length > 0) {
const currentQueueIds = new Set(this.getCurrentQueue().map((t) => t.id));
@ -1405,6 +1536,14 @@ export class Player {
}
async pickRadioSeeds() {
try {
const { smartRecommendations } = await import('./smart-recommendations.js');
const smartSeeds = await smartRecommendations.getSmartSeeds(50);
if (smartSeeds.length > 0) return smartSeeds;
} catch (e) {
console.warn('Smart seeds failed, falling back to basic seed selection:', e);
}
try {
const [history, favorites, userPlaylists] = await Promise.all([
db.getHistory(),
@ -1460,6 +1599,97 @@ export class Player {
}
}
enableAutoplay() {
this.autoplayEnabled = true;
autoplaySettings.setEnabled(true);
}
disableAutoplay() {
this.autoplayEnabled = false;
autoplaySettings.setEnabled(false);
}
addToRecentlyPlayed(trackId) {
if (!trackId) return;
this._recentlyPlayedIds = this._recentlyPlayedIds.filter((id) => id !== trackId);
this._recentlyPlayedIds.push(trackId);
if (this._recentlyPlayedIds.length > this._maxRecentlyPlayed) {
this._recentlyPlayedIds = this._recentlyPlayedIds.slice(-this._maxRecentlyPlayed);
}
}
fetchAutoplayRecommendations() {
if (this.isFetchingAutoplay) return this.autoplayFetchPromise || Promise.resolve();
this.isFetchingAutoplay = true;
this.showRadioLoading(true);
this.autoplayFetchPromise = (async () => {
try {
const { smartRecommendations } = await import('./smart-recommendations.js');
const { autoplaySettings: _autoplaySettings } = await import('./storage.js');
const currentQueue = this.getCurrentQueue();
const recentQueueTracks = currentQueue.slice(
Math.max(0, this.currentQueueIndex - 10),
this.currentQueueIndex + 1
);
const seeds = await smartRecommendations.getAdaptiveQueueSeeds(
recentQueueTracks,
this._recentlyPlayedIds,
5
);
if (seeds.length === 0) {
if (this.currentTrack) seeds.push(this.currentTrack);
else return;
}
const [favorites, userPlaylists, history] = await Promise.all([
db.getFavorites('track'),
db.getAll('user_playlists'),
db.getHistory(),
]);
const knownTrackIds = new Set([
...favorites.map((t) => t.id),
...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
...history.map((t) => t.id),
...this._recentlyPlayedIds,
...currentQueue.map((t) => t.id),
]);
let recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
knownTrackIds: knownTrackIds,
});
if (_autoplaySettings.isSmartRecsEnabled()) {
recommendations = smartRecommendations.filterRecommendations(recommendations);
recommendations = smartRecommendations.rankRecommendations(recommendations);
}
if (recommendations && recommendations.length > 0) {
const currentQueueIds = new Set(currentQueue.map((t) => t.id));
let newTracks = recommendations.filter((t) => !currentQueueIds.has(t.id));
if (newTracks.length > 0) {
const tracksToAdd = newTracks.slice(0, 5);
await this.addToQueue(tracksToAdd);
}
}
} catch (error) {
console.error('Failed to fetch autoplay recommendations:', error);
} finally {
this.isFetchingAutoplay = false;
this.autoplayFetchPromise = null;
setTimeout(() => this.showRadioLoading(false), 500);
}
})();
return this.autoplayFetchPromise;
}
playPrev(recursiveCount = 0) {
const el = this.activeElement;
if (el.currentTime > 3) {
@ -1467,7 +1697,6 @@ export class Player {
this.updateMediaSessionPositionState();
} else if (this.currentQueueIndex > 0) {
this.currentQueueIndex--;
// Skip unavailable and blocked tracks
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (recursiveCount > currentQueue.length) {
@ -1482,6 +1711,12 @@ export class Player {
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
return this.playPrev(recursiveCount + 1);
}
import('./listening-tracker.js')
.then(({ listeningTracker }) => {
listeningTracker.onSkip();
listeningTracker.forceFlush();
})
.catch(() => {});
await this.playTrackFromQueue(0, recursiveCount);
})
.catch(console.error);
@ -2091,37 +2326,37 @@ export class Player {
}
updateMediaSession(track) {
if (!('mediaSession' in navigator)) return;
// Force a refresh for picky Bluetooth systems by clearing metadata first
navigator.mediaSession.metadata = null;
const coverId = track.album?.cover;
const trackTitle = getTrackTitle(track);
navigator.mediaSession.metadata = new MediaMetadata({
title: trackTitle || 'Unknown Title',
artist: getTrackArtists(track) || 'Unknown Artist',
album: track.album?.title || 'Unknown Album',
artwork: coverId
? [
{
src: this.api.getCoverUrl(coverId, '1280'),
sizes: '1280x1280',
type: 'image/jpeg',
},
]
: undefined,
});
this.updateMediaSessionPlaybackState();
this.updateMediaSessionPositionState();
// Force a refresh for picky Bluetooth systems by clearing metadata first
MediaSession.setMetadata({})
.finally(() =>
MediaSession.setMetadata({
title: trackTitle || 'Unknown Title',
artist: getTrackArtists(track) || 'Unknown Artist',
album: track.album?.title || 'Unknown Album',
artwork: coverId
? [
{
src: this.api.getCoverUrl(coverId, '1280'),
sizes: '1280x1280',
type: 'image/jpeg',
},
]
: undefined,
})
)
.catch(() => {})
.finally(() => {
this.updateMediaSessionPlaybackState();
this.updateMediaSessionPositionState();
});
}
updateMediaSessionPlaybackState() {
if (!('mediaSession' in navigator)) return;
const isPlaying = !this.activeElement.paused;
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
void MediaSession.setPlaybackState({ playbackState: isPlaying ? 'playing' : 'paused' });
// Start/stop Android foreground service to prevent background audio throttling
this._updateBackgroundAudioService(isPlaying);
@ -2158,9 +2393,6 @@ export class Player {
}
updateMediaSessionPositionState() {
if (!('mediaSession' in navigator)) return;
if (!('setPositionState' in navigator.mediaSession)) return;
const el = this.activeElement;
const duration = el.duration;
@ -2168,15 +2400,13 @@ export class Player {
return;
}
try {
navigator.mediaSession.setPositionState({
duration: duration,
playbackRate: el.playbackRate || 1,
position: Math.min(el.currentTime, duration),
});
} catch (error) {
MediaSession.setPositionState({
duration: duration,
playbackRate: el.playbackRate || 1,
position: Math.min(el.currentTime, duration),
}).catch((error) => {
console.log('Failed to update Media Session position:', error);
}
});
}
async safePlay(element = this.activeElement) {

View file

@ -114,6 +114,9 @@ export function createRouter(ui) {
case 'home':
await ui.renderHomePage();
break;
case 'reset-password':
await ui.renderResetPasswordPage();
break;
case 'donate':
ui.showPage('donate');
break;

View file

@ -8,6 +8,7 @@ import {
backgroundSettings,
dynamicColorSettings,
cardSettings,
artistBannerSettings,
waveformSettings,
replayGainSettings,
downloadQualitySettings,
@ -5675,6 +5676,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
// Artist Banners Toggle
const artistBannersToggle = document.getElementById('artist-banners-toggle');
if (artistBannersToggle) {
artistBannersToggle.checked = artistBannerSettings.isEnabled();
artistBannersToggle.addEventListener('change', (e) => {
artistBannerSettings.setEnabled(e.target.checked);
});
}
// Compact Album Toggle
const compactAlbumToggle = document.getElementById('compact-album-toggle');
if (compactAlbumToggle) {

171
js/smart-recommendations.js Normal file
View file

@ -0,0 +1,171 @@
import { listeningTracker } from './listening-tracker.js';
import { db } from './db.js';
class SmartRecommendations {
async getSmartSeeds(count = 50) {
const [history, favorites, playlists] = await Promise.all([
db.getHistory(),
db.getFavorites('track'),
db.getPlaylists(true),
]);
const playlistTracks = playlists.flatMap((p) => p.tracks || []);
const scoredTracks = new Map();
const addWithScore = (tracks, baseWeight) => {
for (const t of tracks) {
if (!t || !t.id) continue;
const signalScore = listeningTracker.getTrackScore(t.id);
const completionBonus = this._getCompletionBonus(t.id);
const score = baseWeight + signalScore + completionBonus;
const existing = scoredTracks.get(t.id);
if (existing) {
existing.score += score;
existing.track = t;
} else {
scoredTracks.set(t.id, { score, track: t });
}
}
};
addWithScore(favorites, 3);
addWithScore(playlistTracks, 2);
addWithScore(history, 1);
const sorted = [...scoredTracks.values()].sort((a, b) => b.score - a.score);
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
const filteredSeeds = sorted
.filter((s) => {
const t = s.track;
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) return false;
const signal = listeningTracker.getTrackSignal(t.id);
if (signal && signal.playCount >= 3 && signal.avgCompletionRatio < 0.2) return false;
return true;
})
.slice(0, count)
.map((s) => s.track);
const shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
return shuffle(filteredSeeds);
}
_getCompletionBonus(trackId) {
const signal = listeningTracker.getTrackSignal(trackId);
if (!signal) return 0;
if (signal.avgCompletionRatio > 0.8) return 2;
if (signal.avgCompletionRatio > 0.5) return 1;
if (signal.avgCompletionRatio < 0.2 && signal.playCount >= 2) return -3;
return 0;
}
_isTrackByDislikedArtist(track, dislikedArtistIds) {
if (!track || dislikedArtistIds.size === 0) return false;
if (track.artist?.id && dislikedArtistIds.has(String(track.artist.id))) return true;
if (track.artists?.some((a) => a.id && dislikedArtistIds.has(String(a.id)))) return true;
return false;
}
filterRecommendations(tracks) {
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
const frequentlySkippedIds = new Set(listeningTracker.getFrequentlySkippedTrackIds(100));
const shortPlayIds = new Set(listeningTracker.getShortPlayTrackIds(100));
return tracks.filter((t) => {
if (!t || !t.id) return false;
if (frequentlySkippedIds.has(t.id)) return false;
if (shortPlayIds.has(t.id)) return false;
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) return false;
return true;
});
}
scoreRecommendation(track) {
if (!track) return 0;
let score = 0;
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
const topArtists = listeningTracker.getTopArtists(30);
const topArtistIds = new Set(topArtists.map((a) => a.id));
if (track.artist?.id && topArtistIds.has(String(track.artist.id))) {
const artist = topArtists.find((a) => a.id === String(track.artist.id));
score += artist ? Math.min(artist.affinity * 2, 5) : 1;
}
if (track.artists?.some((a) => a.id && topArtistIds.has(String(a.id)))) {
score += 1;
}
if (this._isTrackByDislikedArtist(track, dislikedArtistIds)) {
score -= 5;
}
const skipIds = new Set(listeningTracker.getFrequentlySkippedTrackIds(50));
if (skipIds.has(track.id)) score -= 3;
return score;
}
rankRecommendations(tracks) {
return tracks
.map((t) => ({ track: t, score: this.scoreRecommendation(t) }))
.sort((a, b) => b.score - a.score)
.map((t) => t.track);
}
async getAdaptiveQueueSeeds(currentQueueTracks, recentlyPlayedIds, count = 5) {
const topArtistIds = new Set(listeningTracker.getTopArtists(20).map((a) => a.id));
const queueArtistIds = new Set();
for (const t of currentQueueTracks) {
if (t.artist?.id) queueArtistIds.add(String(t.artist.id));
if (t.artists)
t.artists.forEach((a) => {
if (a.id) queueArtistIds.add(String(a.id));
});
}
const currentArtistIds = new Set();
for (const id of queueArtistIds) {
if (topArtistIds.has(id)) currentArtistIds.add(id);
}
const recentTrackIds = new Set(recentlyPlayedIds);
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
const scoredTracks = [];
for (const t of currentQueueTracks) {
if (!t || recentTrackIds.has(t.id)) continue;
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) continue;
const signal = listeningTracker.getTrackSignal(t.id);
const completionRatio = signal ? signal.avgCompletionRatio : 0.5;
const score = completionRatio;
scoredTracks.push({ track: t, score });
}
scoredTracks.sort((a, b) => b.score - a.score);
const bestSeeds = scoredTracks.slice(0, Math.ceil(count / 2)).map((s) => s.track);
if (bestSeeds.length < count) {
const smartSeeds = await this.getSmartSeeds(20);
const additional = smartSeeds.filter((s) => {
if (recentTrackIds.has(s.id)) return false;
return !bestSeeds.some((b) => b.id === s.id);
});
bestSeeds.push(...additional.slice(0, count - bestSeeds.length));
}
return bestSeeds.slice(0, count);
}
getKnownBadTrackIds() {
const skipped = new Set(listeningTracker.getFrequentlySkippedTrackIds(100));
const shortPlay = new Set(listeningTracker.getShortPlayTrackIds(100));
return new Set([...skipped, ...shortPlay]);
}
getKnownBadArtistIds() {
return new Set(listeningTracker.getDislikedArtistIds(30));
}
}
export const smartRecommendations = new SmartRecommendations();

View file

@ -687,6 +687,23 @@ export const cardSettings = {
},
};
export const artistBannerSettings = {
STORAGE_KEY: 'artist-banners-enabled',
isEnabled() {
try {
const val = localStorage.getItem(this.STORAGE_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
},
};
export const replayGainSettings = {
STORAGE_KEY_MODE: 'replay-gain-mode', // 'off', 'track', 'album'
STORAGE_KEY_PREAMP: 'replay-gain-preamp',
@ -2354,6 +2371,37 @@ export const radioSettings = {
},
};
export const autoplaySettings = {
ENABLED_KEY: 'autoplay-enabled',
SMART_RECS_KEY: 'smart-recommendations-enabled',
isEnabled() {
try {
const val = localStorage.getItem(this.ENABLED_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setEnabled(enabled) {
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
},
isSmartRecsEnabled() {
try {
const val = localStorage.getItem(this.SMART_RECS_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setSmartRecsEnabled(enabled) {
localStorage.setItem(this.SMART_RECS_KEY, enabled ? 'true' : 'false');
},
};
export const analyticsSettings = {
ENABLED_KEY: 'analytics-enabled',
@ -2602,6 +2650,8 @@ export const fontSettings = {
FONT_SIZE_KEY: 'monochrome-font-size',
FONT_LINK_ID: 'monochrome-dynamic-font',
FONT_FACE_ID: 'monochrome-dynamic-fontface',
NOTO_FALLBACK:
"'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali', 'Noto Sans Thai', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada', 'Noto Sans Malayalam', 'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Georgian', 'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, sans-serif",
getDefaultConfig() {
return {
@ -2717,7 +2767,7 @@ export const fontSettings = {
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
});
document.documentElement.style.setProperty('--font-family', `'${familyName}', sans-serif`);
document.documentElement.style.setProperty('--font-family', `'${familyName}', ${this.NOTO_FALLBACK}`);
},
async loadFontFromUrl(url, familyName) {
@ -2752,7 +2802,7 @@ export const fontSettings = {
weights: weights,
});
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', sans-serif`);
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', ${this.NOTO_FALLBACK}`);
},
getFontFormat(url) {
@ -2835,7 +2885,7 @@ export const fontSettings = {
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
});
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', sans-serif`);
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', ${this.NOTO_FALLBACK}`);
},
deleteUploadedFont(fontId) {
@ -2862,7 +2912,7 @@ export const fontSettings = {
weights: [400, 500, 600, 700, 800],
});
const fontValue = family === 'monospace' ? 'monospace' : `'${family}', ${fallback}`;
const fontValue = family === 'monospace' ? 'monospace' : `'${family}', ${this.NOTO_FALLBACK}`;
document.documentElement.style.setProperty('--font-family', fontValue);
},
@ -2898,7 +2948,7 @@ export const fontSettings = {
weights: [400, 500, 600, 700],
});
document.documentElement.style.setProperty('--font-family', "'SF Pro Display', sans-serif");
document.documentElement.style.setProperty('--font-family', `'SF Pro Display', ${this.NOTO_FALLBACK}`);
},
async applyFont() {

View file

@ -712,7 +712,7 @@ export class ThemeStore {
--highlight: #3b82f6;
--ring: #3b82f6;
--radius: 8px;
--font-family: 'Inter', sans-serif;
--font-family: 'Inter', 'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali', 'Noto Sans Thai', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada', 'Noto Sans Malayalam', 'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Georgian', 'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, sans-serif;
--font-size-scale: 100%;
}`;
this.updatePreview();

159
js/ui.js
View file

@ -29,6 +29,7 @@ import {
contentBlockingSettings,
settingsUiState,
fullscreenCoverNoRoundSettings,
artistBannerSettings,
} from './storage.js';
import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js';
@ -1278,18 +1279,35 @@ export class UIRenderer {
const currentImage = document.getElementById('fullscreen-cover-image');
if (videoCoverUrl) {
const isPaused = this.player?.activeElement?.paused ?? true;
if (currentImage.tagName === 'IMG') {
const video = document.createElement('video');
video.src = videoCoverUrl;
video.autoplay = true;
video.autoplay = !isPaused;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = currentImage.className;
video.id = currentImage.id;
video.style.objectFit = 'cover';
currentImage.replaceWith(video);
if (!isPaused) {
video.play().catch(() => {});
}
} else if (currentImage.src !== videoCoverUrl) {
currentImage.src = videoCoverUrl;
if (!isPaused) {
currentImage.play().catch(() => {});
} else {
currentImage.pause();
}
} else {
if (!isPaused) {
currentImage.play().catch(() => {});
} else {
currentImage.pause();
}
}
} else {
if (currentImage.tagName === 'VIDEO') {
@ -2262,6 +2280,7 @@ export class UIRenderer {
});
this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI);
window.addEventListener('volume-change', updateFsVolumeUI);
updateFsVolumeUI();
}
@ -2374,6 +2393,72 @@ export class UIRenderer {
}
}
async renderResetPasswordPage() {
await this.showPage('reset-password');
const form = document.getElementById('reset-password-form');
const errorEl = document.getElementById('reset-password-error');
const successEl = document.getElementById('reset-password-success');
const btn = document.getElementById('reset-password-submit-btn');
const btnText = document.getElementById('reset-password-btn-text');
const spinner = document.getElementById('reset-password-btn-spinner');
const passwordInput = document.getElementById('reset-password-input');
const confirmInput = document.getElementById('reset-password-confirm');
if (!form) return;
const params = new URLSearchParams(window.location.search);
const userId = params.get('userId');
const secret = params.get('secret');
if (!userId || !secret) {
errorEl.textContent = 'Invalid or missing password reset link.';
errorEl.style.display = 'block';
form.style.display = 'none';
return;
}
form.onsubmit = async (e) => {
e.preventDefault();
errorEl.style.display = 'none';
successEl.style.display = 'none';
const password = passwordInput.value;
const confirm = confirmInput.value;
if (password !== confirm) {
errorEl.textContent = 'Passwords do not match.';
errorEl.style.display = 'block';
return;
}
try {
btn.disabled = true;
btnText.style.display = 'none';
spinner.style.display = 'block';
await authManager.resetPassword(userId, secret, password, confirm);
successEl.textContent = 'Password reset successfully. Opening login...';
successEl.style.display = 'block';
form.style.display = 'none';
setTimeout(() => {
const authModal = document.getElementById('email-auth-modal');
if (authModal) {
authModal.classList.add('active');
}
}, 2000);
} catch (error) {
errorEl.textContent = error.message || 'Failed to reset password. Please try again.';
errorEl.style.display = 'block';
} finally {
btn.disabled = false;
btnText.style.display = 'inline';
spinner.style.display = 'none';
}
};
}
async renderPartyDetailPage(id) {
await this.showPage('party-detail');
await partyManager.joinParty(id);
@ -2915,13 +3000,22 @@ export class UIRenderer {
}
async getSeeds() {
try {
const { smartRecommendations } = await import('./smart-recommendations.js');
const { autoplaySettings } = await import('./storage.js');
if (autoplaySettings.isSmartRecsEnabled()) {
const smartSeeds = await smartRecommendations.getSmartSeeds(50);
if (smartSeeds.length > 0) return smartSeeds;
}
} catch (e) {
console.warn('Smart seeds failed, using basic seeds:', e);
}
const history = await db.getHistory();
const favorites = await db.getFavorites('track');
const playlists = await db.getPlaylists(true);
const playlistTracks = playlists.flatMap((p) => p.tracks || []);
// Prioritize: Playlists > Favorites > History
// Take random samples from each to form seeds
const shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
const combined = [
@ -2955,7 +3049,7 @@ export class UIRenderer {
if (forceRefresh || songsContainer.children.length === 0) {
songsContainer.innerHTML = this.createSkeletonTracks(10, true);
} else if (!songsContainer.querySelector('.skeleton')) {
return; // Already loaded
return;
}
try {
@ -2972,11 +3066,22 @@ export class UIRenderer {
...history.map((t) => t.id),
]);
const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
skipCache: forceRefresh,
knownTrackIds: knownTrackIds,
});
try {
const { smartRecommendations } = await import('./smart-recommendations.js');
const { autoplaySettings } = await import('./storage.js');
if (autoplaySettings.isSmartRecsEnabled()) {
recommendedTracks = smartRecommendations.filterRecommendations(recommendedTracks);
recommendedTracks = smartRecommendations.rankRecommendations(recommendedTracks);
}
} catch (e) {
console.warn('Smart filtering failed for home songs:', e);
}
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
this.lastRecommendedTracks = filteredTracks;
@ -4796,6 +4901,16 @@ export class UIRenderer {
await this.showPage('artist');
this.currentArtistId = artistId;
const bannerContainer = document.getElementById('artist-detail-banner-container');
if (bannerContainer) {
const oldVideo = bannerContainer.querySelector('video');
if (oldVideo && oldVideo._hls) {
oldVideo._hls.destroy();
}
bannerContainer.innerHTML = '';
bannerContainer.style.opacity = '0';
}
const imageEl = document.getElementById('artist-detail-image');
const nameEl = document.getElementById('artist-detail-name');
const metaEl = document.getElementById('artist-detail-meta');
@ -4844,6 +4959,39 @@ export class UIRenderer {
try {
const artist = await this.api.getArtist(artistId, provider);
const currentId = this.currentArtistId;
this.api
.getArtistBanner(artist.name)
.then(async (banner) => {
if (this.currentArtistId !== currentId) return;
if (banner && banner.hlsUrl && bannerContainer) {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.setAttribute('muted', '');
video.setAttribute('autoplay', '');
video.setAttribute('playsinline', '');
video.style.opacity = '1';
try {
await this.setupHlsVideo(video, banner, null);
if (this.currentArtistId === currentId) {
bannerContainer.appendChild(video);
bannerContainer.style.opacity = '1';
video.play().catch(() => {});
}
} catch (e) {
console.warn('Failed to setup artist banner video:', e);
}
}
})
.catch((e) => {
console.warn('Failed to fetch artist banner:', e);
});
// Handle Biography
if (bioEl) {
// Pre-define regex patterns for better performance
@ -6135,6 +6283,7 @@ export class UIRenderer {
playBtn.onclick = () => {
this.player.setQueue([track], 0);
this.player.enableAutoplay();
this.player.playTrackFromQueue();
};

View file

@ -25,9 +25,11 @@
"type": "git",
"url": "git+https://github.com/monochrome-music/monochrome.git"
},
"keywords": [],
"keywords": [
"music"
],
"author": "",
"license": "ISC",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/monochrome-music/monochrome/issues"
},
@ -68,6 +70,7 @@
"@capacitor/core": "^8.2.0",
"@capacitor/haptics": "^8.0.1",
"@capacitor/ios": "^8.2.0",
"@capgo/capacitor-media-session": "^8.0.19",
"@dantheman827/taglib-ts": "^0.1.5",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
@ -76,7 +79,7 @@
"@svta/common-media-library": "^0.18.1",
"@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@uimaxbai/am-lyrics": "^1.1.8",
"@uimaxbai/am-lyrics": "^1.2.8",
"@vitest/web-worker": "^4.1.2",
"appwrite": "^23.0.0",
"butterchurn": "^2.6.7",

View file

@ -24,4 +24,5 @@ album:103897783
album:151728406
album:199412873
album:3280432
album:37927851
album:37927851
album:18083938

View file

@ -16,6 +16,15 @@
body {
font-family:
'Inter',
'Noto Sans SC',
'Noto Sans TC',
'Noto Sans JP',
'Noto Sans KR',
'Noto Sans Hebrew',
'Noto Sans Arabic',
'Noto Sans Devanagari',
'Noto Sans Thai',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',

View file

@ -1,7 +1,13 @@
:root {
color-scheme: light dark;
--font-family: 'Inter', -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, sans-serif;
--font-family:
'Inter', 'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR',
'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali', 'Noto Sans Thai',
'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada', 'Noto Sans Malayalam',
'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Georgian',
'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, -apple-system, blinkmacsystemfont, 'Segoe UI', roboto,
sans-serif;
--font-size-scale: 100%;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
@ -2542,6 +2548,134 @@ body.multi-select-mode .track-item:hover {
border-radius: var(--radius-full);
}
.detail-header-banner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
opacity: 0;
transition: opacity 1s ease-in-out;
}
.detail-header-banner video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.6);
display: block;
}
#page-artist .detail-header {
position: relative;
padding: 12rem 3rem 4rem;
border-radius: 0;
overflow: hidden;
margin-top: -8rem;
margin-left: calc(var(--spacing-xl) * -1);
margin-right: calc(var(--spacing-xl) * -1);
margin-bottom: var(--spacing-xl);
min-height: 550px;
display: flex;
align-items: flex-end;
}
@media (max-width: 1024px) {
#page-artist .detail-header {
margin-top: -7rem;
margin-left: calc(var(--spacing-lg) * -1);
margin-right: calc(var(--spacing-lg) * -1);
padding: 10rem 2rem 3rem;
min-height: 450px;
}
}
@media (max-width: 768px) {
#page-artist .detail-header {
margin-top: -6rem;
margin-left: calc(var(--spacing-md) * -1);
margin-right: calc(var(--spacing-md) * -1);
padding: 8rem 1rem 2rem;
min-height: 400px;
}
}
@media (max-width: 480px) {
#page-artist .detail-header {
margin-top: -5rem;
margin-left: -1rem;
margin-right: -1rem;
padding: 5rem 1rem 2rem;
min-height: 300px;
flex-direction: column;
align-items: center;
text-align: center;
gap: 1.25rem;
}
#page-artist .detail-header-image {
width: 140px;
height: 140px;
border-width: 3px;
}
#page-artist .detail-header-info .title {
font-size: 2rem;
justify-content: center;
gap: 0.5rem;
}
#page-artist .detail-header-info .meta {
justify-content: center;
font-size: 0.85rem;
}
#page-artist .detail-header-actions {
justify-content: center;
margin-top: 1rem;
}
#page-artist .artist-bio {
text-align: center;
margin-left: auto;
margin-right: auto;
font-size: 0.85rem;
line-height: 1.4;
}
}
.detail-header-banner::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgb(0, 0, 0, 0.4) 0%,
rgb(0, 0, 0, 0) 40%,
rgb(0, 0, 0, 0.2) 70%,
var(--background) 100%
);
z-index: 1;
}
#page-artist .detail-header-image {
width: 200px;
height: 200px;
border: 4px solid var(--background);
box-shadow: 0 12px 32px rgb(0, 0, 0, 0.6);
z-index: 2;
}
#page-artist .detail-header-info {
z-index: 2;
text-shadow: 0 2px 15px rgb(0, 0, 0, 0.7);
}
.detail-header-info .type {
font-weight: 600;
margin-bottom: 0.5rem;
@ -10783,7 +10917,12 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
height: 100%;
width: 100%;
font-family: 'SF Pro Display', Inter, sans-serif;
font-family:
'SF Pro Display', Inter, 'Noto Sans', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', 'Noto Sans JP',
'Noto Sans KR', 'Noto Sans Hebrew', 'Noto Sans Arabic', 'Noto Sans Devanagari', 'Noto Sans Bengali',
'Noto Sans Thai', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Gujarati', 'Noto Sans Kannada',
'Noto Sans Malayalam', 'Noto Sans Sinhala', 'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar',
'Noto Sans Georgian', 'Noto Sans Armenian', 'Noto Sans Ethiopic', system-ui, sans-serif;
--lyplus-font-size-base: clamp(34px, 3vw, 52px);
--lyplus-padding-line: 8px;