Files
FlClash/site/assets/app.js
T

1078 lines
39 KiB
JavaScript

(() => {
'use strict';
const PLATFORMS = ['android', 'windows', 'macos', 'linux'];
const PLATFORM_NAMES = { android: 'Android', windows: 'Windows', macos: 'macOS', linux: 'Linux' };
const PAGE = 15;
const { REPO, FIRST_PAGE, parseChangelog, translate, formatDate, changelogMeta, timelineHtml } =
globalThis.FlClashSite;
const SITE = new URL('..', document.currentScript.src);
const root = document.documentElement;
const $ = (selector, scope = document) => scope.querySelector(selector);
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
const springEasing = CSS.supports('transition-timing-function', 'linear(0, 1)')
? getComputedStyle(root).getPropertyValue('--spring').trim()
: 'cubic-bezier(0.22, 1, 0.36, 1)';
const COS30 = Math.cos(Math.PI / 6);
let field = null;
let counting = false;
const state = {
lang: root.lang.startsWith('zh') ? 'zh' : 'en',
platform: 'android',
arch: 'x64',
detected: { platform: null, arch: 'x64', ios: false },
platformChosen: false,
release: null,
releaseFailed: false,
versions: [],
changelogFailed: false,
shown: FIRST_PAGE,
query: '',
};
function t(key, ...args) {
return translate(state.lang, key, ...args);
}
function el(tag, props, ...children) {
const node = document.createElement(tag);
if (props) {
for (const [key, value] of Object.entries(props)) {
if (value == null || value === false) continue;
if (key === 'class') node.className = value;
else if (key === 'text') node.textContent = value;
else if (key === 'dataset') Object.assign(node.dataset, value);
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? '' : value);
}
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
node.append(child);
}
return node;
}
function svg(markup) {
const template = document.createElement('template');
template.innerHTML = markup.trim();
return template.content.firstChild;
}
const ICON_DOWN =
'<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="M10 3.5v11m0 0-4.5-4.5m4.5 4.5 4.5-4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ICON_COPY =
'<svg class="ico-copy" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><rect x="6.75" y="6.75" width="9.5" height="9.5" rx="2.25" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M13.25 4.75v-.5a1.5 1.5 0 0 0-1.5-1.5h-6.5a2.5 2.5 0 0 0-2.5 2.5v6.5a1.5 1.5 0 0 0 1.5 1.5h.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
const ICON_CHECK =
'<svg class="ico-check" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const LANE_IN = [
{ opacity: 0, transform: 'translate(-16px, 9px)' },
{ opacity: 1, transform: 'none' },
];
function animate(node, keyframes, options) {
if (reducedMotion.matches || !node.animate) return null;
return node.animate(keyframes, options);
}
function locale() {
return state.lang === 'zh' ? 'zh-CN' : 'en';
}
function formatAgo(iso) {
if (!iso) return '';
const days = Math.round((Date.now() - Date.parse(`${iso}T00:00:00Z`)) / 86400000);
const rtf = new Intl.RelativeTimeFormat(locale(), { numeric: 'auto' });
if (days < 7) return rtf.format(-Math.max(days, 0), 'day');
if (days < 30) return rtf.format(-Math.round(days / 7), 'week');
if (days < 365) return rtf.format(-Math.round(days / 30.4), 'month');
return rtf.format(-Math.round(days / 365), 'year');
}
function formatSize(bytes) {
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function goArch(arch) {
return arch === 'arm64' ? 'arm64' : 'amd64';
}
function buildsFor(platform, arch) {
switch (platform) {
case 'android':
return [
{ id: 'android-arm64-v8a.apk', kind: 'APK', title: 'arm64-v8a', note: 'noteAndroidArm64', rec: true },
{ id: 'android-armeabi-v7a.apk', kind: 'APK', title: 'armeabi-v7a', note: 'noteAndroidArm32' },
{ id: 'android-x86_64.apk', kind: 'APK', title: 'x86_64', note: 'noteAndroidX64' },
];
case 'windows':
return [
{ id: `windows-${goArch(arch)}-setup.exe`, kind: 'EXE', titleKey: 'winSetup', note: 'noteWinSetup', rec: true },
{ id: `windows-${goArch(arch)}.zip`, kind: 'ZIP', titleKey: 'winPortable', note: 'noteWinPortable' },
];
case 'macos':
return [
{ id: 'macos-arm64.dmg', kind: 'DMG', title: 'Apple Silicon', note: 'noteMacArm', rec: arch === 'arm64' },
{ id: 'macos-amd64.dmg', kind: 'DMG', title: 'Intel', note: 'noteMacIntel', rec: arch !== 'arm64' },
];
case 'linux':
return [
{ id: `linux-${goArch(arch)}.deb`, kind: 'DEB', title: 'Debian / Ubuntu', short: '.deb', note: 'noteDeb', rec: true },
{ id: `linux-${goArch(arch)}.rpm`, kind: 'RPM', title: 'Fedora / openSUSE', short: '.rpm', note: 'noteRpm' },
{ id: `linux-${goArch(arch)}.AppImage`, kind: 'APPIMAGE', title: 'AppImage', note: 'noteAppImage' },
];
default:
return [];
}
}
function resolveBuilds(platform, arch) {
const release = state.release;
if (!release) return [];
return buildsFor(platform, arch)
.map((build) => {
const name = `FlClash-${release.version}-${build.id}`;
const asset = release.assets ? release.assets.get(name) : null;
if (release.assets && !asset) return null;
return {
...build,
name,
title: build.titleKey ? t(build.titleKey) : build.title,
href: `${REPO}/releases/download/${release.tag}/${name}`,
size: asset ? asset.size : null,
digest: asset && asset.digest ? asset.digest.replace(/^sha256:/, '') : null,
};
})
.filter(Boolean);
}
async function detectDevice() {
const ua = navigator.userAgent;
const result = { platform: null, arch: null, ios: false };
if (/android/i.test(ua)) result.platform = 'android';
else if (/iphone|ipad|ipod/i.test(ua) || (/macintosh/i.test(ua) && navigator.maxTouchPoints > 1)) result.ios = true;
else if (/windows/i.test(ua)) result.platform = 'windows';
else if (/macintosh|mac os x/i.test(ua)) result.platform = 'macos';
else if (/linux|x11|cros/i.test(ua)) result.platform = 'linux';
if (/aarch64|arm64/i.test(ua)) result.arch = 'arm64';
try {
const hints = await navigator.userAgentData?.getHighEntropyValues(['architecture']);
if (hints?.architecture === 'arm') result.arch = 'arm64';
else if (hints?.architecture === 'x86') result.arch = 'x64';
} catch {}
if (result.platform === 'macos' && !result.arch) result.arch = guessMacArch();
if (!result.arch) result.arch = result.platform === 'macos' ? 'arm64' : 'x64';
return result;
}
function guessMacArch() {
try {
const gl = document.createElement('canvas').getContext('webgl');
const info = gl && gl.getExtension('WEBGL_debug_renderer_info');
const renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : '';
if (/intel|amd|radeon|nvidia/i.test(renderer)) return 'x64';
} catch {}
return 'arm64';
}
async function loadData() {
const [markdown, release] = await Promise.allSettled([
fetch(new URL('CHANGELOG.md', SITE), { cache: 'no-cache' }).then((r) => (r.ok ? r.text() : Promise.reject(r.status))),
fetch(new URL('release.json', SITE), { cache: 'no-cache' }).then((r) => (r.ok ? r.json() : null)),
]);
if (markdown.status === 'fulfilled') state.versions = parseChangelog(markdown.value);
else state.changelogFailed = true;
const meta = release.status === 'fulfilled' ? release.value : null;
if (meta && meta.tag) {
state.release = {
tag: meta.tag,
version: meta.tag.replace(/^v/, ''),
date: (meta.publishedAt || '').slice(0, 10),
assets: new Map((meta.assets || []).map((asset) => [asset.name, asset])),
stars: meta.stars,
};
} else {
const stable = state.versions.find((v) => !v.tag.includes('-'));
if (stable) {
state.release = { tag: stable.tag, version: stable.version, date: stable.date, assets: null, stars: null };
} else {
state.releaseFailed = true;
}
}
}
function applyStaticText() {
root.lang = state.lang === 'zh' ? 'zh-CN' : 'en';
document.title = t('pageTitle');
$('meta[name="description"]').content = t('pageDescription');
for (const node of document.querySelectorAll('[data-i18n]')) node.textContent = t(node.dataset.i18n);
for (const node of document.querySelectorAll('[data-i18n-alt]')) node.alt = t(node.dataset.i18nAlt);
for (const node of document.querySelectorAll('[data-i18n-placeholder]')) {
node.placeholder = t(node.dataset.i18nPlaceholder);
}
const langToggle = $('#lang-toggle');
langToggle.textContent = t('langToggle');
langToggle.setAttribute('aria-label', t('langLabel'));
langToggle.lang = state.lang === 'zh' ? 'en' : 'zh-CN';
$('#theme-toggle').setAttribute('aria-label', t('themeLabel'));
$('#reel').setAttribute('aria-label', `${t('reelLabel')}: ${PLATFORM_NAMES[state.platform]}`);
$('#platform-seg').setAttribute('aria-label', t('platformLabel'));
$('#arch-seg').setAttribute('aria-label', t('archLabel'));
}
function renderReleaseMeta() {
const release = state.release;
const heroText = $('#hero-release-text');
if (release) {
heroText.textContent = t('heroRelease', release.tag);
$('#hero-release').href = `#${release.tag}`;
$('#dl-meta').textContent = t('dlMeta', release.version, formatDate(release.date, state.lang));
$('#sums-link').href = `${REPO}/releases/download/${release.tag}/SHA256SUMS`;
$('#sums-link').hidden = !release.assets || !release.assets.has('SHA256SUMS');
} else {
heroText.textContent = t('heroReleaseLoading');
}
const stars = $('#stars');
if (release && typeof release.stars === 'number') {
const format = starFormat();
const compact = format.format(release.stars);
if (stars.hidden) {
stars.hidden = false;
countUp(stars, release.stars, format);
} else if (!counting) {
stars.textContent = compact;
}
$('#gh-link').setAttribute('aria-label', `GitHub, ${t('stars', compact)}`);
}
}
function starFormat() {
return new Intl.NumberFormat(locale(), { notation: 'compact', maximumFractionDigits: 1 });
}
// Counts in the final figure's own unit and precision, inside a box as wide as the final figure, so neither a new
// digit nor a switch to a compact suffix moves the nav while it runs.
function countUp(node, value, format) {
const final = format.format(value);
node.textContent = final;
if (reducedMotion.matches) return;
const parts = format.formatToParts(value);
const number = parts.filter((part) => ['integer', 'group', 'decimal', 'fraction'].includes(part.type));
const text = (list) => list.map((part) => part.value).join('');
const prefix = text(parts.slice(0, parts.indexOf(number[0])));
const suffix = text(parts.slice(parts.indexOf(number[number.length - 1]) + 1));
const decimal = parts.find((part) => part.type === 'decimal')?.value ?? '.';
const digits = parts.find((part) => part.type === 'fraction')?.value.length ?? 0;
const shown = Number(text(number.filter((part) => part.type !== 'group')).replace(decimal, '.'));
node.style.minWidth = `${node.getBoundingClientRect().width}px`;
counting = true;
const start = performance.now();
const step = (now) => {
const progress = Math.min(Math.max((now - start) / 1100, 0), 1);
const current = (shown * (1 - (1 - progress) ** 4)).toFixed(digits).replace('.', decimal);
node.textContent = progress < 1 ? prefix + current + suffix : final;
if (progress < 1) {
requestAnimationFrame(step);
} else {
node.style.minWidth = '';
counting = false;
}
};
step(start);
}
function renderCta() {
const cta = $('#cta');
const title = $('#cta-title');
const sub = $('#cta-sub');
const note = $('#cta-note');
note.hidden = !state.detected.ios;
note.textContent = state.detected.ios ? t('ctaIos') : '';
const builds = state.detected.ios && !state.platformChosen ? [] : resolveBuilds(state.platform, state.arch);
const pick = builds.find((b) => b.rec) || builds[0];
if (!pick) {
title.textContent = t('ctaChoose');
sub.hidden = true;
cta.href = '#download';
return;
}
title.textContent = t('ctaDownload', PLATFORM_NAMES[state.platform]);
const parts = [pick.short || pick.title];
if (state.platform === 'windows' || state.platform === 'linux') parts.unshift(state.arch === 'arm64' ? 'ARM64' : 'x64');
if (pick.size) parts.push(formatSize(pick.size));
sub.textContent = parts.join(' · ');
sub.hidden = false;
cta.href = pick.href;
}
function moveInk(seg, withMotion) {
const ink = $('.seg-ink', seg);
const active = $('[aria-selected="true"]', seg);
if (!ink || !active) return;
const left = active.offsetLeft;
const previous = parseFloat(ink.style.left);
ink.classList.toggle('to-end', withMotion && left > previous);
ink.classList.toggle('to-start', withMotion && left < previous);
if (!withMotion) ink.style.transition = 'none';
ink.style.left = `${left}px`;
ink.style.right = `${seg.clientWidth - left - active.offsetWidth}px`;
if (!withMotion) {
void ink.offsetWidth;
ink.style.transition = '';
}
}
function selectTab(seg, value, withMotion) {
for (const button of seg.querySelectorAll('[role="tab"]')) {
const selected = button.dataset.value === value;
button.setAttribute('aria-selected', String(selected));
button.tabIndex = selected ? 0 : -1;
}
moveInk(seg, withMotion);
}
function wireTabs(seg, onSelect) {
seg.addEventListener('click', (event) => {
const button = event.target.closest('[role="tab"]');
if (button) onSelect(button.dataset.value);
});
seg.addEventListener('keydown', (event) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const tabs = [...seg.querySelectorAll('[role="tab"]')];
const index = tabs.indexOf(document.activeElement);
if (index < 0) return;
event.preventDefault();
let next = index;
if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = tabs.length - 1;
tabs[next].focus();
onSelect(tabs[next].dataset.value);
});
}
function fileRow(build) {
const sha = build.digest
? el('button', {
class: 'sha',
type: 'button',
text: 'SHA-256',
title: build.digest,
'aria-label': t('copySha', build.name),
onclick: (event) => copyText(build.digest, event.currentTarget),
})
: null;
return el(
'li',
{ class: build.rec ? 'file rec' : 'file' },
el('span', { class: build.kind.length > 4 ? 'kind long' : 'kind', 'aria-hidden': 'true', text: build.kind }),
el(
'div',
{ class: 'file-main' },
el('a', { class: 'file-title', href: build.href, title: build.name, text: build.title }),
build.rec ? el('span', { class: 'rec-tag', text: t('recommended') }) : null,
el('p', { class: 'note', text: t(build.note) }),
),
el('div', { class: 'meta' }, build.size ? el('span', { text: formatSize(build.size) }) : null, sha),
el('span', { class: 'go', 'aria-hidden': 'true' }, svg(ICON_DOWN)),
);
}
function commandBlock(text) {
return el(
'div',
{ class: 'cmd' },
el('pre', {}, el('code', { text })),
el(
'button',
{
class: 'copy',
type: 'button',
'aria-label': t('copy'),
onclick: (event) => copyText(text, event.currentTarget),
},
svg(ICON_COPY),
svg(ICON_CHECK),
),
);
}
function tip(titleKey, textKey, ...extra) {
return el('div', { class: 'tip' }, el('h3', { text: t(titleKey) }), el('p', { text: t(textKey) }), ...extra);
}
function extrasFor(platform) {
switch (platform) {
case 'android':
return [
tip(
'tipFdroidTitle',
'tipFdroid',
el(
'p',
{},
el('a', {
href: 'https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD',
text: t('tipFdroidLink'),
}),
),
),
tip(
'tipIntentTitle',
'tipIntent',
commandBlock(
['com.follow.clash.action.START', 'com.follow.clash.action.STOP', 'com.follow.clash.action.TOGGLE'].join(
'\n',
),
),
),
];
case 'macos':
return [tip('tipBrewTitle', 'tipBrew', commandBlock('brew tap chen08209/tap\nbrew install --cask flclash'))];
case 'linux':
return [
tip(
'tipTrayTitle',
'tipTray',
commandBlock('sudo apt-get install libayatana-appindicator3-1 # Debian, Ubuntu'),
commandBlock('sudo dnf install libayatana-appindicator-gtk3 # Fedora'),
),
];
default:
return [];
}
}
function renderPanel(withMotion) {
const panel = $('#panel');
const startHeight = panel.offsetHeight;
const hints = { android: 'hintAndroid', windows: 'hintWindows', macos: 'hintMacos', linux: 'hintLinux' };
$('#panel-hint').textContent = t(hints[state.platform]);
panel.setAttribute('aria-labelledby', `tab-${state.platform}`);
const archSeg = $('#arch-seg');
const archWasHidden = archSeg.hidden;
archSeg.hidden = !(state.platform === 'windows' || state.platform === 'linux');
if (!archSeg.hidden) selectTab(archSeg, state.arch, withMotion && !archWasHidden);
const files = $('#files');
const builds = resolveBuilds(state.platform, state.arch);
files.replaceChildren(...builds.map(fileRow));
$('#dl-error').hidden = !state.releaseFailed;
$('#dl-error').textContent = state.releaseFailed ? t('dlError') : '';
$('#extras').replaceChildren(...extrasFor(state.platform));
if (!withMotion) return;
const endHeight = panel.offsetHeight;
if (startHeight && startHeight !== endHeight) {
panel.style.overflow = 'clip';
const resize = animate(panel, [{ height: `${startHeight}px` }, { height: `${endHeight}px` }], {
duration: 520,
easing: springEasing,
});
if (resize) resize.onfinish = resize.oncancel = () => (panel.style.overflow = '');
else panel.style.overflow = '';
}
[...files.children, ...$('#extras').children].forEach((node, index) => {
animate(node, LANE_IN, { duration: 700, delay: 45 * index, easing: springEasing, fill: 'backwards' });
});
}
const reel = {
index: 0,
build() {
const words = [];
for (let cycle = 0; cycle < 3; cycle++) {
for (const platform of PLATFORMS) words.push(el('span', { text: PLATFORM_NAMES[platform] }));
}
$('#reel-track').replaceChildren(...words);
this.apply(0);
},
apply(duration) {
const button = $('#reel');
const track = $('#reel-track');
const motion = duration > 0 && !reducedMotion.matches;
track.style.transition = motion ? `transform ${duration}ms ${springEasing}` : 'none';
button.style.transition = motion ? `width ${duration}ms ${springEasing}` : 'none';
track.style.transform = `translateY(${-this.index * 1.12}em)`;
button.style.width = `${track.children[this.index].getBoundingClientRect().width}px`;
$('#reel-current').textContent = PLATFORM_NAMES[PLATFORMS[this.index % PLATFORMS.length]];
if (!motion) void track.offsetWidth;
},
roll(platform, { full = false } = {}) {
const n = PLATFORMS.length;
let steps = (PLATFORMS.indexOf(platform) - (this.index % n) + n) % n;
if (full) steps += n;
if (!steps) return;
if (this.index + steps >= n * 3) {
this.index -= n;
this.apply(0);
}
this.index += steps;
this.apply(full ? 1500 : 700);
if (reducedMotion.matches) this.settle();
},
settle() {
const n = PLATFORMS.length;
if (this.index >= n && this.index < 2 * n) return;
this.index = n + (this.index % n);
this.apply(0);
},
refit() {
const button = $('#reel');
button.style.transition = 'none';
button.style.width = `${$('#reel-track').children[this.index].getBoundingClientRect().width}px`;
},
};
function setPlatform(platform, { from } = {}) {
if (!PLATFORM_NAMES[platform]) return;
const changed = platform !== state.platform;
state.platform = platform;
state.platformChosen = true;
if (platform === 'macos' || platform === 'windows' || platform === 'linux') {
state.arch = state.detected.platform === platform ? state.detected.arch : platform === 'macos' ? 'arm64' : 'x64';
}
selectTab($('#platform-seg'), platform, true);
if (from !== 'reel' && changed) reel.roll(platform);
if (changed) field?.surge();
$('#reel').setAttribute('aria-label', `${t('reelLabel')}: ${PLATFORM_NAMES[platform]}`);
renderCta();
renderPanel(changed);
}
function setArch(arch) {
if (arch === state.arch) return;
state.arch = arch;
renderCta();
renderPanel(true);
}
function matchVersion(version, query) {
if (version.version.includes(query) || version.tag.toLowerCase().includes(query)) {
return version.groups;
}
return version.groups
.map((group) => ({
...group,
entries: group.entries.filter(
(entry) =>
entry.text.toLowerCase().includes(query) ||
(entry.scope && entry.scope.toLowerCase().includes(query)) ||
(entry.id && entry.id.startsWith(query)),
),
}))
.filter((group) => group.entries.length);
}
const revealer =
'IntersectionObserver' in window
? new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.replace('pending', 'shown');
revealer.unobserve(entry.target);
}
},
{ rootMargin: '0px 0px -10% 0px' },
)
: null;
function stageReveal(nodes, showVisible = false) {
if (!revealer || reducedMotion.matches) return;
for (const node of nodes) {
if (node.getBoundingClientRect().top < innerHeight * 0.9) {
if (showVisible) node.classList.add('shown');
continue;
}
node.classList.add('pending');
revealer.observe(node);
}
}
let lastYear = null;
function appendReleases(container, items, query, showVisible = false) {
const { html, year } = timelineHtml(items, {
lang: state.lang,
query,
latestTag: state.versions[0]?.tag,
ago: formatAgo,
year: lastYear,
});
lastYear = year;
const template = document.createElement('template');
template.innerHTML = html;
const releases = [...template.content.querySelectorAll('.release')];
container.append(template.content);
stageReveal(releases, showVisible);
}
function renderTimeline() {
const timeline = $('#timeline');
const status = $('#cl-status');
const more = $('#cl-more');
if (state.changelogFailed) {
status.replaceChildren(
t('clError'),
' ',
el('a', { href: `${REPO}/blob/main/CHANGELOG.md`, text: t('clErrorLink') }),
);
more.hidden = true;
return;
}
timeline.replaceChildren();
lastYear = null;
const versions = state.versions;
$('#cl-meta').textContent = changelogMeta(versions, state.lang);
const query = state.query.trim().toLowerCase();
if (!query) {
status.replaceChildren();
appendReleases(
timeline,
versions.slice(0, state.shown).map((version) => ({ version, groups: version.groups })),
'',
);
updateMore();
return;
}
const matches = versions
.map((version) => ({ version, groups: matchVersion(version, query) }))
.filter(({ version, groups }) => groups.length || version.version.includes(query));
const entryCount = matches.reduce(
(sum, { groups }) => sum + groups.reduce((n, group) => n + group.entries.length, 0),
0,
);
const clear = el('button', {
type: 'button',
text: t('clClear'),
onclick: () => {
$('#cl-search').value = '';
state.query = '';
renderTimeline();
$('#cl-search').focus();
},
});
if (matches.length) {
status.replaceChildren(t('clResults', entryCount, matches.length), ' · ', clear);
} else {
status.replaceChildren(t('clNone', state.query.trim()), ' ', clear);
}
appendReleases(timeline, matches, query);
more.hidden = true;
}
function updateMore() {
const more = $('#cl-more');
const remaining = state.versions.length - state.shown;
more.hidden = remaining <= 0 || Boolean(state.query.trim());
more.textContent = remaining > 0 ? t('clMore', remaining) : '';
}
function showMore() {
const from = state.shown;
state.shown = Math.min(state.versions.length, state.shown + PAGE);
appendReleases(
$('#timeline'),
state.versions.slice(from, state.shown).map((version) => ({ version, groups: version.groups })),
'',
true,
);
updateMore();
}
function revealHash() {
let tag;
try {
tag = decodeURIComponent(location.hash.slice(1));
} catch {
return;
}
const index = state.versions.findIndex((v) => v.tag === tag);
if (index < 0) return;
if (index >= state.shown) {
state.shown = Math.min(state.versions.length, Math.ceil((index + 1) / PAGE) * PAGE);
renderTimeline();
}
requestAnimationFrame(() => document.getElementById(tag)?.scrollIntoView());
}
async function copyText(text, button) {
let ok = false;
try {
await navigator.clipboard.writeText(text);
ok = true;
} catch {
const area = el('textarea', { readonly: true, style: 'position:fixed;opacity:0' });
area.value = text;
document.body.append(area);
area.select();
try {
ok = document.execCommand('copy');
} catch {}
area.remove();
}
if (!ok) return;
button.classList.add('done');
const label = button.classList.contains('sha') ? button.textContent : null;
if (label) button.textContent = t('copied');
clearTimeout(button._reset);
button._reset = setTimeout(() => {
button.classList.remove('done');
if (label) button.textContent = label;
}, 1600);
}
function effectiveTheme() {
if (root.dataset.theme) return root.dataset.theme;
return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function syncShot() {
const theme = root.dataset.theme;
$('#shot-dark').media = theme ? (theme === 'dark' ? 'all' : 'not all') : '(prefers-color-scheme: dark)';
}
function laneWipe(originX, originY) {
const width = innerWidth;
const height = innerHeight;
const span = width * 0.5 + height * COS30;
const bands = Math.max(6, Math.round(span / 110));
const pitch = span / bands;
const from = -height * 0.5 - 2;
const to = width * COS30 + 2;
const origin = Math.floor((originX * 0.5 + originY * COS30) / pitch);
const lag = 0.5 / Math.max(origin, bands - 1 - origin, 1);
const at = (across, along) =>
`${(across * 0.5 + along * COS30).toFixed(1)}px ${(across * COS30 - along * 0.5).toFixed(1)}px`;
const frames = [];
for (let frame = 0; frame <= 16; frame++) {
const points = [];
for (let band = 0; band < bands; band++) {
const local = Math.min(Math.max((frame / 16 - Math.abs(band - origin) * lag) * 2, 0), 1);
const half = (pitch / 2 + 1) * (1 - (1 - local) ** 3);
const middle = (band + 0.5) * pitch;
points.push(at(middle - half, from), at(middle - half, to), at(middle + half, to), at(middle + half, from));
}
frames.push(`polygon(${points.join(', ')})`);
}
return frames;
}
function toggleTheme(event) {
const next = effectiveTheme() === 'dark' ? 'light' : 'dark';
const apply = () => {
root.dataset.theme = next;
syncShot();
field?.refresh();
try {
localStorage.setItem('flclash.theme', next);
} catch {}
};
if (!document.startViewTransition || reducedMotion.matches) {
apply();
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const frames = laneWipe(rect.left + rect.width / 2, rect.top + rect.height / 2);
document
.startViewTransition(apply)
.ready.then(() => {
root.animate({ clipPath: frames }, { duration: 820, easing: 'linear', pseudoElement: '::view-transition-new(root)' });
})
.catch(() => {});
}
function rememberLang(lang) {
try {
localStorage.setItem('flclash.lang', lang);
} catch {}
}
function anchorAtTop() {
const line = (parseFloat(getComputedStyle(root).scrollPaddingTop) || 0) + 1;
let anchor = '';
for (const node of document.querySelectorAll('#download, #changelog, .release')) {
if (node.getBoundingClientRect().top > line) break;
anchor = `#${node.id}`;
}
return anchor;
}
function carryView(link) {
if (!link.dataset.base) link.dataset.base = link.getAttribute('href');
const url = new URL(link.dataset.base, location.href);
if (state.platformChosen) {
url.searchParams.set('platform', state.platform);
url.searchParams.set('arch', state.arch);
}
const query = state.query.trim();
if (query) url.searchParams.set('q', query);
url.hash = anchorAtTop();
link.href = url.href;
}
function takeCarriedView() {
const params = new URLSearchParams(location.search);
const view = { platform: params.get('platform'), arch: params.get('arch'), query: params.get('q') };
if (!['platform', 'arch', 'q'].some((key) => params.has(key))) return view;
for (const key of ['platform', 'arch', 'q']) params.delete(key);
const search = params.toString();
history.replaceState(history.state, '', `${location.pathname}${search ? `?${search}` : ''}${location.hash}`);
return view;
}
function setLang(next) {
state.lang = next;
rememberLang(next);
applyStaticText();
renderReleaseMeta();
renderCta();
renderPanel(false);
renderTimeline();
reel.refit();
moveInk($('#platform-seg'), false);
field?.refresh();
}
function wireGlyph() {
const glyph = $('.brand .glyph');
const last = $('.sig-3', glyph);
glyph.addEventListener('animationend', (event) => {
if (event.target === last) glyph.classList.remove('enter', 'wave');
});
$('.brand').addEventListener('mouseenter', () => {
if (!glyph.classList.contains('enter')) glyph.classList.add('wave');
});
glyph.classList.add('enter');
}
function splitWordmark() {
const word = $('#wordmark');
const text = word.textContent;
word.replaceChildren(
el('span', { class: 'sr', text }),
el(
'span',
{ 'aria-hidden': 'true' },
[...text].map((letter, index) => el('span', { class: 'ch', style: `--i:${index}`, text: letter })),
),
);
word.classList.add('split');
}
function wireHoverInk(group) {
const ink = el('span', { class: 'hover-ink', 'aria-hidden': 'true' });
group.prepend(ink);
group.addEventListener('pointerover', (event) => {
const item = event.target.closest('a, button');
if (event.pointerType !== 'mouse' || !item || !group.contains(item)) return;
const landing = !ink.classList.contains('on');
if (landing) ink.style.transition = 'none';
ink.style.width = `${item.offsetWidth}px`;
ink.style.transform = `translateX(${item.offsetLeft}px)`;
if (landing) {
void ink.offsetWidth;
ink.style.transition = '';
}
ink.classList.add('on');
});
group.addEventListener('pointerleave', () => ink.classList.remove('on'));
}
function wireMagnet(button) {
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
button.addEventListener('pointermove', (event) => {
if (reducedMotion.matches) return;
const rect = button.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
button.style.setProperty('--pull-x', `${(x * 10).toFixed(1)}px`);
button.style.setProperty('--pull-y', `${(y * 8).toFixed(1)}px`);
});
button.addEventListener('pointerleave', () => {
button.style.removeProperty('--pull-x');
button.style.removeProperty('--pull-y');
});
}
// Offsets rather than client rects: the hero is the offset parent of all of these and of the canvas, and the
// entrance animations move the client rects while they run.
function layoutBox(node) {
return {
left: node.offsetLeft,
top: node.offsetTop,
right: node.offsetLeft + node.offsetWidth,
bottom: node.offsetTop + node.offsetHeight,
};
}
function taglineBox() {
const box = layoutBox($('#tagline'));
const widest = Math.max(...[...$('#reel-track').children].map((word) => word.offsetWidth));
return { ...box, right: box.right - $('#reel').offsetWidth + widest };
}
function wireField() {
field = globalThis.FlClashField?.mount($('#field'), {
avoid: () => [
taglineBox(),
...[...document.querySelectorAll('#hero-release, #wordmark, .lede, .cta, #cta-note, #facts')]
.filter((node) => node.offsetWidth)
.map(layoutBox),
],
stage: () => ($('#stage').offsetWidth ? layoutBox($('#stage')) : null),
still: () => reducedMotion.matches,
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => field?.refresh());
}
// Scroll-driven CSS animations stutter in Android WebView at 120 Hz, so the page progress bar and the preview
// settling flat follow the scroll position from script instead.
function followScroll() {
if (reducedMotion.matches) return;
const range = root.scrollHeight - innerHeight;
const read = range > 0 ? Math.min(1, Math.max(0, scrollY / range)) : 0;
$('.nav-progress').style.transform = `scaleX(${read.toFixed(4)})`;
const shot = $('.shot');
let top = 0;
for (let node = shot; node; node = node.offsetParent) top += node.offsetTop;
const inset = parseFloat(getComputedStyle(root).scrollPaddingTop) || 0;
const travel = (innerHeight - inset + shot.offsetHeight) * 0.42;
const flat = Math.min(1, Math.max(0, (scrollY - top + innerHeight) / travel));
shot.style.transform =
flat < 1 ? `rotateX(${(18 * (1 - flat)).toFixed(2)}deg) scale(${(0.93 + 0.07 * flat).toFixed(4)})` : '';
}
function wireEvents() {
wireTabs($('#platform-seg'), (value) => setPlatform(value));
wireTabs($('#arch-seg'), (value) => setArch(value));
$('#reel').addEventListener('click', () => {
const next = PLATFORMS[(PLATFORMS.indexOf(state.platform) + 1) % PLATFORMS.length];
reel.roll(next);
setPlatform(next, { from: 'reel' });
});
$('#reel-track').addEventListener('transitionend', (event) => {
if (event.propertyName === 'transform') reel.settle();
});
for (const group of document.querySelectorAll('[data-ink]')) wireHoverInk(group);
wireMagnet($('#cta'));
$('#cta').addEventListener('click', () => field?.surge());
$('#theme-toggle').addEventListener('click', toggleTheme);
$('#lang-toggle').addEventListener('click', (event) => {
const next = state.lang === 'zh' ? 'en' : 'zh';
if (!root.dataset.pageLang) {
setLang(next);
return;
}
rememberLang(next);
carryView(event.currentTarget);
});
let searchTimer = 0;
$('#cl-search').addEventListener('input', (event) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.query = event.target.value;
renderTimeline();
}, 80);
});
$('#cl-search').addEventListener('keydown', (event) => {
if (event.key === 'Escape' && event.target.value) {
event.target.value = '';
state.query = '';
renderTimeline();
}
});
document.addEventListener('keydown', (event) => {
if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.target.closest('input, textarea, [contenteditable]')) return;
event.preventDefault();
$('#cl-search').focus({ preventScroll: true });
$('#changelog').scrollIntoView();
});
$('#cl-more').addEventListener('click', showMore);
addEventListener('hashchange', revealHash);
const nav = $('#nav');
const onScroll = () => {
nav.classList.toggle('scrolled', scrollY > 4);
followScroll();
};
addEventListener('scroll', onScroll, { passive: true });
onScroll();
if ('ResizeObserver' in window) new ResizeObserver(followScroll).observe(document.body);
let resizeFrame = 0;
addEventListener('resize', () => {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
moveInk($('#platform-seg'), false);
moveInk($('#arch-seg'), false);
reel.refit();
followScroll();
});
});
}
async function start() {
const carried = takeCarriedView();
syncShot();
splitWordmark();
applyStaticText();
reel.build();
selectTab($('#platform-seg'), state.platform, false);
wireEvents();
wireGlyph();
wireField();
const stars = $('#stars');
if (stars.dataset.count && !stars.hidden) countUp(stars, Number(stars.dataset.count), starFormat());
stageReveal(document.querySelectorAll('.section .rail, .dl, #facts, .foot'));
const [device] = await Promise.all([detectDevice(), loadData()]);
state.detected = device;
state.platform = device.platform || 'android';
state.arch = device.arch;
if (PLATFORMS.includes(carried.platform)) {
state.platform = carried.platform;
state.platformChosen = true;
if (carried.arch === 'x64' || carried.arch === 'arm64') state.arch = carried.arch;
}
if (carried.query) {
state.query = carried.query;
$('#cl-search').value = carried.query;
}
renderReleaseMeta();
selectTab($('#platform-seg'), state.platform, false);
renderCta();
renderPanel(false);
renderTimeline();
if (location.hash) revealHash();
field?.refresh();
document.fonts?.ready.then(() => {
moveInk($('#platform-seg'), false);
reel.refit();
field?.refresh();
});
setTimeout(() => reel.roll(state.platform, { full: true }), reducedMotion.matches ? 0 : 450);
}
start();
})();