一、项目介绍
1.1 📌 Moments 项目简介
/
Moments 是一款极简朋友圈应用,采用 Golang 重写服务端后包体更小、性能更强。支持多用户、标签管理、图片上传(本地/S3)、Markdown 语法,可嵌入网易云音乐、B 站视频及豆瓣内容,适配移动端与暗黑模式,数据存储在 SQLite 中便于备份。它为用户精心打造了一个网页端简洁却功能丰富的社交展示平台,支持多种内容形式的记录与展示。
演示地址:https://pyq.zxiaolin.com/1.2 🎯 功能亮点
📝 Memo 记录:这里就像一个多功能的笔记本,支持标签管理,让你的内容条理清晰;支持图片上传,可存储至本地或 S3,满足不同的存储需求;自动生成缩略图(本地上传),为你节省时间;支持 Markdown 语法,让你可以用简洁的方式表达丰富的内容;点赞与评论功能,让你的分享与他人产生互动;还能嵌入网易云音乐、B站视频、外部链接,引用豆瓣读书和电影信息,让你的分享更加丰富多彩。
🛠️ 其他功能:它就像一个贴心的伙伴,完美适配移动端,无论你是在路上还是在休息,都能随时随地使用;支持暗黑模式,为你在夜晚使用提供舒适的视觉体验;提供回到顶部按钮,让你在浏览长页面时更加便捷;数据库采用 SQLite,便于备份,让你的数据更加安全;支持自定义头图、头像和网站标题,让你的朋友圈独具个性。
二、页面展示增强
2.1 💅 CSS 优化
通过 display: none 隐藏原页面中不需要的链接和 UI 元素,让视觉焦点集中在内容本身。
a[href="<https://github.com/kingwrcy/moments>"] {
display: none;
}
div.sm\\:absolute.sm\\:flex-col.sm\\:-right-10.sm\\:rounded.sm\\:p-2.sm\\:w-fit.dark\\:bg-neutral-800.bg-white.shadow {
display: none !important;
}
2.2 ✨ 灵动岛歌词容器及动画优化
灵动岛是 iPhone 14 及以上机型的标志性 UI 元素,我们用一个 div 来模拟它。
/* 灵动岛歌词容器 */
/* 歌词容器与滚动动画(优化滚动速度) */
.lyric-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
pointer-events: none;
}
.lyric-text {
color: #ffcc99;
font-size: 12px;
white-space: nowrap;
transform: translateX(100%);
opacity: 0;
font-weight: 500;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
}
.lyric-text.visible {
opacity: 1;
transform: translateX(0);
}
/* 优化1:歌词滚动速度减慢,延长动画周期至25秒 */
.lyric-text.scrolling {
animation: scrollLyric 25s linear infinite;
}
@keyframes scrollLyric {
0% { transform: translateX(100%); }
10% { transform: translateX(0); } /* 10%时间完成进入 */
85% { transform: translateX(0); } /* 75%时间保持静止 */
100% { transform: translateX(-100%); } /* 15%时间完成退出 */
}
/* 优化3:灵动岛炫光动画 */
.dynamic-island {
/* 基础样式保持不变... */
position: relative;
overflow: hidden;
}
.dynamic-island::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 204, 153, 0.2) 0%, transparent 70%);
transform: rotate(0deg);
opacity: 0;
transition: opacity 0.5s ease;
}
.dynamic-island.playing::before {
opacity: 1;
animation: glowRotate 6s linear infinite;
}
@keyframes glowRotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
2.2.1 🎵 歌词容器与滚动动画
📦 容器布局:.lyric-container 采用绝对定位,如同一个隐形的框架,覆盖整个父元素,通过 flex 布局将歌词文本垂直和水平居中。overflow: hidden 确保超出容器的歌词部分不会显示,pointer-events: none 使歌词容器不会干扰用户与其他元素的交互,就像一个安静的守护者,默默守护着歌词的展示。
📝 歌词文本样式:.lyric-text 初始状态下透明度为 0 且位于容器右侧,当添加 visible 类时,通过过渡效果使其透明度变为 1 并移动到容器中心,就像一颗星星从黑暗中逐渐闪耀出来。
🎬 滚动动画优化:通过 animation: scrollLyric 25s linear infinite 将歌词滚动动画周期延长至 25 秒,在 @keyframes scrollLyric 中详细定义了歌词的滚动过程,使歌词在进入、静止和退出阶段有更合理的时间分配,就像一场精心编排的舞蹈,提升用户的视觉体验。
2.2.2 🌟 灵动岛炫光动画
✨ 伪元素创建:通过 ::before 伪元素在 .dynamic-island 元素内部创建一个径向渐变的背景,初始状态下透明度为 0,就像一颗隐藏的宝石,等待被点亮。
🎬 动画触发:当 .dynamic-island 元素添加 playing 类时,伪元素的透明度变为 1,并开始执行 glowRotate 动画,使炫光以 6 秒为周期旋转,增强了灵动岛的视觉效果,就像给灵动岛披上了一层绚丽的光环。
三、iPhone 16 Pro 模拟完整实现
3.1 🤔 模拟思路概述
在页面中模拟 iPhone 16 Pro 的效果,就像一场奇妙的魔法之旅。我们主要是通过创建一个具有 iPhone 外观的容器,并将原页面内容嵌入其中,同时添加灵动岛元素来模拟 iPhone 的交互效果。在模拟过程中,需要像一位严谨的建筑师一样,考虑屏幕尺寸的适配、元素的布局和动画效果的实现。
3.2 💻 代码实现步骤
var simulatorWidth;
function initiPhoneSimulator() {
// 路径验证
if (window.location.pathname !== '/') {
console.log('非根路径,不执行模拟');
return;
}
console.log('根路径验证通过,开始初始化');
// 核心参数:新比例395:830,最小宽度395px
const baseWidth = 395;
const baseHeight = 830;
const minWidth = baseWidth; // 强制最小宽度不小于395px
const ratio = baseWidth / baseHeight; // 宽高比≈0.4759
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
// 1. 计算可用空间限制
const maxAvailableWidth = screenWidth * 0.85; // 最大可用宽度为屏幕85%
const maxAvailableHeight = screenHeight * 0.95; // 最大可用高度为屏幕95%
// 2. 优先保证宽度不小于395px,计算最优宽高
let optimalWidth, optimalHeight;
// 基础宽度:取最小宽度和最大可用宽度的较大值(确保不小于395)
const baseCandidateWidth = Math.max(minWidth, maxAvailableWidth);
// 按基础宽度计算对应高度(原始比例)
const heightByWidth = baseCandidateWidth / ratio;
if (heightByWidth <= maxAvailableHeight) {
// 高度在可用范围内:使用基础宽度和对应高度(保持比例)
optimalWidth = baseCandidateWidth;
optimalHeight = heightByWidth;
} else {
// 高度超出范围:限制高度为最大可用高度,宽度按比例计算(可能大于基础宽度)
optimalHeight = maxAvailableHeight;
optimalWidth = optimalHeight * ratio;
// 二次检查:确保宽度不小于最小宽度(允许比例偏差)
if (optimalWidth < minWidth) {
optimalWidth = minWidth;
optimalHeight = maxAvailableHeight; // 此时比例有偏差,优先保证宽度和高度限制
console.log(`宽度已达最小值${minWidth}px,高度限制为${optimalHeight.toFixed(0)}px(比例偏差)`);
}
}
simulatorWidth = optimalWidth;
console.log(`最优尺寸:宽${optimalWidth.toFixed(0)}px,高${optimalHeight.toFixed(0)}px(比例≈${(optimalWidth/optimalHeight).toFixed(3)})`);
// 3. 创建iPhone容器
const iphone = document.createElement('div');
iphone.style.width = `${optimalWidth}px`;
iphone.style.height = `${optimalHeight}px`;
iphone.style.border = '12px solid #1a1a1a';
iphone.style.borderRadius = `${optimalWidth * (56 / baseWidth)}px`; // 按基础宽度比例计算圆角
iphone.style.position = 'relative';
iphone.style.boxShadow = '0 15px 40px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.1)';
iphone.style.background = '#000';
iphone.style.transform = 'scale(0.95)';
iphone.style.transition = 'transform 0.3s ease';
iphone.onmouseover = () => { iphone.style.transform = 'scale(1)'; };
iphone.onmouseout = () => { iphone.style.transform = 'scale(0.95)'; };
console.log('iPhone容器创建完成');
// 4. 创建屏幕容器
const screenBorder = 2;
const screen = document.createElement('div');
screen.style.width = `calc(100% - ${2 * screenBorder}px)`;
screen.style.height = `calc(100% - ${2 * screenBorder}px)`;
screen.style.background = '#fff';
screen.style.borderRadius = `${optimalWidth * (48 / baseWidth)}px`;
screen.style.overflow = 'hidden';
screen.style.position = 'relative';
screen.style.margin = `${screenBorder}px`;
console.log('屏幕容器创建完成');
// 创建灵动岛(新增歌词容器)
const dynamicIsland = document.createElement('div');
dynamicIsland.style.position = 'absolute';
dynamicIsland.style.top = `${optimalWidth * (8 / baseWidth)}px`;
dynamicIsland.style.left = '50%';
dynamicIsland.style.transform = 'translateX(-50%)';
dynamicIsland.style.width = `${optimalWidth * (120 / baseWidth)}px`;
dynamicIsland.style.height = `${optimalWidth * (32 / baseWidth)}px`;
dynamicIsland.style.background = 'linear-gradient(180deg, #1a1a1a 0%, #000 100%)';
dynamicIsland.style.borderRadius = `${optimalWidth * (20 / baseWidth)}px`;
dynamicIsland.style.zIndex = '9999';
dynamicIsland.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.05)';
dynamicIsland.style.transition = 'all 0.3s ease';
// 新增:歌词容器
const lyricContainer = document.createElement('div');
lyricContainer.className = 'lyric-container';
const lyricText = document.createElement('div');
lyricText.className = 'lyric-text';
lyricContainer.appendChild(lyricText);
dynamicIsland.appendChild(lyricContainer);
// 灵动岛交互
dynamicIsland.onclick = () => {
const isExpanded = dynamicIsland.style.width === `${optimalWidth * (160 / baseWidth)}px`;
dynamicIsland.style.width = isExpanded ? `${optimalWidth * (120 / baseWidth)}px` : `${optimalWidth * (160 / baseWidth)}px`;
dynamicIsland.style.height = isExpanded ? `${optimalWidth * (32 / baseWidth)}px` : `${optimalWidth * (38 / baseWidth)}px`;
};
console.log('灵动岛创建完成(支持歌词显示)');
// 6. 创建内容容器
const webContent = document.createElement('div');
webContent.style.position = 'absolute';
webContent.style.top = '0';
webContent.style.left = '0';
webContent.style.width = '100%';
webContent.style.height = '100%';
webContent.style.overflowY = 'auto';
//webContent.style.paddingTop = `${optimalWidth * (40 / baseWidth)}px`; // 避开灵动岛
webContent.style.boxSizing = 'border-box';
console.log('内容容器创建完成');
// 7. 移除固定宽度class
const targetClass = 'md:w-[567px]';
const escapedClass = targetClass.replace(/:/g, '\\\\:').replace(/\\[/g, '\\\\[').replace(/\\]/g, '\\\\]');
const elementsWithClass = document.querySelectorAll(`.${escapedClass}`);
// 处理原项目的最低宽度
elementsWithClass.forEach(el => {
el.classList.remove(targetClass);
});
// 处理特殊样式的宽度
document.querySelectorAll('.h-full').forEach(he => {
he.classList.remove('h-full');
})
// 8. 移动原DOM并适配
const originalContent = document.getElementById('__nuxt');
if (originalContent) {
console.log('找到__nuxt容器,开始移动');
originalContent.style.width = '100%';
originalContent.style.maxWidth = '100%';
originalContent.style.height = '100%';
originalContent.style.boxSizing = 'border-box';
webContent.appendChild(originalContent);
} else {
console.warn('未找到__nuxt容器');
const fallback = document.createElement('div');
fallback.style.padding = '20px';
fallback.style.textAlign = 'center';
fallback.textContent = '内容加载失败';
webContent.appendChild(fallback);
}
// 9. 组装结构
screen.appendChild(dynamicIsland);
screen.appendChild(webContent);
iphone.appendChild(screen);
document.body.appendChild(iphone);
console.log('元素组装完成');
// 10. 页面样式
document.body.style.margin = '0';
document.body.style.padding = '20px';
document.body.style.backgroundColor = '#f0f2f5';
document.body.style.minHeight = '100vh';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.overflow = 'hidden';
// 11. 加载动画
webContent.style.opacity = '0';
setTimeout(() => {
webContent.style.transition = 'opacity 0.5s ease';
webContent.style.opacity = '1';
}, 300);
// 新增:监听meting-js播放事件
setupAdvancedPlayerListener(dynamicIsland, lyricText, optimalWidth, baseWidth);
}
// 高级播放器监听系统(兼容无限滚动)
function setupAdvancedPlayerListener(dynamicIsland, lyricText, optimalWidth, baseWidth) {
const originalWidth = dynamicIsland.style.width;
const originalHeight = dynamicIsland.style.height;
const expandedWidth = `${optimalWidth * (220 / baseWidth)}px`;
const expandedHeight = `${optimalWidth * (38 / baseWidth)}px`;
let activePlayer = null;
let playerObservers = new Map(); // 存储每个播放器的observer
let lyricUpdateTimer = null;
// 处理播放器状态变化
const handlePlayerStateChange = (player) => {
const playButton = player.querySelector('.aplayer-button');
const isPlaying = playButton?.classList.contains('aplayer-pause');
if (isPlaying) {
// 激活当前播放器(不暂停其他)
activePlayer = player;
expandIsland();
startLyricUpdates();
} else if (activePlayer === player) {
// 当前播放器暂停
activePlayer = null;
resetIsland();
stopLyricUpdates();
}
};
// 展开灵动岛
const expandIsland = () => {
dynamicIsland.style.width = expandedWidth;
dynamicIsland.style.height = expandedHeight;
dynamicIsland.classList.add('playing');
lyricText.classList.add('visible');
};
// 重置灵动岛
const resetIsland = () => {
dynamicIsland.style.width = originalWidth;
dynamicIsland.style.height = originalHeight;
dynamicIsland.classList.remove('playing');
lyricText.classList.remove('visible', 'scrolling');
lyricText.textContent = '';
};
// 更新歌词
const updateLyric = () => {
if (!activePlayer) return;
const currentLine = activePlayer.querySelector('.aplayer-lrc-current');
if (currentLine && currentLine.textContent.trim()) {
const lyric = currentLine.textContent;
if (lyric !== lyricText.textContent) {
lyricText.textContent = lyric;
// 重置滚动动画
lyricText.classList.remove('scrolling');
void lyricText.offsetWidth;
lyricText.classList.add('scrolling');
}
}
};
// 开始歌词更新
const startLyricUpdates = () => {
if (!lyricUpdateTimer) {
lyricUpdateTimer = setInterval(updateLyric, 1500);
}
};
// 停止歌词更新
const stopLyricUpdates = () => {
if (lyricUpdateTimer) {
clearInterval(lyricUpdateTimer);
lyricUpdateTimer = null;
}
};
// 初始化单个播放器监听
const initPlayer = (player) => {
// 避免重复初始化
if (playerObservers.has(player)) return;
// 创建状态变化监听器
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
handlePlayerStateChange(player);
}
});
});
// 监听播放器状态变化
observer.observe(player, { attributes: true, attributeFilter: ['class'] });
// 监听播放按钮状态变化
const playButton = player.querySelector('.aplayer-button');
if (playButton) {
observer.observe(playButton, { attributes: true, attributeFilter: ['class'] });
}
// 存储observer引用
playerObservers.set(player, observer);
// 检查初始状态
if (playButton?.classList.contains('aplayer-pause')) {
handlePlayerStateChange(player);
}
if (simulatorWidth && simulatorWidth < 600) {
player.style.width = '95%';
player.style.maxWidth = `${simulatorWidth - 120}px`;
player.style.boxSizing = 'border-box';
console.log(`[播放器宽度适配] ${simulatorWidth - 120}px`)
}
console.log('[播放器监听] 初始化新播放器:', player);
};
// 清理播放器监听
const cleanupPlayer = (player) => {
const observer = playerObservers.get(player);
if (observer) {
observer.disconnect();
playerObservers.delete(player);
}
// 如果清理的是当前活跃播放器
if (activePlayer === player) {
activePlayer = null;
resetIsland();
stopLyricUpdates();
}
};
// 创建Intersection Observer监听播放器元素进入视口
const playerIntersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 播放器进入视口,初始化监听
initPlayer(entry.target);
} else {
// 播放器离开视口,可选择清理监听(这里保留监听以支持后台播放)
// cleanupPlayer(entry.target);
}
});
}, {
rootMargin: '500px 0px', // 提前500px触发观察,确保播放器即将进入视口时就初始化
threshold: 0.1
});
// 创建Mutation Observer监听DOM变化,捕获新添加的播放器
const domObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
// 检查新增节点中是否有播放器
mutation.addedNodes.forEach(node => {
if (node.classList && node.classList.contains('aplayer')) {
// 直接找到播放器
playerIntersectionObserver.observe(node);
} else if (node.nodeType === 1) {
// 检查子节点中是否有播放器
node.querySelectorAll('.aplayer').forEach(player => {
playerIntersectionObserver.observe(player);
});
}
});
}
});
});
// 开始监听DOM变化
domObserver.observe(document.body, {
childList: true,
subtree: true
});
// 初始化现有播放器
document.querySelectorAll('.aplayer').forEach(player => {
playerIntersectionObserver.observe(player);
});
// 页面卸载时清理所有资源
window.addEventListener('beforeunload', () => {
domObserver.disconnect();
playerIntersectionObserver.disconnect();
playerObservers.forEach(observer => observer.disconnect());
playerObservers.clear();
stopLyricUpdates();
});
}
// 新增:判断屏幕宽度,小于500px则不模拟
const screenWidth = window.innerWidth;
if (screenWidth < 500) {
console.log(`[模拟判断] 屏幕宽度${screenWidth}px < 500px,不执行模拟`);
} else {
initiPhoneSimulator();
window.addEventListener('load', function() {
// 处理音乐播放器宽地
if (optimalWidth < 520) {
document.querySelectorAll('.aplayer').forEach(ae => {
ae.style.width = '100%';
ae.style.maxWidth = `${optimalWidth - 30}px`;
ae.style.boxSizing = 'border-box';
})
}
console.log('处理音乐播放器宽度');
});
}
3.2.1 🛂 路径验证
在 initiPhoneSimulator 函数中,首先进行路径验证,就像一个严格的门卫,只有当页面路径为根路径时才执行模拟操作,避免在其他页面不必要的模拟,确保模拟过程的准确性和高效性。
3.2.2 📏 尺寸计算与适配
📌 核心参数设定:定义了 baseWidth 和 baseHeight 作为 iPhone 模拟的基础尺寸,计算出宽高比 ratio。同时,设置了最小宽度 minWidth 为 395px,确保模拟的 iPhone 宽度不会过小,就像为模拟过程设定了一个坚实的基础框架。
📐 可用空间计算:根据屏幕宽度和高度,计算出最大可用宽度和高度,分别为屏幕宽度的 85% 和屏幕高度的 95%,就像为模拟过程规划了一个合理的活动范围。
🌟 最优宽高计算:通过比较按基础宽度计算的高度和最大可用高度,确定最优的宽高值。如果按基础宽度计算的高度超出最大可用高度,则限制高度为最大可用高度,并重新计算宽度。同时,进行二次检查,确保宽度不小于最小宽度,就像一位精打细算的设计师,为模拟过程找到最完美的尺寸。
3.2.3 🎨 元素创建与布局
📱 iPhone 容器创建:使用 document.createElement 创建一个 div 元素作为 iPhone 容器,设置其宽度、高度、边框、圆角、背景等样式,并添加鼠标悬停动画效果,就像为模拟的 iPhone 穿上了一件时尚的外衣,让它更加生动逼真。
📺 屏幕容器创建:在 iPhone 容器内部创建屏幕容器,设置其宽度、高度、背景、圆角等样式,并将其添加到 iPhone 容器中,就像为 iPhone 安装了一块清晰的屏幕,让内容展示更加清晰。
💫 灵动岛创建:在屏幕容器内部创建灵动岛元素,设置其位置、大小、背景、圆角等样式,并添加歌词容器和交互效果。当点击灵动岛时,通过改变其宽度和高度实现展开和收缩的效果,就像为 iPhone 赋予了一个灵动的灵魂,让交互更加有趣。
📄 内容容器创建:创建内容容器,将原页面的 __nuxt 容器移动到内容容器中,并设置其宽度、高度和滚动属性。同时,移除原页面中固定宽度的类,以适配模拟的 iPhone 尺寸,就像为原页面内容找到了一个合适的新家,让它在模拟的 iPhone 中完美展示。
3.2.4 🎵 播放器监听与歌词更新
🎧 高级播放器监听系统:setupAdvancedPlayerListener 函数实现了对多个音乐播放器的监听和歌词更新功能。通过 MutationObserver 监听播放器和播放按钮的状态变化,当播放器开始播放时,展开灵动岛并开始更新歌词;当播放器暂停时,重置灵动岛并停止更新歌词,就像一个智能的音乐管家,时刻关注着播放器的状态。
👀 Intersection Observer:使用 Intersection Observer 监听播放器元素进入视口的事件,当播放器进入视口时,初始化对该播放器的监听;当播放器离开视口时,保留监听以支持后台播放,就像一个默默守护的卫士,确保播放器在任何情况下都能正常工作。
👀 Mutation Observer:使用 Mutation Observer 监听 DOM 变化,当有新的播放器元素添加到页面时,自动初始化对该播放器的监听,就像一个敏锐的侦探,及时发现新的播放器并进行处理。
3.2.5 🧹 资源清理
在页面卸载时,通过 window.addEventListener('beforeunload') 事件清理所有的监听器和定时器,避免内存泄漏,就像一个细心的清洁工,在离开前将一切清理干净,确保页面的整洁和高效。

四、播放器监听系统完整实现
4.1 🤔 问题分析
在实现动态多播放器的过程中,主要面临以下几个问题:
🎵 播放器状态同步:当页面中存在多个音乐播放器时,需要确保灵动岛能够准确显示当前正在播放的播放器的信息,并在播放器状态变化时及时更新,就像一个精准的指挥家,确保所有播放器的节奏一致。
📝 歌词更新与滚动:要实现歌词的实时更新和滚动效果,需要监听播放器的歌词变化,并在合适的时机更新灵动岛中的歌词显示,就像一个勤奋的记录员,及时记录下每一句歌词。
⚙️ 性能优化:在处理多个播放器的监听和更新时,需要考虑性能问题,避免过多的 DOM 操作和定时器导致页面卡顿,就像一个精明的工程师,优化每一个细节,确保系统的高效运行。
📜 无限滚动适配:当页面支持无限滚动时,需要确保新添加的播放器能够自动被监听,并且在播放器离开视口时不会影响后台播放,就像一个灵活的舞者,能够适应各种舞台环境。
4.2 💡 解决方法
4.2.1 🎵 播放器状态同步
👀 使用 MutationObserver:通过 MutationObserver 监听播放器和播放按钮的 class 属性变化,当播放器开始播放时,将其标记为活跃播放器,并展开灵动岛;当播放器暂停时,重置灵动岛,就像一个敏锐的观察者,及时发现播放器状态的变化并做出相应的调整。
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
handlePlayerStateChange(player);
}
});
});
observer.observe(player, { attributes: true, attributeFilter: ['class'] });
observer.observe(playButton, { attributes: true, attributeFilter: ['class'] });4.2.2 📝 歌词更新与滚动
⏱️ 定时器更新歌词:使用 setInterval 定时器每隔 1.5 秒检查当前活跃播放器的歌词变化,当歌词发生变化时,更新灵动岛中的歌词显示,并重置滚动动画,就像一个准时的闹钟,按时提醒我们更新歌词。
const updateLyric = () => {
if (!activePlayer) return;
const currentLine = activePlayer.querySelector('.aplayer-lrc-current');
if (currentLine && currentLine.textContent.trim()) {
const lyric = currentLine.textContent;
if (lyric !== lyricText.textContent) {
lyricText.textContent = lyric;
// 重置滚动动画
lyricText.classList.remove('scrolling');
void lyricText.offsetWidth;
lyricText.classList.add('scrolling');
}
}
};
lyricUpdateTimer = setInterval(updateLyric, 1500);4.2.3 ⚙️ 性能优化
🗄️ 使用 Map 存储监听器:使用 Map 对象存储每个播放器的 MutationObserver 引用,避免重复初始化监听器,提高性能,就像一个高效的仓库管理员,合理管理每一个监听器。
let playerObservers = new Map();
playerObservers.set(player, observer);👀 Intersection Observer 优化:通过设置
rootMargin和threshold参数,提前触发对播放器元素的观察,确保播放器即将进入视口时就初始化监听,减少不必要的 DOM 操作,就像一个聪明的预判者,提前做好准备,避免不必要的麻烦。
const playerIntersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
initPlayer(entry.target);
}
});
}, {
rootMargin: '500px 0px',
threshold: 0.1
});4.2.4 📜 无限滚动适配
👀 Mutation Observer 监听 DOM 变化:使用 MutationObserver 监听页面的 DOM 变化,当有新的播放器元素添加到页面时,自动初始化对该播放器的监听,就像一个警觉的哨兵,时刻关注着页面的变化,及时发现新的播放器并进行处理。
const domObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.classList && node.classList.contains('aplayer')) {
playerIntersectionObserver.observe(node);
} else if (node.nodeType === 1) {
node.querySelectorAll('.aplayer').forEach(player => {
playerIntersectionObserver.observe(player);
});
}
});
}
});
});
domObserver.observe(document.body, {
childList: true,
subtree: true
});五、歌词系统:从定时轮询到 MutationObserver
在原有功能基础上,进一步增强了灵动岛的视觉效果与交互体验,并通过技术重构实现了性能的显著提升,使歌词展示更流畅、资源占用更合理。
5.1 CSS样式增强:专辑封面与歌词容器优化
通过新增专辑封面容器和细化歌词样式,让灵动岛的视觉层次更丰富,交互反馈更直观。
/* 灵动岛基础样式增强 */
.dynamic-island {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 更自然的过渡曲线 */
}
/* 专辑封面容器(默认隐藏,播放时显示) */
.album-cover {
width: 0;
height: 0;
border-radius: 4px;
background-color: #333;
margin-right: 0;
margin-left: 8px;
flex-shrink: 0;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 与灵动岛过渡保持一致 */
}
.dynamic-island.playing .album-cover {
width: 24px;
height: 24px;
margin-right: 8px; /* 播放时展开并显示间距 */
}
.album-cover img {
width: 100%;
height: 100%;
object-fit: cover; /* 确保封面图片比例正确 */
}
/* 歌词容器与文本优化 */
.lyric-container {
position: relative;
flex-grow: 1;
height: 100%;
display: flex;
align-items: center;
overflow: hidden;
padding: 0 4px; /* 避免歌词贴边 */
}
.lyric-text {
color: #ffcc99;
font-size: 14px; /* 增大字体提升可读性 */
white-space: nowrap;
transform: translateX(100%);
opacity: 0;
font-weight: 500;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
max-width: calc(100% - 8px); /* 预留边距,避免内容溢出 */
}核心优化点:
专辑封面动态显示:通过
.dynamic-island.playing类触发,播放时自动展开封面容器并显示专辑图片,增强视觉关联性;
歌词样式细化:增大字体尺寸、添加边距限制,提升可读性;
过渡曲线统一:采用
cubic-bezier(0.4, 0, 0.2, 1)曲线,使灵动岛展开、封面显示等动画更协调自然。
5.2 JavaScript实现:从“主动轮询”到“被动监听”的重构
通过引入 MutationObserver 替代定时轮询,实现歌词变化的精准监听,同时优化滚动逻辑,提升性能与流畅度。
5.2.1 歌词滚动控制器(JS驱动)
// 歌词滚动状态管理
let scrollAnimationId = null; // 动画ID,用于中断
let isScrolling = false; // 滚动状态标记
let startTimestamp = null; // 动画开始时间戳
// 停止当前滚动动画
const stopScroll = () => {
if (scrollAnimationId) {
cancelAnimationFrame(scrollAnimationId);
scrollAnimationId = null;
}
isScrolling = false;
startTimestamp = null;
};
// 平滑滚动函数(优化版)
const smoothScroll = (element, totalDistance, duration) => {
if (totalDistance <= 0) return; // 无需滚动
// 缓动函数:模拟自然物理运动
const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// 重置状态
element.style.transform = 'translateX(0)';
startTimestamp = performance.now();
const animate = (currentTime) => {
const elapsedTime = currentTime - startTimestamp;
const progress = Math.min(elapsedTime / duration, 1);
const easedProgress = easeInOutCubic(progress);
// 计算当前位置(从0滚动到-totalDistance)
const currentPosition = -totalDistance * easedProgress;
element.style.transform = `translateX(${currentPosition}px)`;
if (progress < 1) {
scrollAnimationId = requestAnimationFrame(animate);
} else {
stopScroll(); // 动画完成,清理状态
}
};
isScrolling = true;
scrollAnimationId = requestAnimationFrame(animate);
};5.2.2 歌词变化监听系统(核心优化)
// 监听歌词容器中p元素的class变化
const setupLyricChangeListener = (player) => {
// 找到歌词容器
const lrcContainer = player.querySelector('.aplayer-lrc-contents');
if (!lrcContainer) return;
// 创建监听p元素class变化的Observer
const lyricObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// 只处理class变化的p元素
if (mutation.type === 'attributes' &&
mutation.attributeName === 'class' &&
mutation.target.tagName === 'P') {
const pElement = mutation.target;
// 检查是否是当前歌词(class包含aplayer-lrc-current)
if (pElement.classList.contains('aplayer-lrc-current')) {
handleNewLyric(pElement.textContent.trim()); // 处理新歌词
}
}
});
});
// 开始监听所有p元素的class变化
lyricObserver.observe(lrcContainer, {
attributes: true, // 监听属性变化
attributeFilter: ['class'], // 只监听class属性
subtree: true // 监听子树(所有p元素)
});
// 存储Observer,用于后续清理
player.lyricObserver = lyricObserver;
};
// 处理新歌词
const handleNewLyric = (rawLyric) => {
// 去除翻译内容,保留纯歌词
const cleanLyric = rawLyric.replace(/\\([^)]*\\)|\\[.*?\\]/g, '').trim();
if (!cleanLyric) return;
// 停止当前滚动
stopScroll();
// 更新歌词显示
lyricText.textContent = cleanLyric;
lyricText.classList.add('visible');
lyricText.style.transform = 'translateX(0)';
// 计算滚动参数(歌词宽度 > 可见宽度时才滚动)
const lyricWidth = getTextWidth(cleanLyric);
const visibleWidth = getVisibleWidth();
if (lyricWidth > visibleWidth) {
const scrollDistance = lyricWidth - visibleWidth;
const scrollDuration = Math.max(5, scrollDistance / 60) * 1000; // 60px/秒的滚动速度
setTimeout(() => {
smoothScroll(lyricText, scrollDistance, scrollDuration);
}, 200);
}
};核心优化点:
从“定时轮询”到“被动监听”:使用 MutationObserver 监听歌词元素(p 标签)的 class 变化,仅在歌词实际切换时触发更新,替代原有的 setInterval 定时查询,减少无效计算;
精准歌词处理:通过正则清理歌词中的翻译内容,确保显示纯歌词,并仅在歌词变化时执行滚动计算,避免重复操作;
平滑滚动优化:采用 requestAnimationFrame 结合缓动函数,使歌词滚动更符合物理规律,减少视觉卡顿。
5.3 性能优化对比:从“低效轮询”到“按需执行”
通过技术重构,灵动岛的性能在多个维度实现显著提升,核心差异如下:
核心优势:优化方案彻底消除了“无歌词变化时的无效计算”,将DOM操作频率与歌词变化频率绑定(通常远低于500ms/次),显著降低主线程负担,尤其在多播放器或长时间播放场景下,性能优势更明显。
5.4 专辑封面同步逻辑
新增专辑封面提取与显示功能,实现歌词与封面的联动:
// 更新专辑封面
const updateAlbumCover = () => {
if (!activePlayer) return;
// 从播放器获取封面图片
const coverElement = activePlayer.querySelector('.aplayer-pic');
if (coverElement) {
// 支持从background-image提取封面
const bgImage = coverElement.style.backgroundImage;
if (bgImage) {
const imgUrlMatch = bgImage.match(/url\\("?(.+?)"?\\)/);
if (imgUrlMatch && imgUrlMatch[1]) {
albumCover.innerHTML = `<img src="${imgUrlMatch[1]}" alt="专辑封面">`;
return;
}
}
// 支持从img标签提取封面
const imgElement = coverElement.querySelector('img');
if (imgElement && imgElement.src) {
albumCover.innerHTML = `<img src="${imgElement.src}" alt="专辑封面">`;
}
}
};通过解析播放器中的封面元素(aplayer-pic),自动提取封面图片并同步到灵动岛的专辑封面容器,增强视觉关联性。
优化通过CSS样式增强、JavaScript逻辑重构(从轮询到监听)、性能精细化管理,使灵动岛在视觉效果更丰富的同时,实现了“按需执行”的高效运行模式。专辑封面的动态显示与歌词的精准同步,进一步提升了用户体验,而性能的优化则为多场景下的稳定运行提供了保障。
Moments原作者项目地址:https://github.com/kingwrcy/moments
IPhone16 Pro模拟地址:https://pyq.zxiaolin.com/