mirror of
https://github.com/fabiodalez-dev/Monochrome-Android-APK.git
synced 2026-10-21 16:12:26 -05:00
419 lines
18 KiB
JavaScript
419 lines
18 KiB
JavaScript
// ── ANDROID BRIDGES (synchronous, run immediately) ──
|
|
|
|
// ── CLIPBOARD FALLBACK ──
|
|
if (window.AndroidBridge) {
|
|
const origClipboard = navigator.clipboard?.writeText?.bind(navigator.clipboard);
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText = function(text) {
|
|
try {
|
|
window.AndroidBridge.copyToClipboard(text);
|
|
return Promise.resolve();
|
|
} catch (e) {
|
|
if (origClipboard) return origClipboard(text);
|
|
return Promise.reject(e);
|
|
}
|
|
};
|
|
} else {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: {
|
|
writeText: function(text) {
|
|
window.AndroidBridge.copyToClipboard(text);
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── OAUTH / WINDOW.OPEN OVERRIDE ──
|
|
// Redirect window.open to Android's in-app browser (Chrome Custom Tab)
|
|
if (window.AndroidBridge) {
|
|
const origOpen = window.open.bind(window);
|
|
window.open = function(url, target, features) {
|
|
if (url && url.startsWith('http')) {
|
|
window.AndroidBridge.openInBrowser(url);
|
|
// Return a mock window object so OAuth polling code doesn't crash
|
|
return {
|
|
closed: false,
|
|
close: function() { this.closed = true; },
|
|
location: { _url: url, get href() { return this._url; }, set href(v) { this._url = v; window.AndroidBridge.openInBrowser(v); } },
|
|
};
|
|
}
|
|
if (url === '' || !url) {
|
|
// OAuth flow: opens blank first, then sets location.href
|
|
const mockWindow = {
|
|
closed: false,
|
|
close: function() { this.closed = true; },
|
|
location: {
|
|
_href: '',
|
|
get href() { return this._href; },
|
|
set href(v) {
|
|
this._href = v;
|
|
if (v && v.startsWith('http')) {
|
|
window.AndroidBridge.openInBrowser(v);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
return mockWindow;
|
|
}
|
|
return origOpen(url, target, features);
|
|
};
|
|
}
|
|
|
|
// ── TOUCH DRAG POLYFILL for queue reordering ──
|
|
// HTML5 drag-and-drop doesn't work on mobile touch. Convert touch events to drag events.
|
|
if ('ontouchstart' in window) {
|
|
let _dragEl = null;
|
|
let _dragStartY = 0;
|
|
|
|
document.addEventListener('touchstart', (e) => {
|
|
const item = e.target.closest('[draggable="true"]');
|
|
if (!item) return;
|
|
_dragEl = item;
|
|
_dragStartY = e.touches[0].clientY;
|
|
item.style.opacity = '0.5';
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('touchmove', (e) => {
|
|
if (!_dragEl) return;
|
|
const touch = e.touches[0];
|
|
const target = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
const overItem = target?.closest('[draggable="true"]');
|
|
if (overItem && overItem !== _dragEl && overItem.parentNode === _dragEl.parentNode) {
|
|
const rect = overItem.getBoundingClientRect();
|
|
const mid = rect.top + rect.height / 2;
|
|
if (touch.clientY < mid) {
|
|
overItem.parentNode.insertBefore(_dragEl, overItem);
|
|
} else {
|
|
overItem.parentNode.insertBefore(_dragEl, overItem.nextSibling);
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('touchend', () => {
|
|
if (_dragEl) {
|
|
_dragEl.style.opacity = '';
|
|
// Trigger a "drop" by dispatching a custom event with the new order
|
|
const container = _dragEl.closest('.queue-list, .track-list, .playlist-tracks');
|
|
if (container) {
|
|
const items = container.querySelectorAll('[data-queue-index]');
|
|
items.forEach((item, i) => { item.dataset.queueIndex = i; });
|
|
}
|
|
_dragEl = null;
|
|
}
|
|
}, { passive: true });
|
|
}
|
|
|
|
// ── DOWNLOAD HANDLER (synchronous, runs immediately) ──
|
|
|
|
const _blobStore = new Map();
|
|
|
|
const _origCreate = URL.createObjectURL.bind(URL);
|
|
URL.createObjectURL = function(obj) {
|
|
const url = _origCreate(obj);
|
|
if (obj instanceof Blob) _blobStore.set(url, obj);
|
|
return url;
|
|
};
|
|
|
|
const _origRevoke = URL.revokeObjectURL.bind(URL);
|
|
URL.revokeObjectURL = function(url) {
|
|
setTimeout(() => _blobStore.delete(url), 10000);
|
|
return _origRevoke(url);
|
|
};
|
|
|
|
const _origClick = HTMLAnchorElement.prototype.click;
|
|
HTMLAnchorElement.prototype.click = function() {
|
|
if (this.hasAttribute('download') && this.href && this.href.startsWith('blob:')) {
|
|
const filename = this.download || 'download';
|
|
const blob = _blobStore.get(this.href);
|
|
if (blob && window.AndroidDownload) {
|
|
_saveBlobNative(blob, filename);
|
|
return;
|
|
}
|
|
// Fallback: try fetch
|
|
if (window.AndroidDownload) {
|
|
fetch(this.href).then(r => r.blob()).then(b => _saveBlobNative(b, filename)).catch(() => {});
|
|
return;
|
|
}
|
|
}
|
|
return _origClick.call(this);
|
|
};
|
|
|
|
function _saveBlobNative(blob, filename) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
if (!reader.result) return;
|
|
const dataUri = reader.result;
|
|
const base64 = dataUri.substring(dataUri.indexOf(',') + 1);
|
|
let mime = 'application/octet-stream';
|
|
if (dataUri.startsWith('data:') && dataUri.includes(';')) {
|
|
mime = dataUri.substring(5, dataUri.indexOf(';'));
|
|
}
|
|
window.AndroidDownload.saveBase64(base64, filename, mime);
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
}
|
|
|
|
// ── LOCAL FILES BRIDGE ──
|
|
// Hook into the "Select Music Folder" button to use Android's folder picker
|
|
if (window.AndroidLocalFiles) {
|
|
// Make the button visible (upstream hides it on mobile)
|
|
const _waitForBtn = setInterval(() => {
|
|
const btn = document.getElementById('select-local-folder-btn');
|
|
const warn = document.getElementById('local-browser-warning');
|
|
if (btn) {
|
|
btn.style.display = 'flex';
|
|
if (warn) warn.style.display = 'none';
|
|
clearInterval(_waitForBtn);
|
|
}
|
|
}, 500);
|
|
|
|
// Override showDirectoryPicker for Android
|
|
window.showDirectoryPicker = function() {
|
|
return new Promise((resolve, reject) => {
|
|
// Set up callbacks that the native bridge will call
|
|
window._androidLocalFilesResolve = resolve;
|
|
window._androidLocalFilesReject = reject;
|
|
window.AndroidLocalFiles.pickFolder();
|
|
});
|
|
};
|
|
|
|
// Callbacks from native LocalFilesBridge
|
|
let _collectedFiles = [];
|
|
// eslint-disable-next-line no-unused-vars
|
|
let _pendingResolve = null;
|
|
|
|
window._androidLocalFilesStart = function(count) {
|
|
_collectedFiles = [];
|
|
const btn = document.getElementById('select-local-folder-btn');
|
|
const btnText = document.getElementById('select-local-folder-text');
|
|
if (btnText) btnText.textContent = 'Scanning... (0/' + count + ')';
|
|
else if (btn) btn.textContent = 'Scanning...';
|
|
};
|
|
|
|
window._androidLocalFileReady = function(filename, base64Data, index, total) {
|
|
try {
|
|
const binary = atob(base64Data);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const mimeMap = { flac: 'audio/flac', mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', ogg: 'audio/ogg' };
|
|
const file = new File([bytes], filename, { type: mimeMap[ext] || 'audio/mpeg' });
|
|
_collectedFiles.push(file);
|
|
|
|
const btnText = document.getElementById('select-local-folder-text');
|
|
if (btnText) btnText.textContent = 'Scanning... (' + (index + 1) + '/' + total + ')';
|
|
} catch {
|
|
// Skip unreadable file
|
|
}
|
|
};
|
|
|
|
window._androidLocalFilesDone = async function() {
|
|
const tracks = [];
|
|
for (let i = 0; i < _collectedFiles.length; i++) {
|
|
const f = _collectedFiles[i];
|
|
tracks.push({
|
|
id: 'local-' + i + '-' + f.name,
|
|
title: f.name.replace(/\.[^.]+$/, ''),
|
|
artist: { name: 'Local' },
|
|
artists: [{ name: 'Local' }],
|
|
album: { title: 'Local Files' },
|
|
duration: 0,
|
|
isLocal: true,
|
|
audioUrl: URL.createObjectURL(f),
|
|
});
|
|
}
|
|
|
|
tracks.sort((a, b) => a.title.localeCompare(b.title));
|
|
|
|
window.localFilesCache = tracks;
|
|
const btn = document.getElementById('select-local-folder-btn');
|
|
const btnText = document.getElementById('select-local-folder-text');
|
|
if (btnText) btnText.textContent = tracks.length + ' tracks loaded';
|
|
else if (btn) btn.textContent = tracks.length + ' tracks loaded';
|
|
if (btn) btn.disabled = false;
|
|
|
|
// Trigger library re-render if the function exists
|
|
if (typeof window.ui?.renderLibraryPage === 'function') {
|
|
window.ui.renderLibraryPage();
|
|
}
|
|
};
|
|
|
|
window._androidLocalFilesError = function() {
|
|
const btn = document.getElementById('select-local-folder-btn');
|
|
const btnText = document.getElementById('select-local-folder-text');
|
|
if (btnText) btnText.textContent = 'Select Music Folder';
|
|
else if (btn) btn.textContent = 'Select Music Folder';
|
|
if (btn) btn.disabled = false;
|
|
};
|
|
}
|
|
|
|
// ── MEDIA CONTROLS + CSS (async, loads after Capacitor bridge) ──
|
|
(async () => {
|
|
let AudioService;
|
|
try {
|
|
const { Capacitor, registerPlugin } = await import('@capacitor/core');
|
|
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') return;
|
|
AudioService = registerPlugin('AudioService');
|
|
|
|
// Hide navigation bar using the official Capacitor API (reinforces Java immersive mode)
|
|
const { SystemBars: SB, SystemBarType: SBT } = await import('@capacitor/core');
|
|
SB.hide({ bar: SBT.NavigationBar }).catch(() => {});
|
|
SB.setStyle({ style: 'DARK' }).catch(() => {});
|
|
} catch { return; }
|
|
|
|
// CSS: notch + notifications position
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.main-content { padding-bottom: 120px !important; }
|
|
.side-panel { padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) !important; }
|
|
#queue-modal { padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) !important; }
|
|
#download-notifications { top: 20px !important; bottom: auto !important; }
|
|
#sidebar-nav-download-bottom { display: none !important; }
|
|
#cast-btn, #fs-cast-btn { display: none !important; }
|
|
.track-item-cover { width: 56px !important; height: 56px !important; border-radius: 6px !important; }
|
|
.track-item-details .title { font-size: 1.05rem !important; }
|
|
.track-item-details .artist { font-size: 0.85rem !important; }
|
|
html {
|
|
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) !important;
|
|
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) !important;
|
|
background: #000 !important;
|
|
}
|
|
.main-header { gap: 4px !important; padding: 6px 8px !important; flex-wrap: nowrap !important; overflow: hidden !important; max-width: 100vw !important; box-sizing: border-box !important; }
|
|
.main-header .search-bar { min-width: 0 !important; flex: 1 1 0% !important; width: 0 !important; }
|
|
.main-header .header-account-control { margin-right: 2px !important; flex-shrink: 0 !important; }
|
|
.main-header .hamburger-menu { flex-shrink: 0 !important; }
|
|
#android-back-btn { flex-shrink: 0 !important; }
|
|
#android-back-btn:active { background: var(--muted); border-radius: 50%; }
|
|
.card-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 12px !important; padding: 0 8px !important; }
|
|
.card { overflow: hidden !important; }
|
|
.card .card-image-wrapper { aspect-ratio: 1 !important; }
|
|
.card .card-image { width: 100% !important; height: 100% !important; object-fit: cover !important; }
|
|
.card .card-info { text-align: center !important; padding: 8px 4px !important; }
|
|
.card .card-title { font-size: 0.9rem !important; white-space: normal !important; word-wrap: break-word !important; line-height: 1.3 !important; display: -webkit-box !important; -webkit-line-clamp: 2 !important; -webkit-box-orient: vertical !important; overflow: hidden !important; }
|
|
.card .card-subtitle { font-size: 0.8rem !important; }
|
|
.card.compact { flex-direction: column !important; align-items: center !important; padding: 8px !important; }
|
|
.card.artist .card-image-wrapper { border-radius: 50% !important; overflow: hidden !important; margin: 0 auto !important; width: 140px !important; height: 140px !important; }
|
|
.card.artist .card-image { border-radius: 50% !important; width: 140px !important; height: 140px !important; object-fit: cover !important; }
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// ── BACK BUTTON (inside header, before hamburger) ──
|
|
const hamburger = document.getElementById('hamburger-btn');
|
|
if (hamburger) {
|
|
const backBtn = document.createElement('button');
|
|
backBtn.id = 'android-back-btn';
|
|
backBtn.title = 'Back';
|
|
backBtn.style.cssText = 'display:none;background:none;border:none;color:var(--foreground);padding:6px;cursor:pointer;flex-shrink:0;';
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svg.setAttribute('width', '24');
|
|
svg.setAttribute('height', '24');
|
|
svg.setAttribute('viewBox', '0 0 24 24');
|
|
svg.setAttribute('fill', 'none');
|
|
svg.setAttribute('stroke', 'currentColor');
|
|
svg.setAttribute('stroke-width', '2');
|
|
svg.setAttribute('stroke-linecap', 'round');
|
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
pathEl.setAttribute('d', 'm15 18-6-6 6-6');
|
|
svg.appendChild(pathEl);
|
|
backBtn.appendChild(svg);
|
|
hamburger.parentNode.insertBefore(backBtn, hamburger);
|
|
|
|
function updateBackButton() {
|
|
const p = window.location.pathname;
|
|
const isSubPage = p !== '/' && p !== '/home' && p !== '';
|
|
backBtn.style.display = isSubPage ? 'inline-flex' : 'none';
|
|
}
|
|
|
|
backBtn.addEventListener('click', () => {
|
|
window.history.back();
|
|
});
|
|
|
|
const origPushState = history.pushState.bind(history);
|
|
history.pushState = function() {
|
|
origPushState.apply(this, arguments);
|
|
updateBackButton();
|
|
// Force scroll to top on navigation
|
|
const mc = document.querySelector('.main-content');
|
|
if (mc) mc.scrollTop = 0;
|
|
window.scrollTo(0, 0);
|
|
};
|
|
const origReplaceState = history.replaceState.bind(history);
|
|
history.replaceState = function() {
|
|
origReplaceState.apply(this, arguments);
|
|
updateBackButton();
|
|
};
|
|
window.addEventListener('popstate', () => {
|
|
updateBackButton();
|
|
});
|
|
updateBackButton();
|
|
}
|
|
|
|
// Media commands from notification
|
|
AudioService.addListener('mediaCommand', (data) => {
|
|
const audio = document.getElementById('audio-player');
|
|
if (!audio) return;
|
|
switch (data.command) {
|
|
case 'play': audio.play().catch(() => {}); break;
|
|
case 'pause': audio.pause(); break;
|
|
case 'next': document.getElementById('next-btn')?.click(); break;
|
|
case 'prev': document.getElementById('prev-btn')?.click(); break;
|
|
}
|
|
});
|
|
|
|
function getTrackInfo() {
|
|
const titleEl = document.querySelector('.now-playing-bar .title');
|
|
const artistEl = document.querySelector('.now-playing-bar .artist');
|
|
const coverEl = document.querySelector('.now-playing-bar .track-info img.cover');
|
|
const audio = document.getElementById('audio-player');
|
|
return {
|
|
title: titleEl?.textContent?.trim() || 'Fabiodalez Music',
|
|
artist: artistEl?.textContent?.trim() || 'Music',
|
|
cover: coverEl?.src || null,
|
|
position: Math.floor((audio?.currentTime || 0) * 1000),
|
|
duration: Math.floor((audio?.duration || 0) * 1000),
|
|
};
|
|
}
|
|
|
|
function sendUpdate(playing) {
|
|
const info = getTrackInfo();
|
|
AudioService.start({
|
|
title: info.title, text: info.artist, cover: info.cover,
|
|
playing, position: info.position, duration: info.duration,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// Start with placeholder
|
|
AudioService.start({
|
|
title: 'Fabiodalez Music', text: 'Select a song', cover: null,
|
|
playing: false, position: 0, duration: 0,
|
|
}).catch(() => {});
|
|
|
|
const audio = document.getElementById('audio-player');
|
|
if (!audio) return;
|
|
|
|
let lastUpdate = 0;
|
|
audio.addEventListener('timeupdate', () => {
|
|
const now = Date.now();
|
|
if (now - lastUpdate < 3000) return;
|
|
lastUpdate = now;
|
|
sendUpdate(true);
|
|
});
|
|
|
|
const titleObserver = new MutationObserver(() => {
|
|
if (document.title && document.title.includes('\u2022')) {
|
|
setTimeout(() => sendUpdate(!audio.paused), 500);
|
|
}
|
|
});
|
|
const titleTag = document.querySelector('title');
|
|
if (titleTag) {
|
|
titleObserver.observe(titleTag, { childList: true, characterData: true, subtree: true });
|
|
}
|
|
|
|
audio.addEventListener('playing', () => sendUpdate(true));
|
|
audio.addEventListener('pause', () => sendUpdate(false));
|
|
audio.addEventListener('ended', () => sendUpdate(false));
|
|
})();
|