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/ auth_storage/
www www
neutralino.js 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.config.json
app/src/main/assets/capacitor.plugins.json app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml 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 { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-haptics') 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) { public void stop(PluginCall call) {
try { try {
Intent intent = new Intent(getContext(), AudioPlaybackService.class); 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' include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') 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/core": "^8.2.0",
"@capacitor/haptics": "^8.0.1", "@capacitor/haptics": "^8.0.1",
"@capacitor/ios": "^8.2.0", "@capacitor/ios": "^8.2.0",
"@capgo/capacitor-media-session": "^8.0.19",
"@dantheman827/taglib-ts": "^0.1.5", "@dantheman827/taglib-ts": "^0.1.5",
"@ffmpeg/core": "^0.12.10", "@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
@ -18,7 +19,7 @@
"@svta/common-media-library": "^0.18.1", "@svta/common-media-library": "^0.18.1",
"@types/wicg-file-system-access": "^2023.10.7", "@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/eslint-plugin": "^8.57.2", "@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", "@vitest/web-worker": "^4.1.2",
"appwrite": "^23.0.0", "appwrite": "^23.0.0",
"butterchurn": "^2.6.7", "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=="], "@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-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=="], "@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=="], "@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=="], "@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) { export async function onRequest(context) {
const { request } = context; const { request, env } = context;
const pageUrl = request.url; 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) {
<!DOCTYPE html> const pageUrl = request.url;
<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 property="og:site_name" content="Monochrome"> const metaHtml = `
<meta property="og:title" content="Monochrome Music | About"> <!DOCTYPE html>
<meta property="og:description" content="A minimalist music streaming application"> <html lang="en">
<meta property="og:type" content="website"> <head>
<meta property="og:url" content="${pageUrl}"> <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 property="og:site_name" content="Monochrome">
<meta name="twitter:title" content="Monochrome Music | About"> <meta property="og:title" content="Monochrome Music | About">
<meta name="twitter:description" content="A minimalist music streaming application"> <meta property="og:description" content="A minimalist music streaming application">
</head> <meta property="og:type" content="website">
<body> <meta property="og:url" content="${pageUrl}">
<h1>Monochrome Music | About</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, { <meta name="twitter:card" content="summary">
headers: { 'content-type': 'text/html;charset=UTF-8' }, <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) { export async function onRequest(context) {
const { request } = context; const { request, env } = context;
const pageUrl = request.url; 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) {
<!DOCTYPE html> const pageUrl = request.url;
<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 property="og:site_name" content="Monochrome"> const metaHtml = `
<meta property="og:title" content="Monochrome Music | Donate"> <!DOCTYPE html>
<meta property="og:description" content="A minimalist music streaming application"> <html lang="en">
<meta property="og:type" content="website"> <head>
<meta property="og:url" content="${pageUrl}"> <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 property="og:site_name" content="Monochrome">
<meta name="twitter:title" content="Monochrome Music | Donate"> <meta property="og:title" content="Monochrome Music | Donate">
<meta name="twitter:description" content="A minimalist music streaming application"> <meta property="og:description" content="A minimalist music streaming application">
</head> <meta property="og:type" content="website">
<body> <meta property="og:url" content="${pageUrl}">
<h1>Monochrome Music | Donate</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, { <meta name="twitter:card" content="summary">
headers: { 'content-type': 'text/html;charset=UTF-8' }, <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) { export async function onRequest(context) {
const { request } = context; const { request, env } = context;
const pageUrl = request.url; 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) {
<!DOCTYPE html> const pageUrl = request.url;
<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 property="og:site_name" content="Monochrome"> const metaHtml = `
<meta property="og:title" content="Monochrome Music | Library"> <!DOCTYPE html>
<meta property="og:description" content="A minimalist music streaming application"> <html lang="en">
<meta property="og:type" content="website"> <head>
<meta property="og:url" content="${pageUrl}"> <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 property="og:site_name" content="Monochrome">
<meta name="twitter:title" content="Monochrome Music | Library"> <meta property="og:title" content="Monochrome Music | Library">
<meta name="twitter:description" content="A minimalist music streaming application"> <meta property="og:description" content="A minimalist music streaming application">
</head> <meta property="og:type" content="website">
<body> <meta property="og:url" content="${pageUrl}">
<h1>Monochrome Music | Library</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, { <meta name="twitter:card" content="summary">
headers: { 'content-type': 'text/html;charset=UTF-8' }, <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) { export async function onRequest(context) {
const { request } = context; const { request, env } = context;
const pageUrl = request.url; 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) {
<!DOCTYPE html> const pageUrl = request.url;
<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 property="og:site_name" content="Monochrome"> const metaHtml = `
<meta property="og:title" content="Monochrome Music | Listening Parties"> <!DOCTYPE html>
<meta property="og:description" content="Listen to music with your friends"> <html lang="en">
<meta property="og:type" content="website"> <head>
<meta property="og:url" content="${pageUrl}"> <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 property="og:site_name" content="Monochrome">
<meta name="twitter:title" content="Monochrome Music | Listening Parties"> <meta property="og:title" content="Monochrome Music | Listening Parties">
<meta name="twitter:description" content="Listen to music with your friends"> <meta property="og:description" content="Listen to music with your friends">
</head> <meta property="og:type" content="website">
<body> <meta property="og:url" content="${pageUrl}">
<h1>Monochrome Music | Listening Parties</h1>
<p>Listen to music with your friends</p>
</body>
</html>
`;
return new Response(metaHtml, { <meta name="twitter:card" content="summary">
headers: { 'content-type': 'text/html;charset=UTF-8' }, <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) { export async function onRequest(context) {
const { request } = context; const { request, env } = context;
const pageUrl = request.url; 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) {
<!DOCTYPE html> const pageUrl = request.url;
<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 property="og:site_name" content="Monochrome"> const metaHtml = `
<meta property="og:title" content="Monochrome Music | Recent"> <!DOCTYPE html>
<meta property="og:description" content="A minimalist music streaming application"> <html lang="en">
<meta property="og:type" content="website"> <head>
<meta property="og:url" content="${pageUrl}"> <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 property="og:site_name" content="Monochrome">
<meta name="twitter:title" content="Monochrome Music | Recent"> <meta property="og:title" content="Monochrome Music | Recent">
<meta name="twitter:description" content="A minimalist music streaming application"> <meta property="og:description" content="A minimalist music streaming application">
</head> <meta property="og:type" content="website">
<body> <meta property="og:url" content="${pageUrl}">
<h1>Monochrome Music | Recent</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, { <meta name="twitter:card" content="summary">
headers: { 'content-type': 'text/html;charset=UTF-8' }, <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) { export async function onRequest(context) {
const { request } = context; const { request, env } = context;
const pageUrl = request.url; 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) {
<!DOCTYPE html> const pageUrl = request.url;
<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 property="og:site_name" content="Monochrome"> const metaHtml = `
<meta property="og:title" content="Monochrome Music | Settings"> <!DOCTYPE html>
<meta property="og:description" content="A minimalist music streaming application"> <html lang="en">
<meta property="og:type" content="website"> <head>
<meta property="og:url" content="${pageUrl}"> <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 property="og:site_name" content="Monochrome">
<meta name="twitter:title" content="Monochrome Music | Settings"> <meta property="og:title" content="Monochrome Music | Settings">
<meta name="twitter:description" content="A minimalist music streaming application"> <meta property="og:description" content="A minimalist music streaming application">
</head> <meta property="og:type" content="website">
<body> <meta property="og:url" content="${pageUrl}">
<h1>Monochrome Music | Settings</h1>
<p>A minimalist music streaming application</p>
</body>
</html>
`;
return new Response(metaHtml, { <meta name="twitter:card" content="summary">
headers: { 'content-type': 'text/html;charset=UTF-8' }, <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) { export async function onRequest(context) {
const { request } = context; const { request, env } = context;
const pageUrl = request.url; 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -28,7 +35,12 @@ export async function onRequest(context) {
</html> </html>
`; `;
return new Response(metaHtml, { return new Response(metaHtml, {
headers: { 'content-type': 'text/html;charset=UTF-8' }, 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 --> <!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin /> <link rel="preconnect" href="https://api.fonts.coollabs.io" crossorigin />
<link rel="preconnect" href="https://resources.tidal.com" 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="apple-touch-icon" href="/assets/logo.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
@ -96,6 +98,18 @@
rel="stylesheet" rel="stylesheet"
/> />
</noscript> </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" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
@ -2603,6 +2617,7 @@
<div id="page-artist" class="page"> <div id="page-artist" class="page">
<header class="detail-header"> <header class="detail-header">
<div id="artist-detail-banner-container" class="detail-header-banner"></div>
<img <img
id="artist-detail-image" id="artist-detail-image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
@ -3392,6 +3407,16 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </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="setting-item">
<div class="info"> <div class="info">
<span class="label">Compact Albums</span> <span class="label">Compact Albums</span>
@ -5919,6 +5944,111 @@
</div> </div>
</div> </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> </main>
<footer class="now-playing-bar"> <footer class="now-playing-bar">

View file

@ -11,9 +11,10 @@ let package = Package(
targets: ["CapApp-SPM"]) targets: ["CapApp-SPM"])
], ],
dependencies: [ 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: "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: [ targets: [
.target( .target(
@ -22,7 +23,8 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"), .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'; const isOAuthRedirect = params.get('oauth') === '1';
if (userId && secret && userId !== 'null' && secret !== 'null') { if (userId && secret && userId !== 'null' && secret !== 'null') {
try { if (window.location.pathname !== '/reset-password') {
await auth.createSession(userId, secret); try {
window.history.replaceState({}, '', window.location.pathname); await auth.createSession(userId, secret);
} catch (error) { window.history.replaceState({}, '', window.location.pathname);
console.warn('OAuth session handoff failed:', error.message); } catch (error) {
window.history.replaceState({}, '', window.location.pathname); console.warn('OAuth session handoff failed:', error.message);
window.history.replaceState({}, '', window.location.pathname);
}
} }
} else if (isOAuthRedirect) { } else if (isOAuthRedirect) {
await new Promise((resolve) => setTimeout(resolve, 500)); 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() { async signOut() {
try { try {
await auth.deleteSession('current'); 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( initializeTrackInteractions(
Player.instance, Player.instance,
MusicAPI.instance, MusicAPI.instance,
@ -1087,6 +1087,7 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
Player.instance.setQueue(sortedTracks, 0); Player.instance.setQueue(sortedTracks, 0);
Player.instance.enableAutoplay();
const shuffleBtn = document.getElementById('shuffle-btn'); const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active'); if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false; Player.instance.shuffleActive = false;
@ -1118,6 +1119,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (tracks && tracks.length > 0) { if (tracks && tracks.length > 0) {
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5); const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
Player.instance.setQueue(shuffledTracks, 0); Player.instance.setQueue(shuffledTracks, 0);
Player.instance.enableAutoplay();
const shuffleBtn = document.getElementById('shuffle-btn'); const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active'); if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false; Player.instance.shuffleActive = false;
@ -1186,6 +1188,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5); const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5);
Player.instance.setQueue(shuffledTracks, 0); Player.instance.setQueue(shuffledTracks, 0);
Player.instance.enableAutoplay();
const shuffleBtn = document.getElementById('shuffle-btn'); const shuffleBtn = document.getElementById('shuffle-btn');
if (shuffleBtn) shuffleBtn.classList.remove('active'); if (shuffleBtn) shuffleBtn.classList.remove('active');
Player.instance.shuffleActive = false; Player.instance.shuffleActive = false;
@ -2730,7 +2733,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const headerAccountIcon = document.getElementById('header-account-icon'); const headerAccountIcon = document.getElementById('header-account-icon');
// Temporarily disable accounts - show popup // Temporarily disable accounts - show popup
const isAccountsDisabled = false; const isAccountsDisabled = true;
if (headerAccountBtn && headerAccountDropdown) { if (headerAccountBtn && headerAccountDropdown) {
if (isAccountsDisabled) { if (isAccountsDisabled) {
@ -2740,7 +2743,7 @@ document.addEventListener('DOMContentLoaded', async () => {
headerAccountBtn.addEventListener('click', (e) => { headerAccountBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
alert( 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 { } else {

View file

@ -826,6 +826,7 @@ class AudioContextManager {
if (this.volumeNode && this.audioContext) { if (this.volumeNode && this.audioContext) {
const now = this.audioContext.currentTime; const now = this.audioContext.currentTime;
this.volumeNode.gain.setTargetAtTime(this.currentVolume, now, 0.01); 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) { if (homeStartRadioBtn) {
homeStartRadioBtn.addEventListener('click', async () => { homeStartRadioBtn.addEventListener('click', async () => {
await player.enableRadio(); await player.enableRadio();
@ -384,9 +384,13 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const sleepTimerBtnMobile = document.getElementById('sleep-timer-btn'); const sleepTimerBtnMobile = document.getElementById('sleep-timer-btn');
// History tracking
let historyLoggedTrackId = null; let historyLoggedTrackId = null;
const { listeningTracker } = await import('./listening-tracker.js');
let _previousTrackId = null;
let _trackPlayStartTime = null;
const setupMediaListeners = (element) => { const setupMediaListeners = (element) => {
element.addEventListener('loadstart', () => { element.addEventListener('loadstart', () => {
if (player.activeElement === element) { if (player.activeElement === element) {
@ -397,14 +401,32 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
element.addEventListener('play', async () => { element.addEventListener('play', async () => {
if (player.activeElement !== element) return; if (player.activeElement !== element) return;
// Initialize audio context manager for EQ (only once)
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(element); audioContextManager.init(element);
} }
await audioContextManager.resume(); await audioContextManager.resume();
if (player.currentTrack) { 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()) { if (scrobbler.isAuthenticated()) {
scrobbler.updateNowPlaying(player.currentTrack); scrobbler.updateNowPlaying(player.currentTrack);
} }
@ -433,6 +455,15 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
element.addEventListener('ended', () => { element.addEventListener('ended', () => {
if (player.activeElement !== element) return; 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(); player.playNext();
}); });
@ -446,7 +477,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
progressFill.style.width = `${(currentTime / duration) * 100}%`; progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime); 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) { if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
historyLoggedTrackId = player.currentTrack.id; historyLoggedTrackId = player.currentTrack.id;
const historyEntry = await db.addToHistory(player.currentTrack); 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); setupMediaListeners(audioPlayer);
if (player.video) { if (player.video) {
setupMediaListeners(player.video); setupMediaListeners(player.video);
@ -2145,10 +2179,25 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
player.playVideo(clickedTrack); player.playVideo(clickedTrack);
} else { } else {
player.setQueue([clickedTrack], 0); player.setQueue([clickedTrack], 0);
player.enableAutoplay();
document.getElementById('shuffle-btn').classList.remove('active'); document.getElementById('shuffle-btn').classList.remove('active');
player.playTrackFromQueue(); 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) { if (recs && recs.length > 0) {
player.addToQueue(recs); player.addToQueue(recs);
} }
@ -2164,13 +2213,8 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId); const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
player.setQueue(trackList, startIndex); 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) { if (ui.currentPage === 'artist' && ui.currentArtistId) {
player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true); player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true);
} }
@ -2220,6 +2264,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (trackList.length === 0) return; if (trackList.length === 0) return;
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId); const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
player.setQueue(trackList, startIndex); player.setQueue(trackList, startIndex);
player.enableAutoplay();
if (ui.currentPage === 'artist' && ui.currentArtistId) { if (ui.currentPage === 'artist' && ui.currentArtistId) {
player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true); 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 artist = getTrackArtists(track);
const album = track.album?.title; const album = track.album?.title;
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined; 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-')); const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
let queryTitle = title; let queryTitle = title;
@ -1101,7 +1101,7 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage
if (isrc) amLyrics.setAttribute('isrc', isrc); if (isrc) amLyrics.setAttribute('isrc', isrc);
amLyrics.setAttribute('highlight-color', getLyricsHighlightColor()); 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('autoscroll', '');
amLyrics.setAttribute('interpolate', ''); amLyrics.setAttribute('interpolate', '');
amLyrics.style.height = '100%'; amLyrics.style.height = '100%';

View file

@ -245,12 +245,9 @@ export class MusicAPI {
if (this.videoArtworkCache.has(cacheKey)) { if (this.videoArtworkCache.has(cacheKey)) {
return this.videoArtworkCache.get(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 { 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 url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) return null; if (!response.ok) return null;
@ -261,12 +258,12 @@ export class MusicAPI {
}; };
this.videoArtworkCache.set(cacheKey, result); this.videoArtworkCache.set(cacheKey, result);
return result; return result;
*/
throw new Error('Video artwork is disabled for now.');
} catch (error) { } catch (error) {
console.warn('Failed to fetch video artwork:', error); console.warn('Failed to fetch video artwork:', error);
return null; return null;
} }
*/
} }
getArtistPictureUrl(id, size = '320') { getArtistPictureUrl(id, size = '320') {
@ -277,6 +274,47 @@ export class MusicAPI {
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id)); 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) { extractStreamUrlFromManifest(manifest) {
return this.tidalAPI.extractStreamUrlFromManifest(manifest); return this.tidalAPI.extractStreamUrlFromManifest(manifest);
} }

View file

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

View file

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

View file

@ -8,6 +8,7 @@ import {
backgroundSettings, backgroundSettings,
dynamicColorSettings, dynamicColorSettings,
cardSettings, cardSettings,
artistBannerSettings,
waveformSettings, waveformSettings,
replayGainSettings, replayGainSettings,
downloadQualitySettings, 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 // Compact Album Toggle
const compactAlbumToggle = document.getElementById('compact-album-toggle'); const compactAlbumToggle = document.getElementById('compact-album-toggle');
if (compactAlbumToggle) { 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 = { export const replayGainSettings = {
STORAGE_KEY_MODE: 'replay-gain-mode', // 'off', 'track', 'album' STORAGE_KEY_MODE: 'replay-gain-mode', // 'off', 'track', 'album'
STORAGE_KEY_PREAMP: 'replay-gain-preamp', 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 = { export const analyticsSettings = {
ENABLED_KEY: 'analytics-enabled', ENABLED_KEY: 'analytics-enabled',
@ -2602,6 +2650,8 @@ export const fontSettings = {
FONT_SIZE_KEY: 'monochrome-font-size', FONT_SIZE_KEY: 'monochrome-font-size',
FONT_LINK_ID: 'monochrome-dynamic-font', FONT_LINK_ID: 'monochrome-dynamic-font',
FONT_FACE_ID: 'monochrome-dynamic-fontface', 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() { getDefaultConfig() {
return { return {
@ -2717,7 +2767,7 @@ export const fontSettings = {
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900], 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) { async loadFontFromUrl(url, familyName) {
@ -2752,7 +2802,7 @@ export const fontSettings = {
weights: weights, weights: weights,
}); });
document.documentElement.style.setProperty('--font-family', `'${fontFamily}', sans-serif`); document.documentElement.style.setProperty('--font-family', `'${fontFamily}', ${this.NOTO_FALLBACK}`);
}, },
getFontFormat(url) { getFontFormat(url) {
@ -2835,7 +2885,7 @@ export const fontSettings = {
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900], 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) { deleteUploadedFont(fontId) {
@ -2862,7 +2912,7 @@ export const fontSettings = {
weights: [400, 500, 600, 700, 800], 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); document.documentElement.style.setProperty('--font-family', fontValue);
}, },
@ -2898,7 +2948,7 @@ export const fontSettings = {
weights: [400, 500, 600, 700], 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() { async applyFont() {

View file

@ -712,7 +712,7 @@ export class ThemeStore {
--highlight: #3b82f6; --highlight: #3b82f6;
--ring: #3b82f6; --ring: #3b82f6;
--radius: 8px; --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%; --font-size-scale: 100%;
}`; }`;
this.updatePreview(); this.updatePreview();

159
js/ui.js
View file

@ -29,6 +29,7 @@ import {
contentBlockingSettings, contentBlockingSettings,
settingsUiState, settingsUiState,
fullscreenCoverNoRoundSettings, fullscreenCoverNoRoundSettings,
artistBannerSettings,
} from './storage.js'; } from './storage.js';
import { db } from './db.js'; import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js'; import { getVibrantColorFromImage } from './vibrant-color.js';
@ -1278,18 +1279,35 @@ export class UIRenderer {
const currentImage = document.getElementById('fullscreen-cover-image'); const currentImage = document.getElementById('fullscreen-cover-image');
if (videoCoverUrl) { if (videoCoverUrl) {
const isPaused = this.player?.activeElement?.paused ?? true;
if (currentImage.tagName === 'IMG') { if (currentImage.tagName === 'IMG') {
const video = document.createElement('video'); const video = document.createElement('video');
video.src = videoCoverUrl; video.src = videoCoverUrl;
video.autoplay = true; video.autoplay = !isPaused;
video.loop = true; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'auto'; video.preload = 'auto';
video.className = currentImage.className; video.className = currentImage.className;
video.id = currentImage.id;
video.style.objectFit = 'cover';
currentImage.replaceWith(video); currentImage.replaceWith(video);
if (!isPaused) {
video.play().catch(() => {});
}
} else if (currentImage.src !== videoCoverUrl) { } else if (currentImage.src !== videoCoverUrl) {
currentImage.src = videoCoverUrl; currentImage.src = videoCoverUrl;
if (!isPaused) {
currentImage.play().catch(() => {});
} else {
currentImage.pause();
}
} else {
if (!isPaused) {
currentImage.play().catch(() => {});
} else {
currentImage.pause();
}
} }
} else { } else {
if (currentImage.tagName === 'VIDEO') { if (currentImage.tagName === 'VIDEO') {
@ -2262,6 +2280,7 @@ export class UIRenderer {
}); });
this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI); this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI);
window.addEventListener('volume-change', updateFsVolumeUI);
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) { async renderPartyDetailPage(id) {
await this.showPage('party-detail'); await this.showPage('party-detail');
await partyManager.joinParty(id); await partyManager.joinParty(id);
@ -2915,13 +3000,22 @@ export class UIRenderer {
} }
async getSeeds() { 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 history = await db.getHistory();
const favorites = await db.getFavorites('track'); const favorites = await db.getFavorites('track');
const playlists = await db.getPlaylists(true); const playlists = await db.getPlaylists(true);
const playlistTracks = playlists.flatMap((p) => p.tracks || []); 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 shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
const combined = [ const combined = [
@ -2955,7 +3049,7 @@ export class UIRenderer {
if (forceRefresh || songsContainer.children.length === 0) { if (forceRefresh || songsContainer.children.length === 0) {
songsContainer.innerHTML = this.createSkeletonTracks(10, true); songsContainer.innerHTML = this.createSkeletonTracks(10, true);
} else if (!songsContainer.querySelector('.skeleton')) { } else if (!songsContainer.querySelector('.skeleton')) {
return; // Already loaded return;
} }
try { try {
@ -2972,11 +3066,22 @@ export class UIRenderer {
...history.map((t) => t.id), ...history.map((t) => t.id),
]); ]);
const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
skipCache: forceRefresh, skipCache: forceRefresh,
knownTrackIds: knownTrackIds, 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'); const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
this.lastRecommendedTracks = filteredTracks; this.lastRecommendedTracks = filteredTracks;
@ -4796,6 +4901,16 @@ export class UIRenderer {
await this.showPage('artist'); await this.showPage('artist');
this.currentArtistId = artistId; 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 imageEl = document.getElementById('artist-detail-image');
const nameEl = document.getElementById('artist-detail-name'); const nameEl = document.getElementById('artist-detail-name');
const metaEl = document.getElementById('artist-detail-meta'); const metaEl = document.getElementById('artist-detail-meta');
@ -4844,6 +4959,39 @@ export class UIRenderer {
try { try {
const artist = await this.api.getArtist(artistId, provider); 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 // Handle Biography
if (bioEl) { if (bioEl) {
// Pre-define regex patterns for better performance // Pre-define regex patterns for better performance
@ -6135,6 +6283,7 @@ export class UIRenderer {
playBtn.onclick = () => { playBtn.onclick = () => {
this.player.setQueue([track], 0); this.player.setQueue([track], 0);
this.player.enableAutoplay();
this.player.playTrackFromQueue(); this.player.playTrackFromQueue();
}; };

View file

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

View file

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

View file

@ -16,6 +16,15 @@
body { body {
font-family: font-family:
'Inter', '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, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
'Segoe UI', 'Segoe UI',

View file

@ -1,7 +1,13 @@
:root { :root {
color-scheme: light dark; 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%; --font-size-scale: 100%;
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-sm: 0.875rem; --text-sm: 0.875rem;
@ -2542,6 +2548,134 @@ body.multi-select-mode .track-item:hover {
border-radius: var(--radius-full); 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 { .detail-header-info .type {
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@ -10783,7 +10917,12 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
height: 100%; height: 100%;
width: 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-font-size-base: clamp(34px, 3vw, 52px);
--lyplus-padding-line: 8px; --lyplus-padding-line: 8px;