Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
4ef66d8da9
30 changed files with 317 additions and 92 deletions
BIN
.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite
vendored
Normal file
BIN
.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite
vendored
Normal file
Binary file not shown.
BIN
.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm
vendored
Normal file
BIN
.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm
vendored
Normal file
Binary file not shown.
BIN
.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal
vendored
Normal file
BIN
.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal
vendored
Normal file
Binary file not shown.
BIN
assets/extension-help-1.png
Normal file
BIN
assets/extension-help-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/extension-help-2.png
Normal file
BIN
assets/extension-help-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
16
extension/README.md
Normal file
16
extension/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Monochrome Tidal Origin
|
||||||
|
|
||||||
|
While the website works without the extension with the use of proxies, it is recommended to install it to prevent various annoying issues. The website works best with the extension on.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Click on the **Code** button on the [home page](https://github.com/monochrome-music/monochrome) of the repo and click **Download ZIP**. (or download the [latest main branch ZIP](https://github.com/monochrome-music/monochrome/archive/refs/heads/main.zip))
|
||||||
|
2. Unpack the **.zip** archive with your archive program (like WinRar or 7Zip on Windows)
|
||||||
|
3. Open extensions page in your Chromium-based browser (Or `chrome://extensions/`) and make sure **Developer mode** is turned on.
|
||||||
|
4. Click on the **Load unpacked** and navigate to the directory you just unpacked.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
5. Inside of it, there's a directory **extension**. **SELECT THAT DIRECTORY INSTEAD OF THE MAIN ONE!** It should contain file "manifest.json".
|
||||||
|
|
||||||
|

|
||||||
4
extension/content.js
Normal file
4
extension/content.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = chrome.runtime.getURL('inject.js');
|
||||||
|
(document.head || document.documentElement).appendChild(s);
|
||||||
|
s.remove();
|
||||||
BIN
extension/icons/128.png
Normal file
BIN
extension/icons/128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/icons/16.png
Normal file
BIN
extension/icons/16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
extension/icons/48.png
Normal file
BIN
extension/icons/48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
1
extension/inject.js
Normal file
1
extension/inject.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
window.__tidalOriginExtension = true;
|
||||||
51
extension/manifest.json
Normal file
51
extension/manifest.json
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Monochrome Tidal Bypass",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"description": "Adds Origin: https://listen.tidal.com to Tidal CDN requests so audio plays without a proxy",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "monochrome-tidal-bypass@binimum.org",
|
||||||
|
"strict_min_version": "111.0",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["none"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": ["declarativeNetRequest", "scripting", "declarativeNetRequestWithHostAccess"],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://*.tidal.com/*",
|
||||||
|
"*://monochrome.tf/*",
|
||||||
|
"*://monochrome.samidy.com/*",
|
||||||
|
"*://lossless.wtf/*",
|
||||||
|
"*://localhost/*"
|
||||||
|
],
|
||||||
|
"declarative_net_request": {
|
||||||
|
"rule_resources": [
|
||||||
|
{
|
||||||
|
"id": "rules",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "rules.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_start",
|
||||||
|
"all_frames": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/16.png",
|
||||||
|
"48": "icons/48.png",
|
||||||
|
"128": "icons/128.png"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["inject.js"],
|
||||||
|
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
extension/rules.json
Normal file
55
extension/rules.json
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"priority": 1,
|
||||||
|
"action": {
|
||||||
|
"type": "modifyHeaders",
|
||||||
|
"responseHeaders": [
|
||||||
|
{
|
||||||
|
"header": "Access-Control-Allow-Origin",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Access-Control-Allow-Methods",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "GET, POST, PUT, DELETE, PATCH, OPTIONS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Access-Control-Allow-Headers",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"urlFilter": "||tidal.com*",
|
||||||
|
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"],
|
||||||
|
"resourceTypes": ["xmlhttprequest", "media"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"priority": 1,
|
||||||
|
"action": {
|
||||||
|
"type": "modifyHeaders",
|
||||||
|
"requestHeaders": [
|
||||||
|
{
|
||||||
|
"header": "Origin",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "https://listen.tidal.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Referer",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "https://listen.tidal.com/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"urlFilter": "||tidal.com*",
|
||||||
|
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"],
|
||||||
|
"resourceTypes": ["xmlhttprequest", "media"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
14
index.html
14
index.html
|
|
@ -117,6 +117,12 @@
|
||||||
<body>
|
<body>
|
||||||
<audio id="audio-player" style="display: none"></audio>
|
<audio id="audio-player" style="display: none"></audio>
|
||||||
<video id="video-player" style="display: none"></video>
|
<video id="video-player" style="display: none"></video>
|
||||||
|
<script>
|
||||||
|
if (window.__tidalOriginExtension) {
|
||||||
|
document.getElementById('audio-player').crossOrigin = 'anonymous';
|
||||||
|
document.getElementById('video-player').crossOrigin = 'anonymous';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<div id="context-menu">
|
<div id="context-menu">
|
||||||
<ul>
|
<ul>
|
||||||
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
||||||
|
|
@ -1732,8 +1738,11 @@
|
||||||
<div id="server-disruption-banner" class="server-disruption-sidebar" style="display: none">
|
<div id="server-disruption-banner" class="server-disruption-sidebar" style="display: none">
|
||||||
<span class="disruption-icon">⚠</span>
|
<span class="disruption-icon">⚠</span>
|
||||||
<span
|
<span
|
||||||
>Services are currently unstable. <br /><br />For Hi-Res streaming, use Chrome or
|
>Services are currently unstable. <br /><br />Use the extension
|
||||||
Safari.</span
|
<a href="https://github.com/monochrome-music/monochrome/tree/main/extension"
|
||||||
|
>here</a
|
||||||
|
>
|
||||||
|
for the best experience.</span
|
||||||
>
|
>
|
||||||
<button id="dismiss-disruption-btn" class="disruption-dismiss" title="Dismiss">
|
<button id="dismiss-disruption-btn" class="disruption-dismiss" title="Dismiss">
|
||||||
×
|
×
|
||||||
|
|
@ -4063,6 +4072,7 @@
|
||||||
</div>
|
</div>
|
||||||
<select id="streaming-quality-setting">
|
<select id="streaming-quality-setting">
|
||||||
<option value="auto">Auto (Adaptive)</option>
|
<option value="auto">Auto (Adaptive)</option>
|
||||||
|
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
|
||||||
<option value="LOSSLESS">Lossless (16-bit)</option>
|
<option value="LOSSLESS">Lossless (16-bit)</option>
|
||||||
<option value="HIGH">AAC 320kbps</option>
|
<option value="HIGH">AAC 320kbps</option>
|
||||||
<option value="LOW">AAC 96kbps</option>
|
<option value="LOW">AAC 96kbps</option>
|
||||||
|
|
|
||||||
|
|
@ -1590,9 +1590,12 @@ class HiFiClient {
|
||||||
const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes);
|
const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes);
|
||||||
|
|
||||||
let pic_id: string | null = null;
|
let pic_id: string | null = null;
|
||||||
const art_data = inc.relationships?.profileArt?.data;
|
const art_refs_artist = (() => {
|
||||||
if (Array.isArray(art_data) && art_data.length > 0) {
|
const d = inc.relationships?.profileArt?.data;
|
||||||
const artwork = artworks_map[art_data[0].id];
|
return Array.isArray(d) ? d : d ? [d as JsonApiRef] : [];
|
||||||
|
})();
|
||||||
|
if (art_refs_artist.length > 0) {
|
||||||
|
const artwork = artworks_map[art_refs_artist[0].id];
|
||||||
const files = artwork?.attributes?.files;
|
const files = artwork?.attributes?.files;
|
||||||
if (Array.isArray(files) && files[0]?.href) {
|
if (Array.isArray(files) && files[0]?.href) {
|
||||||
pic_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href);
|
pic_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href);
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,35 @@ const syncManager = {
|
||||||
user_folders: Object.values(userFolders).filter((f) => f && typeof f === 'object'),
|
user_folders: Object.values(userFolders).filter((f) => f && typeof f === 'object'),
|
||||||
};
|
};
|
||||||
|
|
||||||
await database.importData(convertedData);
|
// Safety check: if we had local data but merged result is completely empty, something went wrong.
|
||||||
|
// Do NOT call importData as it would wipe the user's local stores.
|
||||||
|
const hadLocalData =
|
||||||
|
localData.tracks.length > 0 ||
|
||||||
|
localData.albums.length > 0 ||
|
||||||
|
localData.artists.length > 0 ||
|
||||||
|
localData.playlists.length > 0 ||
|
||||||
|
localData.mixes.length > 0 ||
|
||||||
|
localData.history.length > 0 ||
|
||||||
|
localData.userPlaylists.length > 0 ||
|
||||||
|
localData.userFolders.length > 0;
|
||||||
|
|
||||||
|
const isConvertedEmpty =
|
||||||
|
convertedData.favorites_tracks.length === 0 &&
|
||||||
|
convertedData.favorites_albums.length === 0 &&
|
||||||
|
convertedData.favorites_artists.length === 0 &&
|
||||||
|
convertedData.favorites_playlists.length === 0 &&
|
||||||
|
convertedData.favorites_mixes.length === 0 &&
|
||||||
|
convertedData.history_tracks.length === 0 &&
|
||||||
|
convertedData.user_playlists.length === 0 &&
|
||||||
|
convertedData.user_folders.length === 0;
|
||||||
|
|
||||||
|
if (hadLocalData && isConvertedEmpty) {
|
||||||
|
console.warn(
|
||||||
|
'[PocketBase] Sync aborted: local data exists but merged result is empty. Preserving local data to prevent accidental wipe.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await database.importData(convertedData, true);
|
||||||
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('library-changed'));
|
window.dispatchEvent(new CustomEvent('library-changed'));
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings } from './
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { DashDownloader } from './dash-downloader.ts';
|
import { DashDownloader } from './dash-downloader.ts';
|
||||||
import { HlsDownloader } from './hls-downloader.js';
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
|
import { getProxyUrl } from './proxy-utils.js';
|
||||||
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
|
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
|
||||||
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||||
import { isCustomFormat } from './ffmpegFormats.ts';
|
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||||
|
|
@ -1768,7 +1769,7 @@ export class LosslessAPI {
|
||||||
if (streamUrl.startsWith('blob:')) {
|
if (streamUrl.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
const downloader = new DashDownloader();
|
const downloader = new DashDownloader();
|
||||||
blob = await downloader.downloadDashStream(streamUrl, {
|
blob = await downloader.downloadDashStream(getProxyUrl(streamUrl), {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
onProgress,
|
onProgress,
|
||||||
calculateDashBytes: calculateDashBytes ?? true,
|
calculateDashBytes: calculateDashBytes ?? true,
|
||||||
|
|
@ -1787,7 +1788,7 @@ export class LosslessAPI {
|
||||||
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
||||||
try {
|
try {
|
||||||
const downloader = new HlsDownloader();
|
const downloader = new HlsDownloader();
|
||||||
blob = await downloader.downloadHlsStream(streamUrl, {
|
blob = await downloader.downloadHlsStream(getProxyUrl(streamUrl), {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
onProgress,
|
onProgress,
|
||||||
});
|
});
|
||||||
|
|
@ -1812,7 +1813,7 @@ export class LosslessAPI {
|
||||||
/* ignore HEAD failure; proceed with GET */
|
/* ignore HEAD failure; proceed with GET */
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(streamUrl, {
|
const response = await fetch(getProxyUrl(streamUrl), {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
33
js/app.js
33
js/app.js
|
|
@ -412,6 +412,13 @@ async function uploadCoverImage(file) {
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
await modernSettings.waitPending();
|
await modernSettings.waitPending();
|
||||||
|
|
||||||
|
// Request persistent storage to reduce risk of browser wiping data on updates or cleanup
|
||||||
|
if (navigator.storage && navigator.storage.persist) {
|
||||||
|
navigator.storage.persist().catch(() => {
|
||||||
|
// Ignore errors; persistence is a best-effort request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
window.monochrome = {
|
window.monochrome = {
|
||||||
HiFiClient,
|
HiFiClient,
|
||||||
|
|
@ -467,31 +474,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const audioPlayer = document.getElementById('audio-player');
|
const audioPlayer = document.getElementById('audio-player');
|
||||||
|
|
||||||
// i love ios and macos!!!! webkit fucking SUCKS BULLSHIT sorry ios/macos heads yall getting lossless only playback
|
// i love ios and macos!!!! webkit fucking SUCKS BULLSHIT sorry ios/macos heads yall getting lossless only playback
|
||||||
// Use isIos from platform-detection (set before UA spoof in index.html) so detection works on real iOS.
|
const currentQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS';
|
||||||
if (isIos || isSafari) {
|
|
||||||
const qualitySelect = document.getElementById('streaming-quality-setting');
|
|
||||||
const downloadQualitySelect = document.getElementById('download-quality-setting');
|
|
||||||
|
|
||||||
const removeHiRes = (select) => {
|
|
||||||
if (!select) return;
|
|
||||||
const option = select.querySelector('option[value="HI_RES_LOSSLESS"]');
|
|
||||||
if (option) option.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
removeHiRes(qualitySelect);
|
|
||||||
removeHiRes(downloadQualitySelect);
|
|
||||||
|
|
||||||
if (isIos) {
|
|
||||||
document.querySelector('#hi-res-download-warning').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentQualitySetting = localStorage.getItem('playback-quality');
|
|
||||||
if (!currentQualitySetting || currentQualitySetting === 'HI_RES_LOSSLESS') {
|
|
||||||
localStorage.setItem('playback-quality', 'LOSSLESS');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
|
|
||||||
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
|
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
|
||||||
|
|
||||||
// Initialize tracker
|
// Initialize tracker
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,25 @@ class AudioContextManager {
|
||||||
this.audioContext = new AudioContext();
|
this.audioContext = new AudioContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.__tidalOriginExtension) {
|
||||||
|
if (!this.sources.has(audioElement)) {
|
||||||
|
const src = this.audioContext.createMediaElementSource(audioElement);
|
||||||
|
this.sources.set(audioElement, src);
|
||||||
|
}
|
||||||
|
this.source = this.sources.get(audioElement);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.audioContext.destination.channelCount = Math.min(this.audioContext.destination.maxChannelCount, 8);
|
||||||
|
this.audioContext.destination.channelCountMode = 'explicit';
|
||||||
|
this.audioContext.destination.channelInterpretation = 'discrete';
|
||||||
|
} catch {
|
||||||
|
// Some browsers may not support changing destination channel count
|
||||||
|
}
|
||||||
|
|
||||||
|
this.binauralDsp = new BinauralDSP(this.audioContext);
|
||||||
|
void this._loadBinauralSettings();
|
||||||
|
}
|
||||||
|
|
||||||
this.analyser = this.audioContext.createAnalyser();
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 1024;
|
this.analyser.fftSize = 1024;
|
||||||
this.analyser.smoothingTimeConstant = 0.7;
|
this.analyser.smoothingTimeConstant = 0.7;
|
||||||
|
|
@ -502,12 +521,16 @@ class AudioContextManager {
|
||||||
|
|
||||||
this.monoMergerNode = this.audioContext.createChannelMerger(2);
|
this.monoMergerNode = this.audioContext.createChannelMerger(2);
|
||||||
|
|
||||||
|
if (window.__tidalOriginExtension) {
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-recover from unexpected suspensions (e.g. background throttling)
|
// Auto-recover from unexpected suspensions (e.g. background throttling)
|
||||||
this.audioContext.addEventListener('statechange', () => {
|
this.audioContext.addEventListener('statechange', () => {
|
||||||
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
|
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
|
||||||
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
|
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.audioContext && this.audioContext.state !== 'running') {
|
if (this.audioContext && this.audioContext.state !== 'running' && (window.__tidalOriginExtension ? this.source : true)) {
|
||||||
this.audioContext.resume().catch((e) => {
|
this.audioContext.resume().catch((e) => {
|
||||||
console.warn('[AudioContext] Auto-resume failed:', e);
|
console.warn('[AudioContext] Auto-resume failed:', e);
|
||||||
});
|
});
|
||||||
|
|
@ -529,7 +552,32 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
if (this.audio === audioElement) return;
|
if (this.audio === audioElement) return;
|
||||||
|
|
||||||
|
if (window.__tidalOriginExtension) {
|
||||||
|
try {
|
||||||
|
if (this.source) {
|
||||||
|
try {
|
||||||
|
this.source.disconnect();
|
||||||
|
} catch {
|
||||||
|
// node may already be disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.audio = audioElement;
|
this.audio = audioElement;
|
||||||
|
|
||||||
|
if (!this.sources.has(audioElement)) {
|
||||||
|
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
|
||||||
|
}
|
||||||
|
this.source = this.sources.get(audioElement);
|
||||||
|
|
||||||
|
if (this.isInitialized) {
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('changeSource failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.audio = audioElement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export class DashDownloader {
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
urls.map(async (url) => {
|
urls.map(async (url) => {
|
||||||
const result = await fetch(url, { method: 'HEAD', signal });
|
const result = await fetch(getProxyUrl(url), { method: 'HEAD', signal });
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const contentLength = result.headers.get('Content-Length');
|
const contentLength = result.headers.get('Content-Length');
|
||||||
|
|
@ -75,7 +75,7 @@ export class DashDownloader {
|
||||||
|
|
||||||
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
|
||||||
|
|
||||||
const url = urls[i];
|
const url = getProxyUrl(urls[i]);
|
||||||
const segmentResponse = await fetch(url, { signal });
|
const segmentResponse = await fetch(url, { signal });
|
||||||
|
|
||||||
if (!segmentResponse.ok) {
|
if (!segmentResponse.ok) {
|
||||||
|
|
|
||||||
33
js/db.js
33
js/db.js
|
|
@ -85,8 +85,15 @@ export class MusicDatabase {
|
||||||
const store = transaction.objectStore(storeName);
|
const store = transaction.objectStore(storeName);
|
||||||
const request = callback(store);
|
const request = callback(store);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (request) {
|
||||||
|
request.onsuccess = () => {
|
||||||
|
result = request.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
transaction.oncomplete = () => {
|
||||||
resolve(request?.result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
transaction.onerror = (event) => {
|
transaction.onerror = (event) => {
|
||||||
reject(event.target.error);
|
reject(event.target.error);
|
||||||
|
|
@ -448,6 +455,25 @@ export class MusicDatabase {
|
||||||
async importData(data, clear = false) {
|
async importData(data, clear = false) {
|
||||||
const db = await this.open();
|
const db = await this.open();
|
||||||
|
|
||||||
|
// Safety check: if clear=true but all data is empty, skip to avoid wiping existing data
|
||||||
|
if (clear) {
|
||||||
|
const allEmpty = [
|
||||||
|
data.favorites_tracks,
|
||||||
|
data.favorites_albums,
|
||||||
|
data.favorites_artists,
|
||||||
|
data.favorites_playlists,
|
||||||
|
data.favorites_mixes,
|
||||||
|
data.history_tracks,
|
||||||
|
data.user_playlists,
|
||||||
|
data.user_folders,
|
||||||
|
].every((arr) => !arr || (Array.isArray(arr) ? arr.length === 0 : Object.keys(arr).length === 0));
|
||||||
|
|
||||||
|
if (allEmpty) {
|
||||||
|
console.warn('[importData] Aborting: clear=true but all import data is empty. Existing data preserved.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const importStore = async (storeName, items) => {
|
const importStore = async (storeName, items) => {
|
||||||
if (items === undefined) return false;
|
if (items === undefined) return false;
|
||||||
|
|
||||||
|
|
@ -481,9 +507,10 @@ export class MusicDatabase {
|
||||||
const transaction = db.transaction(storeName, 'readwrite');
|
const transaction = db.transaction(storeName, 'readwrite');
|
||||||
const store = transaction.objectStore(storeName);
|
const store = transaction.objectStore(storeName);
|
||||||
|
|
||||||
// force clear on first sync
|
if (clear) {
|
||||||
console.log(`Clearing ${storeName} to Make Sure Everythings Good`);
|
console.log(`[importData] Clearing ${storeName} before import`);
|
||||||
store.clear();
|
store.clear();
|
||||||
|
}
|
||||||
|
|
||||||
itemsArray.forEach((item) => {
|
itemsArray.forEach((item) => {
|
||||||
if (item.id && typeof item.id === 'string' && !isNaN(item.id)) {
|
if (item.id && typeof item.id === 'string' && !isNaN(item.id)) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { SegmentedDownloadProgress } from './progressEvents';
|
import { SegmentedDownloadProgress } from './progressEvents';
|
||||||
|
import { getProxyUrl } from './proxy-utils';
|
||||||
|
|
||||||
export class HlsDownloader {
|
export class HlsDownloader {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
@ -6,12 +7,12 @@ export class HlsDownloader {
|
||||||
async downloadHlsStream(masterUrl, options = {}) {
|
async downloadHlsStream(masterUrl, options = {}) {
|
||||||
const { onProgress, signal } = options;
|
const { onProgress, signal } = options;
|
||||||
|
|
||||||
const response = await fetch(masterUrl, { signal });
|
const response = await fetch(getProxyUrl(masterUrl), { signal });
|
||||||
const masterText = await response.text();
|
const masterText = await response.text();
|
||||||
|
|
||||||
const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
|
const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
|
||||||
|
|
||||||
const mediaResponse = await fetch(variantUrl, { signal });
|
const mediaResponse = await fetch(getProxyUrl(variantUrl), { signal });
|
||||||
const mediaText = await mediaResponse.text();
|
const mediaText = await mediaResponse.text();
|
||||||
|
|
||||||
const segments = this.parseMediaPlaylist(variantUrl, mediaText);
|
const segments = this.parseMediaPlaylist(variantUrl, mediaText);
|
||||||
|
|
@ -29,7 +30,7 @@ export class HlsDownloader {
|
||||||
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));
|
||||||
|
|
||||||
const segmentUrl = segments[i];
|
const segmentUrl = segments[i];
|
||||||
const segmentResponse = await fetch(segmentUrl, { signal });
|
const segmentResponse = await fetch(getProxyUrl(segmentUrl), { signal });
|
||||||
|
|
||||||
if (!segmentResponse.ok) {
|
if (!segmentResponse.ok) {
|
||||||
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
|
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
|
||||||
|
|
|
||||||
24
js/player.js
24
js/player.js
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import { audioContextManager } from './audio-context.js';
|
import { audioContextManager } from './audio-context.js';
|
||||||
import { isIos, isSafari } from './platform-detection.js';
|
import { isIos, isSafari } from './platform-detection.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
|
import { getProxyUrl } from './proxy-utils.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';
|
||||||
|
|
@ -133,17 +134,25 @@ export class Player {
|
||||||
},
|
},
|
||||||
abr: {
|
abr: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// Start with a low bandwidth estimate (200kbps) so it plays instantly
|
|
||||||
// on slow connections and smoothly scales UP to Hi-Fi if the connection allows.
|
|
||||||
defaultBandwidthEstimate: 100000,
|
defaultBandwidthEstimate: 100000,
|
||||||
switchInterval: 1, // Check more frequently
|
switchInterval: 1,
|
||||||
bandwidthDowngradeTarget: 0.8, // Downgrade more aggressively if bandwidth drops
|
bandwidthDowngradeTarget: 0.8,
|
||||||
restrictToElementSize: false,
|
restrictToElementSize: false,
|
||||||
},
|
},
|
||||||
mediaSource: {
|
mediaSource: {
|
||||||
codecSwitchingStrategy: 'smooth',
|
codecSwitchingStrategy: 'smooth',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.shakaPlayer.getNetworkingEngine().registerRequestFilter((type, request) => {
|
||||||
|
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
||||||
|
const uris = request.uris;
|
||||||
|
for (let i = 0; i < uris.length; i++) {
|
||||||
|
if (uris[i].includes('tidal.com')) {
|
||||||
|
uris[i] = getProxyUrl(uris[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
this.shakaPlayer.addEventListener('adaptation', this.updateAdaptiveQualityBadge.bind(this));
|
this.shakaPlayer.addEventListener('adaptation', this.updateAdaptiveQualityBadge.bind(this));
|
||||||
this.shakaPlayer.addEventListener('variantchanged', this.updateAdaptiveQualityBadge.bind(this));
|
this.shakaPlayer.addEventListener('variantchanged', this.updateAdaptiveQualityBadge.bind(this));
|
||||||
|
|
||||||
|
|
@ -1266,6 +1275,13 @@ export class Player {
|
||||||
// which delays the event loop and natively adds gap/latency
|
// which delays the event loop and natively adds gap/latency
|
||||||
await this.safePlay(activeElement);
|
await this.safePlay(activeElement);
|
||||||
} else {
|
} else {
|
||||||
|
if (this.shakaInitialized) {
|
||||||
|
try {
|
||||||
|
this.shakaPlayer.unload();
|
||||||
|
this.shakaPlayer.detach();
|
||||||
|
} catch {}
|
||||||
|
this.shakaInitialized = false;
|
||||||
|
}
|
||||||
activeElement.src = streamUrl;
|
activeElement.src = streamUrl;
|
||||||
this.applyAudioEffects();
|
this.applyAudioEffects();
|
||||||
this.updateAdaptiveQualityBadge();
|
this.updateAdaptiveQualityBadge();
|
||||||
|
|
|
||||||
4
js/proxy-utils.js
Normal file
4
js/proxy-utils.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export const getProxyUrl = (url) => {
|
||||||
|
if (window.__tidalOriginExtension) return url;
|
||||||
|
return `https://audio-proxy.binimum.org/proxy-audio?url=${url}`;
|
||||||
|
};
|
||||||
|
|
@ -6573,7 +6573,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.target.result);
|
const data = JSON.parse(event.target.result);
|
||||||
await db.importData(data);
|
await db.importData(data, true);
|
||||||
alert('Library imported successfully!');
|
alert('Library imported successfully!');
|
||||||
window.location.reload(); // Simple way to refresh all state
|
window.location.reload(); // Simple way to refresh all state
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -79,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.2.8",
|
"@uimaxbai/am-lyrics": "^1.2.9",
|
||||||
"@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",
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { HiFiClient } from './js/HiFi.ts';
|
|
||||||
import { LosslessAPI } from './js/api.js';
|
|
||||||
|
|
||||||
// mock out modules to make LosslessAPI load in bun
|
|
||||||
import { mock } from 'bun:test';
|
|
||||||
mock.module('./js/icons.ts', () => ({}));
|
|
||||||
mock.module('./js/settings.js', () => ({
|
|
||||||
devModeSettings: { isEnabled: () => false },
|
|
||||||
syncManager: {},
|
|
||||||
musicProviderSettings: {},
|
|
||||||
audioSettings: {},
|
|
||||||
apiSettings: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
|
||||||
globalThis.window = { matchMedia: () => ({ matches: false }) };
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
await HiFiClient.initialize();
|
|
||||||
const api = new LosslessAPI({ getInstances: () => [] });
|
|
||||||
|
|
||||||
// mock cache
|
|
||||||
api.cache = { get: () => null, set: () => {} };
|
|
||||||
|
|
||||||
api.fetchWithRetry = async function (relativePath, options) {
|
|
||||||
console.log('fetchWithRetry called:', relativePath);
|
|
||||||
return HiFiClient.instance.query(relativePath);
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await api.search('coldplay');
|
|
||||||
console.log('Returned tracks:', res.tracks?.items?.length);
|
|
||||||
}
|
|
||||||
test().catch(console.error);
|
|
||||||
|
|
@ -7,17 +7,17 @@
|
||||||
"types": ["vite/client", "node", "@types/wicg-file-system-access"],
|
"types": ["vite/client", "node", "@types/wicg-file-system-access"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"!/*": ["node_modules/*"]
|
"!/*": ["node_modules/*"],
|
||||||
},
|
},
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": false,
|
"checkJs": false,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "5.0",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true,
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "*.ts", "**/*.js", "*.js"],
|
"include": ["**/*.ts", "*.ts", "**/*.js", "*.js"],
|
||||||
"exclude": ["**/node_modules/*"]
|
"exclude": ["**/node_modules/*"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
|
import path from 'path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import authGatePlugin from './vite-plugin-auth-gate.js';
|
import authGatePlugin from './vite-plugin-auth-gate.js';
|
||||||
import path from 'path';
|
|
||||||
import uploadPlugin from './vite-plugin-upload.js';
|
|
||||||
import blobAssetPlugin from './vite-plugin-blob.js';
|
import blobAssetPlugin from './vite-plugin-blob.js';
|
||||||
import svgUse from './vite-plugin-svg-use.js';
|
import svgUse from './vite-plugin-svg-use.js';
|
||||||
|
import uploadPlugin from './vite-plugin-upload.js';
|
||||||
// import purgecss from 'vite-plugin-purgecss';
|
// import purgecss from 'vite-plugin-purgecss';
|
||||||
import purgecss from 'vite-plugin-purgecss';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import purgecss from 'vite-plugin-purgecss';
|
||||||
|
|
||||||
|
function proxyAudioPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'proxy-audio-dev',
|
||||||
|
configureServer(server) {
|
||||||
|
// No longer needed: local proxy-audio middleware replaced by remote proxy
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getGitCommitHash() {
|
function getGitCommitHash() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -80,6 +89,7 @@ export default defineConfig((_options) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
proxyAudioPlugin(),
|
||||||
purgecss({
|
purgecss({
|
||||||
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
|
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
|
||||||
safelist: {
|
safelist: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue