Dynamic PWA Theme

Sets PWA titlebar color from manifest icon and other methods, with optional debug overlay

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Dynamic PWA Theme
// @namespace    http://example.com
// @version      1.9
// @description  Sets PWA titlebar color from manifest icon and other methods, with optional debug overlay
// @match        *://*/*
// @grant        none
// @run-at       document-start
// @author       jigglypug
// @license      MIT
// ==/UserScript==

(async function() {
    'use strict';

    const DEBUG = 1; // 0 = off, 1 = show debug overlay

    async function getImageDominantColor(url) {
        return new Promise(resolve => {
            const img = new Image();
            img.crossOrigin = 'Anonymous';
            img.src = url;
            img.onload = () => {
                const canvas = document.createElement('canvas');
                const w = 64, h = 64;
                canvas.width = w;
                canvas.height = h;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0, w, h);
                const data = ctx.getImageData(0, 0, w, h).data;

                const colorCount = {};
                for (let i = 0; i < data.length; i += 4) {
                    const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
                    if (a < 128) continue;
                    if (r > 240 && g > 240 && b > 240) continue;
                    if (r < 15 && g < 15 && b < 15) continue;
                    // Use a numeric key instead of a string to avoid thousands of string allocations
                    const key = (r << 16) | (g << 8) | b;
                    colorCount[key] = (colorCount[key] || 0) + 1;
                }

                let max = 0, dominant = null;
                for (const key in colorCount) {
                    if (colorCount[key] > max) {
                        max = colorCount[key];
                        dominant = key;
                    }
                }

                if (!dominant) return resolve(null);
                // Derive hex directly from the numeric key
                const hex = '#' + Number(dominant).toString(16).padStart(6, '0');
                resolve({ hex });
            };
            img.onerror = () => {
                console.warn('Failed to load image for dominant color:', url);
                resolve(null);
            };
        });
    }

    async function getThemeColor() {
        let methodUsed = 'default #121212';

        const manifestLink = document.querySelector('link[rel="manifest"]');

        // 1. Fetch manifest once and try icon, then theme_color
        if (manifestLink) {
            try {
                const res = await fetch(manifestLink.href);
                const manifest = await res.json();

                // 1a. Manifest icon — find the largest icon using reduce
                if (manifest.icons?.length) {
                    const largest = manifest.icons.reduce((best, icon) => {
                        const size = icon.sizes ? Math.max(...icon.sizes.split('x').map(Number)) : 0;
                        const bestSize = best.sizes ? Math.max(...best.sizes.split('x').map(Number)) : 0;
                        return size > bestSize ? icon : best;
                    }, manifest.icons[0]);

                    if (largest.src) {
                        try {
                            const iconUrl = new URL(largest.src, manifestLink.href).href;
                            const color = await getImageDominantColor(iconUrl);
                            if (color) {
                                return { color: color.hex, iconUrl, methodUsed: 'manifest.icon' };
                            }
                        } catch(e) {}
                    }
                }

                // 1b. Manifest theme_color (same fetch, no second request)
                if (manifest.theme_color) {
                    return { color: manifest.theme_color, iconUrl: null, methodUsed: 'manifest.theme_color' };
                }
            } catch {}
        }

        // 2. meta theme-color
        const meta = document.querySelector('meta[name="theme-color"]');
        if (meta?.content) {
            return { color: meta.content, iconUrl: null, methodUsed: 'meta theme-color' };
        }

        // 3. favicon
        const link = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
        if (link?.href) {
            const color = await getImageDominantColor(link.href);
            if (color) {
                return { color: color.hex, iconUrl: link.href, methodUsed: 'favicon' };
            }
        }

        // 4. default
        return { color: '#121212', iconUrl: null, methodUsed };
    }

    const { color, iconUrl, methodUsed } = await getThemeColor();

    if (!document.querySelector('meta[name="theme-color"][data-dynamic]')) {
        const metaTag = document.createElement('meta');
        metaTag.name = 'theme-color';
        metaTag.setAttribute('data-dynamic', 'true');
        metaTag.content = color;
        document.head.prepend(metaTag);
    }

    console.log('PWA theme color applied:', color, 'via', methodUsed);

    if (DEBUG) {
        const overlay = document.createElement('div');
        overlay.style = `
            position:fixed;
            top:10px; left:10px;
            z-index:99999;
            padding:4px;
            border:2px solid black;
            background:white;
            font-family:sans-serif;
            font-size:12px;
            display:flex;
            align-items:center;
            gap:4px;
        `;

        if (iconUrl) {
            const img = document.createElement('img');
            img.src = iconUrl;
            img.width = 24;
            img.height = 24;
            img.style = 'border:1px solid black';
            overlay.appendChild(img);
        }

        const info = document.createElement('div');
        info.textContent = `[${color}] Method: ${methodUsed}`;
        overlay.appendChild(info);

        document.body.appendChild(overlay);
    }
})();