507 lines
27 KiB
JavaScript
507 lines
27 KiB
JavaScript
/* ============ 媒体应用:照片 / 音乐 / QuickTime / 预览 / Safari ============ */
|
||
'use strict';
|
||
|
||
/* ==================== 照片 ==================== */
|
||
Apps.register({
|
||
id: 'photos', name: '照片', icon: 'assets/icons/photos.png',
|
||
w: 820, h: 560, minW: 480, minH: 320,
|
||
menus() { return stdMenus(this, { view: [] }); },
|
||
photos() {
|
||
return WALLPAPERS.map(w => ({ src: w.src, name: w.name, date: '2026年7月' }));
|
||
},
|
||
render(win) {
|
||
const st = win.appState = { idx: 0 };
|
||
win.body.classList.add('photos-body');
|
||
const side = el('div', { class: 'photos-side' },
|
||
el('div', { class: 'fb-side-title', text: '图库' }),
|
||
el('div', { class: 'fb-side-item sel' }, el('span', { class: 'fb-side-ico', text: '🖼' }), el('span', { text: '所有照片' })),
|
||
el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '⭐' }), el('span', { text: '个人收藏' })),
|
||
el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '🕘' }), el('span', { text: '最近项目' })));
|
||
const grid = el('div', { class: 'photos-grid' });
|
||
win.body.append(side, grid);
|
||
const items = this.photos();
|
||
for (let i = 0; i < items.length; i++) {
|
||
const p = items[i];
|
||
const cell = el('div', { class: 'photo-cell', tabindex: '0' });
|
||
const im = iconImg(p.src, '', p.name);
|
||
im.addEventListener('error', () => { cell.classList.add('photo-missing'); cell.innerHTML = ''; cell.append(el('div', { class: 'photo-fallback', text: '🌄' })); }, { once: true });
|
||
cell.append(im, el('div', { class: 'photo-name', text: p.name }));
|
||
cell.addEventListener('click', () => openLightbox(i));
|
||
grid.append(cell);
|
||
}
|
||
const openLightbox = i => {
|
||
st.idx = i;
|
||
const lb = el('div', { class: 'lightbox', tabindex: '0' });
|
||
const counter = el('div', { class: 'lb-counter' });
|
||
const im = el('img', { class: 'lb-img', alt: '' });
|
||
const close = () => lb.remove();
|
||
const show = () => {
|
||
const p = items[st.idx];
|
||
im.src = p.src; im.onerror = () => { im.remove(); lb.append(el('div', { class: 'lb-fallback', text: '图片不可用' })); };
|
||
counter.textContent = `${p.name} — ${st.idx + 1} / ${items.length}`;
|
||
};
|
||
const nav = d => { st.idx = (st.idx + d + items.length) % items.length; show(); };
|
||
lb.append(
|
||
el('button', { class: 'lb-btn lb-close', html: '✕', title: '关闭', onclick: close }),
|
||
el('button', { class: 'lb-btn lb-prev', html: '‹', title: '上一张', onclick: e => { e.stopPropagation(); nav(-1); } }),
|
||
im, counter,
|
||
el('button', { class: 'lb-btn lb-next', html: '›', title: '下一张', onclick: e => { e.stopPropagation(); nav(1); } }));
|
||
lb.addEventListener('click', e => { if (e.target === lb) close(); });
|
||
lb.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') close();
|
||
if (e.key === 'ArrowLeft') nav(-1);
|
||
if (e.key === 'ArrowRight') nav(1);
|
||
});
|
||
win.body.append(lb);
|
||
show(); lb.focus();
|
||
};
|
||
}
|
||
});
|
||
|
||
/* ==================== 音乐 ==================== */
|
||
const MUSIC_TRACKS = [
|
||
{ file: 'Monkeys Spinning Monkeys', title: 'Monkeys Spinning Monkeys', album: 'Hidden Agenda', dur: 22 },
|
||
{ file: 'Fluffing a Duck', title: 'Fluffing a Duck', album: 'Curious', dur: 32 },
|
||
{ file: 'Carefree', title: 'Carefree', album: 'Life of Riley', dur: 59 },
|
||
{ file: 'Daily Beetle', title: 'Daily Beetle', album: 'Daily Beetle', dur: 38 },
|
||
{ file: 'Sneaky Snitch', title: 'Sneaky Snitch', album: 'Mysterioso', dur: 25 },
|
||
{ file: 'Wallpaper', title: 'Wallpaper', album: 'Wallpaper', dur: 28 },
|
||
];
|
||
Apps.register({
|
||
id: 'music', name: '音乐', icon: 'assets/icons/music.png',
|
||
w: 780, h: 520, minW: 520, minH: 360,
|
||
menus(win) {
|
||
const st = win?.appState;
|
||
return stdMenus(this, {
|
||
file: [{ label: '播放', key: '空格', action: () => st?.toggle() }],
|
||
});
|
||
},
|
||
render(win) {
|
||
const st = win.appState = { idx: -1, playing: false, failed: false };
|
||
win.body.classList.add('music-body');
|
||
const side = el('div', { class: 'photos-side music-side' },
|
||
el('div', { class: 'fb-side-title', text: '资料库' }),
|
||
el('div', { class: 'fb-side-item sel' }, el('span', { class: 'fb-side-ico', text: '🎵' }), el('span', { text: '歌曲' })),
|
||
el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '💿' }), el('span', { text: '专辑' })),
|
||
el('div', { class: 'fb-side-item' }, el('span', { class: 'fb-side-ico', text: '🎤' }), el('span', { text: '艺人' })),
|
||
el('div', { class: 'music-credit', text: '音乐:Kevin MacLeod\nincompetech.com\nCC-BY 4.0' }));
|
||
const listEl = el('div', { class: 'music-list' });
|
||
const main = el('div', { class: 'music-main' }, listEl);
|
||
const artEl = el('div', { class: 'music-art' }, iconImg(this.icon, '', ''));
|
||
const titleEl = el('div', { class: 'music-now-title', text: '未在播放' });
|
||
const subEl = el('div', { class: 'music-now-sub', text: 'Kevin MacLeod' });
|
||
const prevBtn = el('button', { class: 'music-ctl', html: '⏮', title: '上一首' });
|
||
const playBtn = el('button', { class: 'music-ctl play', html: '▶', title: '播放/暂停' });
|
||
const nextBtn = el('button', { class: 'music-ctl', html: '⏭', title: '下一首' });
|
||
const curT = el('span', { class: 'music-time', text: '0:00' });
|
||
const durT = el('span', { class: 'music-time', text: '--:--' });
|
||
const seek = el('input', { type: 'range', class: 'slider music-seek', min: '0', max: '100', value: '0' });
|
||
const bar = el('div', { class: 'music-bar' },
|
||
el('div', { class: 'music-now' }, artEl, el('div', null, titleEl, subEl)),
|
||
el('div', { class: 'music-center' },
|
||
el('div', { class: 'music-ctls' }, prevBtn, playBtn, nextBtn),
|
||
el('div', { class: 'music-seek-row' }, curT, seek, durT)),
|
||
el('div', { class: 'music-vol' }, (() => {
|
||
// 统一音量模型:App 内滑块即全局音量控制器(与控制中心一致),并随全局变化同步
|
||
const v = el('input', { type: 'range', class: 'slider', min: '0', max: '100', value: String(Math.round(Sys.settings.volume * 100)), title: '音量(全局)' });
|
||
v.style.width = '76px';
|
||
v.style.setProperty('--fill', (Sys.settings.volume * 100) + '%');
|
||
v.addEventListener('input', () => {
|
||
Sys.settings.volume = v.value / 100; Sys.settings.muted = v.value == 0;
|
||
v.style.setProperty('--fill', v.value + '%');
|
||
Sys.applyVolume(); Sys.save();
|
||
});
|
||
win._volUnsub = Bus.on('volume:changed', gv => { v.value = String(Math.round(gv * 100)); v.style.setProperty('--fill', (gv * 100) + '%'); });
|
||
return v;
|
||
})()));
|
||
win.body.append(main, bar);
|
||
const audio = new Audio();
|
||
Sys.registerMedia(audio);
|
||
audio.preload = 'metadata';
|
||
win.onClose = () => { audio.pause(); audio.src = ''; Sys.unregisterMedia(audio); win._volUnsub && win._volUnsub(); };
|
||
|
||
const renderList = () => {
|
||
listEl.innerHTML = '';
|
||
const head = el('div', { class: 'music-row music-head' },
|
||
el('span', { text: '' }), el('span', { text: '标题' }), el('span', { text: '专辑' }), el('span', { text: '时长' }));
|
||
listEl.append(head);
|
||
MUSIC_TRACKS.forEach((t, i) => {
|
||
const row = el('div', { class: 'music-row' + (i === st.idx ? ' sel' : '') },
|
||
el('span', { class: 'music-note', text: i === st.idx && st.playing ? '♪' : String(i + 1) }),
|
||
el('span', { class: 'music-t' }, el('b', { text: t.title }), el('small', { text: 'Kevin MacLeod' })),
|
||
el('span', { class: 'music-album', text: t.album }),
|
||
el('span', { class: 'music-dur', text: fmtDur(t.dur) }));
|
||
row.addEventListener('click', () => playAt(i));
|
||
listEl.append(row);
|
||
});
|
||
};
|
||
const setNow = () => {
|
||
const t = MUSIC_TRACKS[st.idx];
|
||
titleEl.textContent = t ? t.title : '未在播放';
|
||
subEl.textContent = t ? `Kevin MacLeod — ${t.album}` : 'Kevin MacLeod';
|
||
artEl.classList.toggle('spin', st.playing);
|
||
};
|
||
const playAt = i => {
|
||
st.idx = i; st.failed = false;
|
||
const t = MUSIC_TRACKS[i];
|
||
audio.src = `assets/audio/${encodeURIComponent(t.file)}.mp3`;
|
||
audio.play().then(() => { st.playing = true; syncUI(); }).catch(() => { st.playing = false; st.failed = true; syncUI(); });
|
||
renderList(); setNow();
|
||
};
|
||
st.toggle = () => {
|
||
if (st.idx < 0) return playAt(0);
|
||
if (audio.paused) audio.play().then(() => { st.playing = true; syncUI(); }).catch(() => { st.failed = true; syncUI(); });
|
||
else { audio.pause(); st.playing = false; syncUI(); }
|
||
};
|
||
const syncUI = () => {
|
||
playBtn.innerHTML = st.playing ? '⏸' : '▶';
|
||
if (st.failed) { titleEl.textContent = '无法播放'; subEl.textContent = '音频文件缺失或加载失败'; }
|
||
renderList(); setNow();
|
||
};
|
||
audio.addEventListener('timeupdate', () => {
|
||
if (!audio.duration) return;
|
||
curT.textContent = fmtDur(audio.currentTime);
|
||
durT.textContent = fmtDur(audio.duration);
|
||
seek.value = String((audio.currentTime / audio.duration) * 100);
|
||
seek.style.setProperty('--fill', seek.value + '%');
|
||
});
|
||
audio.addEventListener('ended', () => playAt((st.idx + 1) % MUSIC_TRACKS.length));
|
||
audio.addEventListener('error', () => { st.playing = false; st.failed = true; syncUI(); });
|
||
seek.addEventListener('input', () => {
|
||
if (audio.duration) { audio.currentTime = (seek.value / 100) * audio.duration; seek.style.setProperty('--fill', seek.value + '%'); }
|
||
});
|
||
prevBtn.addEventListener('click', () => playAt((st.idx - 1 + MUSIC_TRACKS.length) % MUSIC_TRACKS.length));
|
||
nextBtn.addEventListener('click', () => playAt((st.idx + 1) % MUSIC_TRACKS.length));
|
||
playBtn.addEventListener('click', () => st.toggle());
|
||
win.el.addEventListener('keydown', e => { if (e.key === ' ' && !e.target.matches('input,textarea')) { e.preventDefault(); st.toggle(); } });
|
||
renderList();
|
||
}
|
||
});
|
||
|
||
/* ==================== QuickTime Player ==================== */
|
||
const QT_VIDEOS = [
|
||
{ file: 'flower.mp4', title: '花朵', src: 'CC0 / MDN' },
|
||
{ file: 'friday.mp4', title: '星期五', src: 'CC0 / MDN' },
|
||
{ file: 'big-buck-bunny.mp4', title: '大雄兔', src: 'CC-BY / Blender' },
|
||
];
|
||
Apps.register({
|
||
id: 'quicktime', name: 'QuickTime Player', icon: 'assets/icons/quicktime.svg',
|
||
w: 720, h: 500, minW: 420, minH: 300,
|
||
menus(win) {
|
||
const st = win?.appState;
|
||
return stdMenus(this, {
|
||
file: [
|
||
{ label: '播放/暂停', key: '空格', action: () => st?.toggle() },
|
||
{ label: '下一个视频', action: () => st?.nav(1) },
|
||
{ label: '上一个视频', action: () => st?.nav(-1) },
|
||
]
|
||
});
|
||
},
|
||
render(win) {
|
||
const st = win.appState = { idx: 0 };
|
||
win.body.classList.add('qt-body');
|
||
const video = el('video', { class: 'qt-video', playsinline: '' });
|
||
const errBox = el('div', { class: 'qt-error hidden' },
|
||
el('div', { class: 'es-icon', text: '🎬' }),
|
||
el('div', { text: '无法播放此视频' }),
|
||
el('div', { class: 'qt-error-sub', text: '文件缺失或格式不受支持' }));
|
||
const stage = el('div', { class: 'qt-stage' }, video, errBox);
|
||
const playBtn = el('button', { class: 'qt-btn', html: '▶', title: '播放/暂停' });
|
||
const prevBtn = el('button', { class: 'qt-btn', html: '⏮', title: '上一个' });
|
||
const nextBtn = el('button', { class: 'qt-btn', html: '⏭', title: '下一个' });
|
||
const curT = el('span', { class: 'music-time', text: '0:00' });
|
||
const durT = el('span', { class: 'music-time', text: '--:--' });
|
||
const seek = el('input', { type: 'range', class: 'slider qt-seek', min: '0', max: '100', value: '0' });
|
||
const vol = el('input', { type: 'range', class: 'slider qt-vol', min: '0', max: '100', value: String(Math.round(Sys.settings.volume * 100)), title: '音量' });
|
||
const fsBtn = el('button', { class: 'qt-btn', html: '⛶', title: '全屏' });
|
||
const nameEl = el('div', { class: 'qt-name' });
|
||
const controls = el('div', { class: 'qt-controls' },
|
||
playBtn, prevBtn, nextBtn, curT, seek, durT,
|
||
(() => { const i = el('span', { class: 'qt-vol-ico', text: '🔊' }); return i; })(), vol, fsBtn);
|
||
win.body.append(stage, controls);
|
||
Sys.registerMedia(video);
|
||
win.onClose = () => { video.pause(); video.src = ''; Sys.unregisterMedia(video); win._volUnsub && win._volUnsub(); };
|
||
|
||
const load = () => {
|
||
const v = QT_VIDEOS[st.idx];
|
||
WM.setTitle(win, `${v.title} — QuickTime Player`);
|
||
nameEl.textContent = '';
|
||
errBox.classList.add('hidden');
|
||
video.classList.remove('hidden');
|
||
video.innerHTML = `<source src="assets/video/${v.file}" type="video/mp4"><source src="assets/video/${v.file.replace('.mp4', '.webm')}" type="video/webm">`;
|
||
video.load();
|
||
video.play().catch(() => syncPlay());
|
||
};
|
||
const syncPlay = () => { playBtn.innerHTML = video.paused ? '▶' : '⏸'; };
|
||
st.toggle = () => { video.paused ? video.play().catch(() => {}) : video.pause(); };
|
||
st.nav = d => { st.idx = (st.idx + d + QT_VIDEOS.length) % QT_VIDEOS.length; load(); };
|
||
video.addEventListener('play', syncPlay);
|
||
video.addEventListener('pause', syncPlay);
|
||
video.addEventListener('timeupdate', () => {
|
||
if (!video.duration) return;
|
||
curT.textContent = fmtDur(video.currentTime);
|
||
durT.textContent = fmtDur(video.duration);
|
||
seek.value = String((video.currentTime / video.duration) * 100);
|
||
seek.style.setProperty('--fill', seek.value + '%');
|
||
});
|
||
video.addEventListener('ended', () => st.nav(1));
|
||
video.addEventListener('error', () => {
|
||
video.classList.add('hidden');
|
||
errBox.classList.remove('hidden');
|
||
});
|
||
stage.addEventListener('click', () => st.toggle());
|
||
seek.addEventListener('input', () => { if (video.duration) video.currentTime = (seek.value / 100) * video.duration; });
|
||
vol.addEventListener('input', () => {
|
||
// 统一音量模型:直写全局设置,applyVolume 会作用于全部已注册媒体
|
||
Sys.settings.volume = vol.value / 100; Sys.settings.muted = vol.value == 0;
|
||
vol.style.setProperty('--fill', vol.value + '%');
|
||
Sys.applyVolume(); Sys.save();
|
||
});
|
||
win._volUnsub = Bus.on('volume:changed', gv => { vol.value = String(Math.round(gv * 100)); vol.style.setProperty('--fill', (gv * 100) + '%'); });
|
||
playBtn.addEventListener('click', () => st.toggle());
|
||
prevBtn.addEventListener('click', () => st.nav(-1));
|
||
nextBtn.addEventListener('click', () => st.nav(1));
|
||
fsBtn.addEventListener('click', () => { stage.requestFullscreen ? stage.requestFullscreen().catch(() => {}) : null; });
|
||
win.el.addEventListener('keydown', e => { if (e.key === ' ' && !e.target.matches('input')) { e.preventDefault(); st.toggle(); } });
|
||
load();
|
||
}
|
||
});
|
||
|
||
/* ==================== 预览 ==================== */
|
||
Apps.register({
|
||
id: 'preview', name: '预览', icon: 'assets/icons/preview.svg',
|
||
w: 720, h: 540, minW: 380, minH: 280, singleton: false,
|
||
menus(win) {
|
||
const st = win?.appState;
|
||
return stdMenus(this, {
|
||
view: [
|
||
{ label: '放大', key: '⌘+', action: () => st?.zoom(0.2) },
|
||
{ label: '缩小', key: '⌘−', action: () => st?.zoom(-0.2) },
|
||
{ label: '实际大小', key: '⌘0', action: () => st?.zoomReset() },
|
||
]
|
||
});
|
||
},
|
||
render(win, args) {
|
||
const st = win.appState = { scale: 1, rot: 0, path: args.path || null };
|
||
win.body.classList.add('preview-body');
|
||
const zOut = el('button', { class: 'fb-btn', text: '−', title: '缩小' });
|
||
const zIn = el('button', { class: 'fb-btn', text: '+', title: '放大' });
|
||
const rotBtn = el('button', { class: 'fb-btn', html: '⟳', title: '旋转' });
|
||
const infoEl = el('span', { class: 'preview-info' });
|
||
const toolbar = el('div', { class: 'fb-toolbar preview-toolbar' }, zOut, zIn, rotBtn, infoEl);
|
||
const stage = el('div', { class: 'preview-stage' });
|
||
win.body.append(toolbar, stage);
|
||
st.zoom = d => { st.scale = clamp(st.scale + d, 0.2, 4); apply(); };
|
||
st.zoomReset = () => { st.scale = 1; st.rot = 0; apply(); };
|
||
zIn.addEventListener('click', () => st.zoom(0.2));
|
||
zOut.addEventListener('click', () => st.zoom(-0.2));
|
||
rotBtn.addEventListener('click', () => { st.rot = (st.rot + 90) % 360; apply(); });
|
||
let contentEl = null;
|
||
const apply = () => { if (contentEl) contentEl.style.transform = `scale(${st.scale}) rotate(${st.rot}deg)`; infoEl.textContent = `缩放 ${Math.round(st.scale * 100)}%` + (st.isPdf ? ' · 第 1 页,共 1 页' : ''); };
|
||
const showError = msg => {
|
||
stage.innerHTML = '';
|
||
stage.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '🚫' }), el('div', { text: '无法打开此文件' }), el('div', { class: 'preview-err-sub', text: msg })));
|
||
infoEl.textContent = '';
|
||
};
|
||
// args.asset 优先于「无 path 显示画廊」:壁纸缩略图点击后必须打开实际大图
|
||
if (args.asset) st.path = args.asset;
|
||
if (!st.path) {
|
||
// 从 Dock 打开:显示内置图片集
|
||
const gallery = el('div', { class: 'preview-gallery' });
|
||
WALLPAPERS.forEach(w => {
|
||
const im = iconImg(w.src, '', w.name);
|
||
const cell = el('div', { class: 'preview-cell' }, im, el('div', { class: 'photo-name', text: w.name }));
|
||
cell.addEventListener('click', () => { Apps.open('preview', { asset: w.src, name: w.name }); });
|
||
gallery.append(cell);
|
||
});
|
||
stage.append(gallery);
|
||
infoEl.textContent = '点击查看大图';
|
||
WM.setTitle(win, '图片集 — 预览');
|
||
return;
|
||
}
|
||
const asset = args.asset || null; // asset 已与 st.path 归一
|
||
const ext = (st.path.split('.').pop() || '').toLowerCase();
|
||
if (asset || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
|
||
const src = asset || st.path;
|
||
contentEl = el('img', { class: 'preview-img', alt: args.name || FS.baseName(src) });
|
||
contentEl.addEventListener('error', () => showError('图片数据不可用(虚拟文件或资源缺失)。'));
|
||
contentEl.src = src;
|
||
stage.append(el('div', { class: 'preview-canvas' }, contentEl));
|
||
WM.setTitle(win, (args.name || FS.baseName(src)) + ' — 预览');
|
||
apply();
|
||
} else if (ext === 'pdf') {
|
||
contentEl = el('div', { class: 'preview-pdf' },
|
||
el('div', { class: 'preview-pdf-badge', text: 'PDF' }),
|
||
el('div', { class: 'preview-pdf-name', text: FS.baseName(st.path) }),
|
||
el('div', { class: 'preview-pdf-sub', text: '虚拟 PDF 文档 · 第 1 页,共 1 页' }));
|
||
st.isPdf = true;
|
||
stage.append(el('div', { class: 'preview-canvas' }, contentEl));
|
||
apply();
|
||
} else {
|
||
// 文本类
|
||
try {
|
||
const txt = FS.read(st.path);
|
||
contentEl = el('pre', { class: 'preview-text', text: txt });
|
||
stage.append(el('div', { class: 'preview-canvas' }, contentEl));
|
||
apply();
|
||
} catch (e) { showError(e.message); }
|
||
}
|
||
}
|
||
});
|
||
|
||
/* ==================== Safari ==================== */
|
||
Apps.register({
|
||
id: 'safari', name: 'Safari 浏览器', icon: 'assets/icons/safari.png',
|
||
w: 900, h: 600, minW: 560, minH: 380,
|
||
menus(win) {
|
||
const st = win?.appState;
|
||
return stdMenus(this, {
|
||
file: [
|
||
{ label: '新建窗口', key: '⌘N', action: () => Apps.open('safari') },
|
||
{ label: '重新载入页面', key: '⌘R', action: () => st?.reload() },
|
||
],
|
||
view: [
|
||
{ label: '后退', key: '⌘[', disabled: !st?.canBack(), action: () => st?.back() },
|
||
{ label: '前进', key: '⌘]', disabled: !st?.canFwd(), action: () => st?.fwd() },
|
||
]
|
||
});
|
||
},
|
||
builtinPages: {
|
||
'macos://weather': { title: '天气 - 内置页', build: box => WeatherApp.renderInline(box) },
|
||
'macos://news': {
|
||
title: '科技新闻 - 内置页',
|
||
build: box => {
|
||
box.append(el('h2', { text: '科技要闻' }));
|
||
[
|
||
['macOS 网页版发布', '完全在浏览器中运行的桌面体验,支持虚拟文件系统与窗口管理。'],
|
||
['本地优先的软件设计兴起', '越来越多的应用选择将数据保存在本地,兼顾隐私与离线可用性。'],
|
||
['Web 平台能力持续增强', '通知、文件系统访问与硬件加速让网页应用接近原生体验。'],
|
||
['开源社区年度盘点', '来自全球的开发者为桌面模拟器项目贡献了图标、壁纸与音视频素材。'],
|
||
].forEach(([t, d]) => box.append(el('div', { class: 'sf-news-item' }, el('b', { text: t }), el('p', { text: d }))));
|
||
}
|
||
},
|
||
'macos://baike': {
|
||
title: '百科 - 内置页',
|
||
build: box => {
|
||
box.append(el('h2', { text: 'macOS' }));
|
||
box.append(el('p', { text: 'macOS 是苹果公司为 Mac 系列电脑开发的操作系统,以图形用户界面、UNIX 内核与深度软硬件整合著称。本页面由 Safari 内置百科提供,离线可读。' }));
|
||
box.append(el('h3', { text: '版本沿革' }));
|
||
box.append(el('p', { text: 'Cheetah、Puma、Jaguar、Panther、Tiger、Leopard、Snow Leopard、Lion、Mountain Lion、Mavericks、Yosemite、El Capitan、Sierra、High Sierra、Mojave、Catalina、Big Sur、Monterey、Ventura、Sonoma、Sequoia。' }));
|
||
box.append(el('h3', { text: '特色功能' }));
|
||
box.append(el('p', { text: '访达、程序坞、聚焦搜索、控制中心、通知中心、启动台、调度中心等。' }));
|
||
}
|
||
},
|
||
'macos://github': {
|
||
title: '代码仓库 - 内置页',
|
||
build: box => {
|
||
box.append(el('h2', { text: 'macos-web' }));
|
||
box.append(el('p', { text: '一个纯静态的 macOS 桌面模拟器。' }));
|
||
box.append(el('pre', { class: 'sf-code', text: ['macos-web/', '├── index.html', '├── css/ # 样式', '├── js/ # 系统与应用', '└── assets/ # 图标 / 壁纸 / 音视频'].join('\n') }));
|
||
box.append(el('p', { text: '许可:MIT。素材来源:Kevin MacLeod (CC-BY)、Blender 基金会 (CC-BY)、MDN (CC0)。' }));
|
||
}
|
||
},
|
||
},
|
||
render(win, args) {
|
||
const st = win.appState = { hist: [], hi: -1, loading: false };
|
||
const favs = Store.get('safari-favs', [
|
||
{ name: '天气', url: 'macos://weather', ico: '🌤' },
|
||
{ name: '科技新闻', url: 'macos://news', ico: '📰' },
|
||
{ name: '百科', url: 'macos://baike', ico: '📚' },
|
||
{ name: '代码仓库', url: 'macos://github', ico: '💻' },
|
||
]);
|
||
win.body.classList.add('safari-body');
|
||
const backBtn = el('button', { class: 'fb-btn', html: '‹', title: '后退' });
|
||
const fwdBtn = el('button', { class: 'fb-btn', html: '›', title: '前进' });
|
||
const reloadBtn = el('button', { class: 'fb-btn', html: '⟳', title: '重新载入' });
|
||
const addr = el('input', { class: 'text-input sf-addr', type: 'text', placeholder: '搜索或输入网站地址', spellcheck: 'false', 'aria-label': '地址栏' });
|
||
const progress = el('div', { class: 'sf-progress' });
|
||
const toolbar = el('div', { class: 'fb-toolbar sf-toolbar' }, backBtn, fwdBtn, reloadBtn,
|
||
el('div', { class: 'sf-addr-wrap' }, addr, progress));
|
||
const page = el('div', { class: 'sf-page' });
|
||
win.body.append(toolbar, page);
|
||
st.canBack = () => st.hi > 0;
|
||
st.canFwd = () => st.hi < st.hist.length - 1;
|
||
const syncNav = () => { backBtn.disabled = !st.canBack(); fwdBtn.disabled = !st.canFwd(); };
|
||
const setLoading = on => {
|
||
st.loading = on;
|
||
progress.style.width = on ? '70%' : '100%';
|
||
progress.style.opacity = on ? '1' : '0';
|
||
if (on) setTimeout(() => { if (st.loading) { progress.style.transition = 'width 2s ease'; progress.style.width = '92%'; } }, 60);
|
||
else { progress.style.transition = 'width .2s ease'; setTimeout(() => progress.style.width = '0', 240); }
|
||
};
|
||
const showStart = () => {
|
||
page.innerHTML = '';
|
||
WM.setTitle(win, '起始页 — Safari 浏览器');
|
||
addr.value = '';
|
||
const wrap = el('div', { class: 'sf-start' }, el('h2', { text: '个人收藏' }));
|
||
const grid = el('div', { class: 'sf-fav-grid' });
|
||
favs.forEach(f => {
|
||
const c = el('div', { class: 'sf-fav' },
|
||
el('div', { class: 'sf-fav-ico', text: f.ico }),
|
||
el('div', { class: 'sf-fav-name', text: f.name }));
|
||
c.addEventListener('click', () => navigate(f.url));
|
||
grid.append(c);
|
||
});
|
||
wrap.append(grid, el('p', { class: 'sf-tip', text: '在地址栏输入 macos://weather 等内置地址,或输入网址查看离线回退页。' }));
|
||
page.append(wrap);
|
||
};
|
||
const showOffline = url => {
|
||
page.innerHTML = '';
|
||
WM.setTitle(win, '无法连接 — Safari 浏览器');
|
||
const engine = Sys.settings.searchEngine === 'baidu' ? '百度' : 'Bing';
|
||
page.append(el('div', { class: 'sf-offline' },
|
||
el('div', { class: 'sf-offline-globe', text: '🌐' }),
|
||
el('h3', { text: 'Safari 浏览器无法打开页面' }),
|
||
el('p', { text: `你的 Mac 当前处于离线环境,无法连接到“${url.replace(/^https?:\/\//, '').slice(0, 60)}”。` }),
|
||
el('p', { class: 'sf-offline-sub', text: '内置页面(天气、新闻、百科)仍可离线访问;也可以在系统浏览器中新标签页打开该地址。' }),
|
||
el('div', { class: 'sf-offline-btns' },
|
||
el('button', { class: 'btn primary', text: '在新标签页打开', onclick: () => window.open(url, '_blank', 'noopener') }),
|
||
el('button', { class: 'btn', text: '返回起始页', onclick: () => navigate('macos://start') })),
|
||
el('p', { class: 'sf-offline-sub', text: `当前搜索引擎:${engine}(可在 系统设置 › Safari 中修改)` })));
|
||
};
|
||
const navigate = (url, push = true) => {
|
||
url = (url || '').trim();
|
||
if (!url) return showStart();
|
||
setLoading(true);
|
||
setTimeout(() => {
|
||
setLoading(false);
|
||
if (push) { st.hist = st.hist.slice(0, st.hi + 1); st.hist.push(url); st.hi++; }
|
||
syncNav();
|
||
if (url === 'macos://start') return showStart();
|
||
const bp = Apps.get('safari').builtinPages[url];
|
||
if (bp) {
|
||
page.innerHTML = '';
|
||
addr.value = url;
|
||
WM.setTitle(win, bp.title);
|
||
const box = el('div', { class: 'sf-builtin' });
|
||
try { bp.build(box); } catch (e) { box.append(el('p', { text: '页面加载失败。' })); }
|
||
page.append(box);
|
||
return;
|
||
}
|
||
if (/^https?:\/\//i.test(url)) { addr.value = url; return showOffline(url); }
|
||
// 视为搜索
|
||
const q = encodeURIComponent(url);
|
||
const su = Sys.settings.searchEngine === 'baidu' ? `https://www.baidu.com/s?wd=${q}` : `https://www.bing.com/search?q=${q}`;
|
||
addr.value = su;
|
||
showOffline(su);
|
||
}, 380);
|
||
};
|
||
st.back = () => { if (st.canBack()) { st.hi--; const u = st.hist[st.hi]; navigate(u, false); } };
|
||
st.fwd = () => { if (st.canFwd()) { st.hi++; const u = st.hist[st.hi]; navigate(u, false); } };
|
||
st.reload = () => { const u = st.hist[st.hi] || 'macos://start'; navigate(u, false); };
|
||
backBtn.addEventListener('click', () => st.back());
|
||
fwdBtn.addEventListener('click', () => st.fwd());
|
||
reloadBtn.addEventListener('click', () => st.reload());
|
||
addr.addEventListener('keydown', e => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Enter') {
|
||
let v = addr.value.trim();
|
||
if (/^[\w-]+(\.[\w-]+)+(\/.*)?$/.test(v) && !v.includes(' ')) v = 'https://' + v;
|
||
navigate(v);
|
||
}
|
||
});
|
||
navigate(args.url || 'macos://start', true);
|
||
}
|
||
});
|
||
window.MUSIC_TRACKS = MUSIC_TRACKS; window.QT_VIDEOS = QT_VIDEOS;
|