把 Shoka 的普通音乐播放器改造成一个可以旋转、换唱片、看歌词和跟随音乐律动的 Three.js 三维留声机,并保证 Three.js 只在留声机页面加载。
搞这个活也是为了消耗下 token
#前言
Shoka 原本已经支持 media audio 音乐标签,但它主要负责播放音乐,并不会把音乐状态呈现在三维场景中。本次改造保留主题原来的音频解析和播放能力,在它外面增加一层 Three.js 交互场景。
最终效果包含:
- 三渲二风格的留声机底座、唱盘、唱针、喇叭、摇把、花草、路牌和喷泉;
- 播放时唱片和摇把转动,暂停后停止;
- 唱针随播放状态平移,并根据播放进度逐渐改变位置;
- 喇叭口只在播放时出现声浪;
- 唱片周围显示跟随音乐变化的彩色频谱;
- 点击唱片架后展开多个歌单,支持左右浏览和换片动画;
- 点击正面屏幕打开播放面板,可操作播放、暂停、停止、上一首、下一首、进度、音量和具体歌曲;
- 播放面板支持单曲循环、列表播放和随机播放,并自动定位当前歌曲;
- 支持当前歌词、下一句歌词、封面和当前歌单曲目列表;
- 支持拖动视角、滚轮缩放和全屏浏览;
- 离开页面时释放场景、纹理、监听器和动画;
- Three.js、留声机脚本及样式只在指定页面加载。
这篇教程以 “能照着做出来” 为目标。即使你以前没有写过 Three.js,也可以先按顺序搭好结构,再逐步替换模型细节。
#实现思路
先看清楚各部分之间的关系,后面修改时会容易很多:
关于页面 Markdown | |
└─ gramophone 自定义标签 | |
├─ 输出 Three.js canvas 和播放面板 | |
└─ 输出隐藏的 Shoka 音乐播放器 | |
↓ | |
PJAX 检测 page.gramophone | |
├─ 按需加载 gramophone.css | |
├─ 按需加载 gramophone.js | |
└─ 按需加载 Three.js | |
↓ | |
gramophone.js 读取原播放器状态 | |
├─ 同步歌曲、歌单、封面、歌词和进度 | |
├─ 控制唱片、唱针、摇把、声浪和频谱 | |
└─ 页面离开时统一销毁 |
最重要的一点是:音乐仍由 Shoka 原播放器负责,Three.js 只负责表现和交互。 这样不需要重新实现网易云歌单解析,也不会出现两个播放器同时播放的问题。
#开始前准备
#1. 找到博客根目录
能看到下面这些文件和文件夹的位置就是博客根目录:
_config.yml | |
package.json | |
source | |
themes |
本文所有路径都从博客根目录开始计算。
#2. 先备份
建议先提交一次 Git,或者至少备份以下目录:
source/about | |
themes/shoka |
#3. 安装 Three.js
在博客根目录打开终端,执行:
npm install three@0.186.0 --save-exact | |
npm install esbuild@0.28.2 --save-dev --save-exact |
本文按 Three.js r186 编写。这个版本的 npm 包不再提供预压缩的 three.module.min.js 和 three.core.min.js ,因此需要 esbuild 在生成站点时制作本地压缩资源。它只参与构建,不会作为额外脚本发送给浏览器;部署环境安装依赖时也要保留开发依赖,例如使用 npm ci --include=dev 。
自定义标签使用 YAML 读取歌单。Hexo 项目通常已经间接安装 js-yaml ;如果构建时提示 Cannot find module 'js-yaml' ,再执行:
npm install js-yaml --save |
#4. 确认原音乐标签可用
留声机复用 Shoka 的音乐解析代码。请先确认普通音乐标签能够正常生成播放器:
{% media audio %} | |
- title: 测试歌单 | |
list: | |
- https://music.163.com/playlist?id=你的歌单ID | |
{% endmedia %} |
如果普通播放器本身都无法播放,应先修复音源或歌单接口,再继续制作三维模型。
不要直接修改 public 目录。它是 hexo generate 自动生成的结果,执行 hexo clean 后会被重新创建。
#文件结构
本次会涉及这些文件:
package.json | |
lib/build-three.cjs | |
source/about/index.md | |
themes/shoka/_config.yml | |
themes/shoka/layout/_partials/layout.njk | |
themes/shoka/scripts/generaters/script.js | |
themes/shoka/scripts/tags/gramophone.js | |
themes/shoka/source/js/_app/pjax.js | |
themes/shoka/source/js/gramophone.js | |
themes/shoka/source/css/gramophone.styl | |
themes/shoka/source/images/music-cover-default.svg |
#文件变更清单
| 操作 | 文件 | 作用 |
|---|---|---|
| 修改 | package.json | 声明 Three.js 直接依赖和 esbuild 构建依赖。 |
| 新增 | lib/build-three.cjs | 在生成站点时压缩 Three.js 模块和核心文件,并保留原本的公开资源地址。 |
| 修改 | source/about/index.md | 开启本页留声机资源,并使用新的成对标签配置歌单。 |
| 修改 | themes/shoka/_config.yml | 增加 Three.js 的首选 CDN 地址。 |
| 修改 | themes/shoka/layout/_partials/layout.njk | 仅当页面 Front Matter 开启 gramophone 时输出页面标记。 |
| 修改 | themes/shoka/scripts/generaters/script.js | 向浏览器传递 Three.js、留声机脚本和样式地址,并生成本地后备文件。 |
| 新增 | themes/shoka/scripts/tags/gramophone.js | 注册 gramophone 成对标签,解析 YAML 歌单并输出页面结构。 |
| 修改 | themes/shoka/source/js/_app/pjax.js | 进入页面时按需加载,离开页面时销毁留声机。 |
| 新增 | themes/shoka/source/js/gramophone.js | 创建三维场景、模型、交互、音频联动和销毁逻辑。 |
| 新增 | themes/shoka/source/css/gramophone.styl | 设置画布、全屏按钮、播放面板、曲目列表和移动端样式。 |
| 新增 | themes/shoka/source/images/music-cover-default.svg | 外部封面不可用时显示本地默认唱片封面。 |
本次没有删除主题原来的音乐播放器。留声机正是通过隐藏播放器获得歌单、音频、歌词和封面信息,所以不要删除 mediaPlayer 、APlayer 或主题现有的音频解析代码。
#开始实现
#第 1 步:配置 Three.js 和本地后备
打开 themes/shoka/_config.yml ,找到 vendors.js ,加入:
vendors: | |
js: | |
three: npm/three@0.186.0/build/three.module.js |
这里使用主题已有的 CDN 地址拼接规则。接着打开 themes/shoka/scripts/generaters/script.js ,把资源地址写入浏览器端配置:
js: { | |
three: theme.vendors.js.three, | |
gramophone: theme.js + '/gramophone.js' | |
}, | |
css: { | |
gramophone: theme.css + '/gramophone.css' | |
}, | |
fallback: { | |
js: { | |
three: config.root + theme.js + '/three.module.min.js', | |
gramophone: config.root + theme.js + '/gramophone.js' | |
} | |
} |
注意 CDN 入口现在是上游实际提供的 three.module.js ,本地后备地址则继续使用 three.module.min.js ,两者不必同名。不要继续从 r186 的 npm 包复制不存在的 .min.js 文件。
在博客根目录新建 lib 文件夹,再创建 lib/build-three.cjs ,完整内容如下:
'use strict'; | |
const fs = require('node:fs'); | |
const path = require('node:path'); | |
const { transformSync } = require('esbuild'); | |
// r186 ships unminified ESM only. Keep our public URLs and the module/core | |
// split stable; the existing asset pipeline fingerprints both and rewrites | |
// the relative import to the matching fingerprinted core. | |
function buildThreeAssets() { | |
const directory = path.dirname(require.resolve('three')); | |
const core = fs.readFileSync(path.join(directory, 'three.core.js'), 'utf8'); | |
const module = fs.readFileSync(path.join(directory, 'three.module.js'), 'utf8'); | |
const reference = /(['"])\.\/three\.core\.js\1/g; | |
if (!reference.test(module)) throw new Error('Three.js module/core entry changed; review the asset build before publishing.'); | |
const rewritten = module.replace(reference, '$1./three.core.min.js$1'); | |
const compile = (source, sourcefile) => transformSync(source, { | |
loader: 'js', format: 'esm', target: 'es2020', minify: true, | |
legalComments: 'inline', sourcefile | |
}).code; | |
return { | |
'three.module.min.js': compile(rewritten, 'three.module.js'), | |
'three.core.min.js': compile(core, 'three.core.js') | |
}; | |
} | |
module.exports = { buildThreeAssets }; |
这个函数读取 npm 包中的两个 ESM 文件,分别压缩,并把模块内的核心文件引用改成 ./three.core.min.js 。不要只发布模块文件而漏掉核心文件。
回到 themes/shoka/scripts/generaters/script.js ,在文件顶部其他 require 旁加入:
const { buildThreeAssets } = require('../../../../lib/build-three.cjs'); |
在已有 generatedAssets.forEach(...) 循环之后、 return outputs 之前加入下面代码。如果以前在 generatedAssets 数组中直接复制这两个 Three.js .min.js 文件,应删除那两条旧条目,保留其他资源条目:
const threeAssets = buildThreeAssets(); | |
Object.entries(threeAssets).forEach(([filename, data]) => { | |
outputs.push({ path: theme.js + '/' + filename, data }); | |
}); |
这样加载顺序就是:
- 优先请求配置的 CDN;
- CDN 失败时请求站点自己的
/js/three.module.min.js; - 本地入口再加载同目录的
three.core.min.js,两个文件都由hexo generate生成,也能被你的静态 CDN 一起发布。
压缩只发生在生成站点时,不会把 Three.js 合并进每页都加载的 app.js ,也不会改变下面的页面开关和按需加载逻辑。本项目的资源指纹流程还会给这两个文件生成对应的版本地址,并同步改写它们之间的引用;不要手工编辑 public 中的文件。
#第 2 步:给页面增加独立开关
打开 themes/shoka/layout/_partials/layout.njk ,在页面配置对象中加入:
{%- if page.gramophone %}gramophone: true,{%- endif %} |
以后只有 Front Matter 中写了下面配置的页面,才会得到 LOCAL.gramophone :
gramophone: true |
不要把这个开关写成全局固定的 true ,否则每篇文章都会有机会加载 Three.js,失去按页加载的意义。
#第 3 步:注册新的 Markdown 标签
在 themes/shoka/scripts/tags 中新建 gramophone.js 。
先引入 YAML 解析器,并准备 HTML 转义函数:
'use strict' | |
const yaml = require('js-yaml') | |
const escapeHTML = value => String(value == null ? '' : value) | |
.replace(/&/g, '&') | |
.replace(/</g, '<') | |
.replace(/>/g, '>') | |
.replace(/"/g, '"') | |
.replace(/'/g, ''') |
为什么必须转义?因为歌单数据最终会进入 HTML 属性。如果标题中包含引号而不转义,就可能破坏整个标签结构。
然后解析标签中的 YAML:
function renderGramophone(args, content) { | |
const playlists = yaml.load(content) | |
if(!Array.isArray(playlists) || !playlists.length) | |
throw new TypeError('gramophone 标签内需要填写至少一个歌单') | |
const playlistNames = playlists.map((playlist, index) => | |
playlist && playlist.title ? playlist.title : `歌单 ${index + 1}`) | |
const source = escapeHTML(JSON.stringify(playlists)) | |
const names = escapeHTML(JSON.stringify(playlistNames)) | |
// 这里返回留声机画布、播放面板和隐藏播放器结构 | |
} | |
hexo.extend.tag.register('gramophone', renderGramophone, { ends: true }) |
{ ends: true } 表示这是一个成对标签,必须同时存在开始标记和结束标记。
标签输出内容分成两部分:
<section id="about-gramophone" | |
class="about-gramophone" | |
data-gramophone-playlists="歌单名称数组"> | |
<div class="about-gramophone__visual"> | |
<canvas id="about-gramophone-canvas"></canvas> | |
</div> | |
</section> | |
<div id="about-gramophone-source" class="about-gramophone__source"> | |
<div class="media-container"> | |
<div class="player" data-type="audio" data-src="完整歌单数据"></div> | |
</div> | |
</div> |
第二部分虽然被 CSS 隐藏,但仍会被 Shoka 原有的音乐代码识别并创建真正的 <audio> 。留声机通过它完成播放,不要使用 display:none 隐藏 <audio> 本身,只隐藏外层展示区域即可。
#第 4 步:在关于页面配置歌单
打开 source/about/index.md ,在 Front Matter 中加入:
gramophone: true |
然后在正文中使用新标签:
# 听听音乐吧 | |
{% gramophone %} | |
- title: 我喜欢的音乐 | |
list: | |
- https://music.163.com/playlist?id=5297225655 | |
- title: 轻音乐 | |
list: | |
- https://music.163.com/playlist?id=100106023 | |
- title: 游戏原声 | |
list: | |
- https://music.163.com/playlist?id=164521381 | |
{% endgramophone %} |
添加新歌单时,只需要继续增加一组 title 和 list :
- title: 新歌单名称 | |
list: | |
- https://music.163.com/playlist?id=新的歌单ID |
YAML 对缩进非常敏感。 list 和 title 对齐,歌单地址要比 list 多缩进两格,并且不要使用 Tab。
#第 5 步:在 PJAX 中按需加载和销毁
Shoka 使用 PJAX。如果只在页面底部直接引入脚本,会遇到 “刷新正常、菜单进入空白” 或 “多次进入后动画越来越快” 的问题。
打开 themes/shoka/source/js/_app/pjax.js ,建立挂载和销毁函数:
var gramophoneLoadId = 0 | |
const destroyAboutGramophone = function() { | |
gramophoneLoadId++ | |
if(window.AboutGramophone && typeof window.AboutGramophone.destroy === 'function') | |
window.AboutGramophone.destroy() | |
} | |
const mountAboutGramophone = function() { | |
if(!LOCAL.gramophone || !document.getElementById('about-gramophone')) return | |
var currentLoadId = ++gramophoneLoadId | |
vendorCss('gramophone') | |
vendorJs('gramophone', function() { | |
if(currentLoadId !== gramophoneLoadId) return | |
vendorModule('three', function(THREE) { | |
if(currentLoadId !== gramophoneLoadId) return | |
if(window.AboutGramophone) | |
window.AboutGramophone.mount({ THREE: THREE }) | |
}) | |
}, window.AboutGramophone) | |
} |
gramophoneLoadId 用来让已经过期的异步加载自动失效。比如 Three.js 还没下载完,用户就离开了关于页面,旧回调不会再创建场景。
然后分别连接 PJAX 生命周期:
const pjaxReload = function () { | |
destroyAboutGramophone() | |
// 保留原来的清理代码 | |
} | |
const siteRefresh = function () { | |
// 保留原来的页面初始化代码 | |
mountAboutGramophone() | |
} |
#第 6 步:创建透明 Three.js 场景
在 themes/shoka/source/js 中新建 gramophone.js ,使用立即执行函数,避免变量污染全局:
(function() { | |
'use strict' | |
var active = null | |
function mount(options) { | |
destroy() | |
var THREE = options && options.THREE | |
var root = document.getElementById('about-gramophone') | |
var canvas = document.getElementById('about-gramophone-canvas') | |
if(!THREE || !root || !canvas) return | |
var scene = new THREE.Scene() | |
var camera = new THREE.PerspectiveCamera(31, 1, 0.1, 80) | |
var renderer = new THREE.WebGLRenderer({ | |
canvas: canvas, | |
antialias: true, | |
alpha: true, | |
powerPreference: 'high-performance' | |
}) | |
renderer.setClearColor(0x000000, 0) | |
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) | |
} | |
function destroy() { | |
if(active) active.destroy() | |
} | |
window.AboutGramophone = { mount: mount, destroy: destroy } | |
})() |
这里有三个容易忽略的地方:
alpha: true让 WebGL 画布支持透明背景;setClearColor(..., 0)才会真正清空默认黑色背景;- 像素比限制为 2,避免高分屏把渲染量放大到四倍以上。
#第 7 步:加入环境光和三渲二配色
为了让模型既有立体感又不过度写实,可以组合环境光、半球光、方向光和彩色点光:
scene.add(new THREE.AmbientLight(0xfffaff, 0.58)) | |
scene.add(new THREE.HemisphereLight(0xeaf3ff, 0x765582, 0.9)) | |
var key = new THREE.DirectionalLight(0xfff7fb, 1.75) | |
key.position.set(4.8, 9, 7) | |
key.castShadow = true | |
scene.add(key) | |
var violet = new THREE.PointLight(0xc17aff, 5.5, 16, 1.65) | |
violet.position.set(-4.5, 3.6, 3.2) | |
scene.add(violet) | |
var cyan = new THREE.PointLight(0x54efff, 4.5, 15, 1.8) | |
cyan.position.set(4.8, 2.4, -3.4) | |
scene.add(cyan) |
模型材质以紫色、粉色、青色和金色为主,并为主要物体添加稍微放大的背面轮廓,就能得到接近动画模型的描边效果:
function outlined(object, amount) { | |
var outline = object.clone() | |
outline.material = outlineMaterial | |
outline.material.side = THREE.BackSide | |
outline.scale.multiplyScalar(amount || 1.03) | |
object.parent.add(outline) | |
return object | |
} |
#第 8 步:分组搭建留声机模型
不要把所有物体直接放进 scene 。建议使用多个 THREE.Group :
scene | |
└─ machine | |
├─ cabinet 底座与正面屏幕 | |
├─ vinylGroup 唱盘、唱片、封面、频谱 | |
├─ tonearm 唱臂、唱头和唱针 | |
├─ horn 管道、喇叭和声浪 | |
├─ crankGroup 侧边摇把 | |
├─ recordShelf 唱片架和歌单唱片 | |
└─ decorations 花草、路牌和喷泉 |
这样播放动画只需要旋转 vinylGroup ,换片动画可以移动整个唱片组,拖动视角时则旋转最外层 machine 。
模型可以全部通过基础几何体组合:
- 底座:
BoxGeometry; - 唱盘和唱片:
CylinderGeometry; - 管道和唱臂:
TubeGeometry配合CatmullRomCurve3; - 喇叭:自定义顶点生成花瓣形几何体;
- 叶片:带透明纹理的
PlaneGeometry; - 喷泉水幕:透明圆台几何体;
- 水流:不等角度、不等半径的
CatmullRomCurve3曲线; - 屏幕文字:Canvas 绘制后转为
CanvasTexture。
例如创建模型的小工具:
function mesh(geometry, material, position, rotation, parent) { | |
var object = new THREE.Mesh(geometry, material) | |
if(position) object.position.set(position[0], position[1], position[2]) | |
if(rotation) object.rotation.set(rotation[0], rotation[1], rotation[2]) | |
;(parent || machine).add(object) | |
return object | |
} |
先把底座、唱盘和喇叭位置调整正确,再添加花草、喷泉等装饰。一次堆太多模型会让你很难判断究竟是哪一个坐标不正确。
#第 9 步:连接原来的音频播放器
在 mount() 中找到自定义标签生成的隐藏播放器:
var sourceContainer = document.getElementById('about-gramophone-source') | |
var source = sourceContainer && sourceContainer.querySelector('.player') | |
var audio = source && source.querySelector('audio') |
判断播放状态:
function isPlaying() { | |
return !!audio && !audio.paused && !audio.ended | |
} |
播放和暂停仍调用原播放器公开的方法:
function togglePlay() { | |
if(!source || !audio) return | |
if(isPlaying()) { | |
if(source.player && source.player.pause) source.player.pause() | |
else audio.pause() | |
} else { | |
var promise = source.player && source.player.play | |
? source.player.play() | |
: audio.play() | |
if(promise && promise.catch) promise.catch(function() {}) | |
} | |
} |
监听音频事件后,所有界面都能共用同一个状态:
audio.addEventListener('play', setPlayingState) | |
audio.addEventListener('pause', setPlayingState) | |
audio.addEventListener('ended', setPlayingState) | |
audio.addEventListener('timeupdate', updateTimeline) | |
audio.addEventListener('loadedmetadata', updateMetadata) |
#第 10 步:让机械部件跟随播放
在每帧动画中读取播放状态和进度:
var playing = isPlaying() | |
var ratio = audio && isFinite(audio.duration) && audio.duration > 0 | |
? audio.currentTime / audio.duration | |
: 0 |
然后分别控制:
if(playing) { | |
record.rotation.y += 0.016 | |
crankGroup.rotation.x -= 0.045 | |
} | |
var tonearmTarget = playing ? 0.5 + ratio * 0.48 : 1.42 | |
tonearm.rotation.y += (tonearmTarget - tonearm.rotation.y) * 0.055 | |
hornWaveGroup.visible = playing | |
spectrumGroup.visible = playing && !recordSwapping |
当前实现中唱针在播放开始时更靠近唱片中心,随着播放接近结束逐渐向外移动;暂停或停止后会干脆地平移回停放位置。
如果你希望符合另一种唱机的沟槽方向,只需要交换公式两端:
var tonearmTarget = playing ? 0.98 - ratio * 0.48 : 1.42 |
#第 11 步:制作音乐频谱
浏览器允许时,可以通过 captureStream() 把正在播放的音频送入分析器:
function setupReactiveAudio() { | |
var capture = audio.captureStream || audio.mozCaptureStream | |
var AudioContext = window.AudioContext || window.webkitAudioContext | |
if(!capture || !AudioContext) return | |
var stream = capture.call(audio) | |
audioContext = new AudioContext() | |
analyser = audioContext.createAnalyser() | |
analyser.fftSize = 512 | |
analyser.smoothingTimeConstant = 0.58 | |
analyserSource = audioContext.createMediaStreamSource(stream) | |
analyserSource.connect(analyser) | |
frequencyData = new Uint8Array(analyser.frequencyBinCount) | |
} |
唱片周围均匀放置多根短柱,每帧根据不同频率区间更新高度:
analyser.getByteFrequencyData(frequencyData) | |
spectrumBars.forEach(function(bar, index) { | |
var bin = Math.floor(index / spectrumBars.length * frequencyData.length) | |
var level = frequencyData[bin] / 255 | |
bar.scale.y += (level - bar.scale.y) * 0.3 | |
}) |
实际项目还加入了平滑、频率映射、光晕和无法读取真实频谱时的降级律动。降级动画只用于保持画面不僵硬,不会影响音乐播放。
部分跨域音源不允许 Web Audio 读取真实频率。这时音乐可以正常播放,但频谱只能使用降级律动。不要为了频谱给所有音频强制设置 crossOrigin ,否则某些音源会直接无法播放。
#第 12 步:制作唱片架和换片动画
这一部分最容易出现三个问题:唱片架与唱盘重叠、展开后无法收回,以及大小唱片切换时像突然消失。可以按下面四个小步骤处理。
#12.1 把唱片架放到唱盘后方
唱片架和唱盘都是 machine 的子对象,因此可以直接比较它们的坐标。摄像机位于 Z 轴正方向,Z 值越小越靠近场景后方:
var recordShelf = new THREE.Group() | |
recordShelf.position.set(-2.3, 0.89, -1.52) | |
recordShelf.rotation.y = 0.12 | |
machine.add(recordShelf) |
这里的三个数字分别控制左右、高度和前后位置。不要只修改屏幕上的 CSS,因为唱片架是 WebGL 场景中的三维物体,CSS 无法改变它和唱盘之间的真实遮挡关系。
关闭状态下,架内唱片数量跟随歌单数量。为避免歌单过多后变成一整块黑色,最多绘制 10 张,同时让架体宽度随数量增长:
var shelfRecordCount = Math.max(1, Math.min(playlistNames.length, 10)) | |
var shelfWidth = clamp( | |
1.5 + (shelfRecordCount - 1) * 0.11, | |
1.5, | |
2.5 | |
) |
这个限制只影响架内模型,不会删掉真实歌单。每张小唱片还可以加入独立的彩色底环和外圈描边,让叠放状态仍能分辨出数量。
#12.2 让展开后的空唱片架仍然可点击
唱片展开后,架内小唱片和封套会隐藏。如果只有唱片绑定了点击动作,用户就无法再次点击木质架体把它收回。
先给底板、背板和立柱注册相同的动作:
;[shelfBase, shelfBack].forEach(function(part) { | |
part.userData.action = 'open-shelf' | |
interactiveTargets.push(part) | |
}) |
再在架体正面增加一个完全透明、但可以被 Raycaster 检测到的拾取区域:
var shelfHitArea = mesh( | |
new THREE.BoxGeometry(shelfWidth + 0.2, 1.16, 0.08), | |
new THREE.MeshBasicMaterial({ | |
transparent: true, | |
opacity: 0, | |
depthWrite: false, | |
side: THREE.DoubleSide | |
}), | |
[0, 0.64, 0.42], | |
null, | |
recordShelf | |
) | |
shelfHitArea.userData.action = 'open-shelf' | |
interactiveTargets.push(shelfHitArea) |
openRecordShelf() 使用同一个函数处理展开和收回:
function openRecordShelf() { | |
if(recordSwapStarted) return | |
if(shelfOpen) { | |
shelfOpen = false | |
hoveredPlaylistIndex = -1 | |
root.classList.remove('is-selecting-vinyl') | |
updateMetadata() | |
return | |
} | |
shelfOpen = true | |
root.classList.add('is-selecting-vinyl') | |
// 继续设置当前窗口、悬停唱片和屏幕提示 | |
} |
这样第一次点击是展开,未选择唱片时再次点击架体就是收回。
#12.3 展开全部歌单唱片
每个歌单仍对应一张可选择的三维唱片卡片。点击唱片架时,让架内小唱片逐渐抬起、缩小并隐藏,同时把歌单唱片从架体位置横向展开。屏幕一次显示固定数量,其余内容通过左右按钮浏览:
var galleryVisibleCount = 5 | |
var galleryWindowStart = 0 | |
function browseShelf(direction) { | |
var maxStart = Math.max(0, playlistNames.length - galleryVisibleCount) | |
galleryWindowStart = Math.max( | |
0, | |
Math.min(maxStart, galleryWindowStart + direction) | |
) | |
} |
唱片卡片收回时执行相反动画。只有 shelfExpansion 接近 0 后,架内的小唱片才重新出现,从视觉上表现为唱片已经回到架内。
#12.4 制作带尺寸变化的换片轨迹
唱盘上的唱片半径约为 1.54 ,架内唱片半径约为 0.55 ,两者比例大约是 0.36 。如果动画只移动坐标、不修改尺寸,就会看到一张巨大的唱片直接塞进架里。
先让唱片材质支持双面渲染,避免唱片转为竖直方向时因为背面剔除看起来缺了一角:
var vinylMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x09070f, | |
roughness: 0.27, | |
metalness: 0.14, | |
side: THREE.DoubleSide | |
}) |
再增加起止速度都为 0 的缓动函数:
function smootherStep(value) { | |
value = clamp(value, 0, 1) | |
return value * value * value * (value * (value * 6 - 15) + 10) | |
} |
不要把唱片架终点写成一组猜测值,而是根据唱片架与唱盘的相对位置计算:
var swapShelfX = recordShelf.position.x - platter.position.x + 0.12 | |
var swapShelfY = recordShelf.position.y + 0.88 - platter.position.y | |
var swapShelfZ = recordShelf.position.z - platter.position.z + 0.08 | |
var shelfRecordScale = 0.365 |
完整换片过程分成三段:
| 动画区间 | 画面表现 |
|---|---|
| 0%~45% | 旧唱片抬起,沿弧线飞向唱片架,同时由水平转为竖直并缩小。 |
| 45%~55% | 唱片在架内轻微下沉,切换隐藏播放器的歌单和封面。 |
| 55%~100% | 新唱片从架内取出,沿另一条弧线放大,最终恢复水平并落入唱盘。 |
关键的尺寸插值写法如下:
// 放回唱片架:从 1 缩小到 0.365 | |
vinylGroup.scale.setScalar( | |
1 + (shelfRecordScale - 1) * outgoingEase | |
) | |
// 放入唱盘:从 0.365 放大到 1 | |
vinylGroup.scale.setScalar( | |
shelfRecordScale + incomingEase * (1 - shelfRecordScale) | |
) |
点击某张唱片后也不要立即切歌。先让唱针归位并开始换片,在唱片进入架内的中点再执行真正的歌单切换:
function startRecordSwap(action) { | |
if(isPlaying()) source.player.pause() | |
recordSwapAction = action | |
recordSwapStarted = performance.now() | |
root.classList.add('is-changing-record') | |
} |
当前动画总时长为 2380ms 。结束后必须把位置、三个旋转轴和缩放全部恢复,否则下一次播放或换片会继承上次残留的变换:
vinylGroup.position.set(0, 0, 0) | |
vinylGroup.rotation.set(0, 0, 0) | |
vinylGroup.scale.setScalar(1) |
这样 “换歌单” 才会像真实取片和放片,而不是普通网页下拉框。
#第 13 步:同步歌曲、封面、歌词和曲目列表
Shoka 播放器已经把歌单渲染到隐藏区域,所以不必再次请求接口。读取当前激活的歌单:
function activePlaylistTab() { | |
return sourceContainer.querySelector('.playlist .tab.active[data-title]') | |
} |
读取当前曲目并生成播放面板列表:
var tab = activePlaylistTab() | |
var items = tab | |
? Array.prototype.slice.call(tab.querySelectorAll('li:not(.error)')) | |
: [] | |
items.forEach(function(item, index) { | |
var button = document.createElement('button') | |
button.setAttribute('data-gramophone-track-index', index) | |
// 继续加入序号、歌名、歌手和状态图标 | |
}) |
用户点击列表时,转发给隐藏播放器的原曲目节点:
var item = items[Number(button.dataset.gramophoneTrackIndex)] | |
if(item.classList.contains('current')) togglePlay() | |
else item.click() |
歌曲切换后给当前按钮增加 is-current ,打开面板时再把列表滚动到当前歌曲附近:
function revealCurrentDialogTrack() { | |
var currentButton = trackListNode.querySelector('button.is-current') | |
if(!currentButton) return | |
var listRect = trackListNode.getBoundingClientRect() | |
var currentRect = currentButton.getBoundingClientRect() | |
var currentTop = currentRect.top - listRect.top + trackListNode.scrollTop | |
var targetTop = Math.max( | |
0, | |
currentTop - (trackListNode.clientHeight - currentButton.offsetHeight) / 2 | |
) | |
trackListNode.scrollTo({ top: targetTop, behavior: 'smooth' }) | |
} |
播放模式不要自己重复实现 ended 事件,而是直接修改原播放器的模式:
function setPlaybackMode(mode) { | |
if(!source || !source.player) return | |
source.player.options.mode = mode | |
syncPlaybackMode() | |
} |
允许的值分别是 loop 、 order 和 random ,对应单曲循环、列表播放和随机播放。
歌词同样直接读取隐藏播放器已经同步好的节点:
var currentLyric = sourceContainer.querySelector('.lrc p.current') | |
var nextLyric = currentLyric && currentLyric.nextElementSibling |
封面加载失败时使用本地默认 SVG:
coverNode.addEventListener('error', function() { | |
coverNode.src = '/images/music-cover-default.svg' | |
}) |
#第 14 步:添加播放面板和移动端样式
在 themes/shoka/source/css 中新建 gramophone.styl 。根元素先定义模块自己的颜色变量:
.about-gramophone { | |
--gramophone-primary: #b15cff; | |
--gramophone-secondary: #ff5fc8; | |
--gramophone-cyan: #27e4ff; | |
--gramophone-gold: #ffd05d; | |
position: relative; | |
overflow: hidden; | |
} |
画布必须拥有明确高度,否则 Three.js 虽然运行了,但用户看到的会是一片空白:
.about-gramophone__visual { | |
position: relative; | |
min-height: 680px; | |
} | |
#about-gramophone-canvas { | |
display: block; | |
width: 100%; | |
height: 100%; | |
touch-action: none; | |
} |
播放面板使用固定定位并限制最大高度,内容过多时只滚动面板内部:
.about-gramophone__console { | |
position: fixed; | |
top: 50%; | |
left: 50%; | |
width: min(620px, calc(100vw - 28px)); | |
max-height: calc(100vh - 32px); | |
overflow: auto; | |
transform: translate(-50%, -46%) scale(.96); | |
opacity: 0; | |
pointer-events: none; | |
z-index: 10002; | |
} | |
.about-gramophone.is-console-open .about-gramophone__console { | |
transform: translate(-50%, -50%) scale(1); | |
opacity: 1; | |
pointer-events: auto; | |
} |
移动端把双列内容改成单列,并缩小封面和控制区:
@media (max-width: 767px) { | |
.about-gramophone__visual { min-height: 610px; } | |
.about-gramophone__console { grid-template-columns: 76px minmax(0, 1fr); } | |
.about-gramophone__lyrics, | |
.about-gramophone__queue, | |
.about-gramophone__timeline, | |
.about-gramophone__controls { grid-column: 1 / -1; } | |
} |
#第 15 步:处理拖动、全屏和页面位置
通过 Raycaster 找到用户点击的三维物体,再根据 userData.action 执行动作:
function activateHit(hit) { | |
var action = hit.object.userData.action | |
if(action === 'play') togglePlay() | |
else if(action === 'screen') setDialogOpen(true) | |
else if(action === 'open-shelf') openRecordShelf() | |
else if(action === 'choose-playlist') choosePlaylist(hit.object.userData.playlistIndex) | |
} |
进入全屏前记录页面位置:
fullscreenScrollY = window.pageYOffset || 0 | |
root.requestFullscreen() |
退出全屏后连续两次恢复,避免浏览器自己的滚动发生在第一帧之后:
requestAnimationFrame(function() { | |
requestAnimationFrame(function() { | |
window.scrollTo(0, fullscreenScrollY) | |
}) | |
}) | |
setTimeout(function() { | |
window.scrollTo(0, fullscreenScrollY) | |
}, 140) |
#第 16 步:完整释放资源
WebGL 页面最容易遗漏的是销毁。离开页面后如果动画仍在后台运行,多次进入就会产生多个渲染循环。
至少要释放:
cancelAnimationFrame(raf) | |
timers.forEach(clearTimeout) | |
listeners.forEach(function(remove) { remove() }) | |
resizeObserver.disconnect() | |
intersectionObserver.disconnect() | |
mutationObserver.disconnect() | |
audioContext.close() | |
disposeObject(scene) | |
renderer.dispose() |
同时使用 IntersectionObserver 判断模块是否接近视口:
var intersectionObserver = new IntersectionObserver(function(entries) { | |
visible = !!entries[0].isIntersecting | |
}, { rootMargin: '180px' }) |
动画循环中加入:
if(!visible) return |
这样用户滚动到页面其他位置时仍保留音乐播放,但暂停不必要的三维绘制。
#常用自定义位置
#修改主题颜色
编辑 themes/shoka/source/css/gramophone.styl 开头的四个变量:
--gramophone-primary: #b15cff; | |
--gramophone-secondary: #ff5fc8; | |
--gramophone-cyan: #27e4ff; | |
--gramophone-gold: #ffd05d; |
#修改初始观察角度
在 gramophone.js 中搜索:
var targetYaw = -0.16 | |
var targetPitch = -0.12 | |
var distance = 16.4 |
targetYaw:左右角度;targetPitch:上下俯仰;distance:摄像机距离。
#修改一次展示的唱片数量
搜索:
var galleryVisibleCount = 5 |
屏幕较窄时不建议设置太大,否则两侧唱片会超出画布。
#调整唱片架、喷泉和花盆位置
这些装饰都属于 machine ,修改各自的三维坐标即可:
// 左侧唱片架:减小 Z 值会向底座后方移动 | |
recordShelf.position.set(-2.3, 0.89, -1.52) | |
// 喷泉:增大 X 值会向右移动 | |
fountain.position.set(1.66, 0.88, 1.94) | |
// 右后侧花盆:减小 X 值会向左收回底座 | |
makePlant([2.28, 0.87, 0.32], true) |
每次只调整一个方向,每次变化建议控制在 0.1~0.25 。修改后从默认视角、旋转视角和移动端分别检查,确保物体没有互相穿透,也没有越过底座边缘。
#调整喷泉水流
搜索 fountainFlowLines 。当前水流使用稳定的伪随机值生成不同间距、弯曲、深浅和速度,因此每次刷新造型保持一致,但视觉上不会像规则栅栏。
修改水流数量:
var upperFlowCount = 17 | |
var lowerFlowCount = 22 |
修改水幕涌动速度:
var fountainUpperSurge = Math.sin(t * 1.72) | |
var fountainLowerSurge = Math.sin(t * 1.48 + 0.8) |
#常见问题
#页面完全没有留声机
依次检查:
source/about/index.md是否写了gramophone: true;- 是否同时写了
gramophone开始标记和endgramophone结束标记; - 控制台是否提示
Cannot find module 'js-yaml'; - 页面源代码中是否存在
id="about-gramophone"; CONFIG.js.three和CONFIG.js.gramophone是否有值。
#页面有画布,但模型是空白
检查 Three.js 是否加载成功,并确认画布父元素有明确高度。还要确认 WebGL 没有被浏览器或显卡设置禁用。
#刷新正常,点击菜单进入后空白
这是 PJAX 生命周期没有连接。确认 siteRefresh() 调用了 mountAboutGramophone() 。
#多次进入页面后动画越来越快
说明旧的 requestAnimationFrame 或事件监听没有销毁。确认 pjaxReload() 调用了 destroyAboutGramophone() ,并检查 destroy() 是否释放完整。
#音乐能播放,频谱却没有真实变化
常见原因是浏览器不支持 captureStream() ,或者音源跨域策略不允许读取音频数据。应保留降级律动,不要因此阻断音乐播放。
#唱片封面一直为空
先查看隐藏播放器里是否已经显示封面。如果外部图片接口很慢,应让播放器先使用站点 CDN 缓存;同时保留本地 music-cover-default.svg 兜底。
#播放面板没有 “当前歌单曲目”
修改自定义标签后要重新生成页面。开发服务器可能缓存旧标签结果,建议停止服务后执行:
hexo clean | |
hexo generate --bail | |
hexo server |
#唱片架展开后不能再次点击收回
这是因为展开动画隐藏了架内唱片,而木质架体没有注册到 interactiveTargets 。给底板、背板和立柱设置 open-shelf ,并增加透明的 shelfHitArea 。不要只把点击事件绑定在会消失的小唱片上。
#换片过程中唱片像缺了一角
先确认唱片和封面材质使用 THREE.DoubleSide 。然后检查换片中点前后的坐标、旋转和缩放是否完全连续;同一个进度点不能突然从大唱片跳成小唱片。动画结束时还要恢复 position 、 rotation 和 scale 。
#退出全屏后跳到页面顶部
进入全屏前保存 window.pageYOffset ,退出后在两个动画帧以及短延时后恢复一次。只恢复一次通常会被浏览器自己的全屏收尾滚动覆盖。
#手机端模型被裁切
优先调整摄像机距离和容器高度,不要直接缩放整个网页。画布应保持 width: 100% ,并在 ResizeObserver 中重新计算渲染尺寸和相机宽高比。
#测试
完成修改后执行:
hexo clean | |
hexo generate --bail | |
hexo server |
建议按下面顺序验收:
- 直接打开关于页面,留声机可以正常显示;
- 从首页通过菜单进入关于页面,模型仍能初始化;
- 播放时唱片、摇把、唱针、喇叭声浪和频谱都有对应变化;
- 暂停后唱片、摇把和声浪停止,唱针归位;
- 点击唱片架可展开歌单,不选择唱片时再次点击架体可以收回;
- 换片过程中唱片会平滑改变大小和方向,没有缺角或突然跳动;
- 点击正面屏幕可打开面板,并能选择歌单中的具体歌曲;
- 打开面板时,曲目列表会自动滚动到当前播放歌曲;
- 单曲循环、列表播放和随机播放都能切换,并与原播放器保持一致;
- 封面、歌词、进度、音量、上一首和下一首同步正确;
- 进入再退出全屏,页面仍停在原来的位置;
- 连续进入、离开页面三次,控制台没有 WebGL 或重复监听报错;
- 打开其他文章,网络面板中没有加载 Three.js、
gramophone.js和gramophone.css; - 手机端画布、弹窗和曲目列表没有横向溢出;
- 执行
hexo generate --bail能完整生成站点。
#总结
这个留声机不是一个独立的新播放器,而是 Shoka 原音乐系统的三维交互外壳。这样的拆分带来三个好处:
- 歌单解析、音源切换和歌词继续复用主题成熟逻辑;
- Three.js 只负责视觉表现,后续可以单独调整模型而不破坏音乐功能;
- 通过页面开关、PJAX 生命周期和资源销毁,复杂三维效果不会拖慢其他页面。
以后想继续扩展时,可以增加唱片收藏信息、不同材质主题或更多环境动画,但仍建议保持 “音乐状态唯一、三维表现可销毁、资源按页加载” 这三个原则。
#涉及改动的代码
代码卡片按属性名、函数名或带内容校验的具名片段定位,并显示当前源码行号。片段前后插行可自动重新定位;片段本身改变或位置不唯一时会停止生成,核对教程后再更新摘录,避免误引其他功能。
下面仅展示这项功能涉及的改动位置,不展示站点配置文件中的其他私人内容。行号以当前项目为准,后续修改后可能发生偏移,定位时优先搜索函数名或选择器。
three.module.min.js 和 three.core.min.js 由 lib/build-three.cjs 在生成站点时从 npm 包中的未压缩 ESM 制作,不要手工编辑,也不在文章中展开生成结果。构建辅助文件的完整内容见上面的第 1 步;下方资源代码卡片展示它在生成器中的实际调用。 public 中的文件同样由 Hexo 自动生成。
#依赖、页面开关与资源地址
package.json改动位置:第 82 行
"three": "0.186.0", |
themes/shoka/_config.yml 只展示本教程新增的 Three.js 地址,不公开其余站点配置:
以下是应合并到 themes/shoka/_config.yml 的脱敏示例,不再按行号读取实际站点配置:
source/code-examples/shoka-tutorial/gramophone.yml完整文件
# 修改位置:themes/shoka/_config.yml 的 vendors.js 中 | |
# 仅为公开示例,不读取实际站点配置。 | |
vendors: | |
js: | |
three: npm/three@0.186.0/build/three.module.js | |
themes/shoka/layout/_partials/layout.njk改动位置:第 275 行
{%- if page.gramophone %}gramophone: true,{%- endif %} |
themes/shoka/scripts/generaters/script.js改动位置:第 5、47、49、60、72、74、180–183 行
const { buildThreeAssets } = require('../../../../lib/build-three.cjs'); | |
⋯ 未改动代码已省略 ⋯ | |
three: theme.vendors.js.three, | |
⋯ 未改动代码已省略 ⋯ | |
gramophone: theme.js + '/gramophone.js', | |
⋯ 未改动代码已省略 ⋯ | |
gramophone: theme.css + '/gramophone.css', | |
⋯ 未改动代码已省略 ⋯ | |
three: config.root + theme.js + '/three.module.min.js', | |
⋯ 未改动代码已省略 ⋯ | |
gramophone: config.root + theme.js + '/gramophone.js', | |
⋯ 未改动代码已省略 ⋯ | |
const threeAssets = buildThreeAssets(); | |
Object.entries(threeAssets).forEach(([filename, data]) => { | |
outputs.push({ path: theme.js + '/' + filename, data }); | |
}); |
#自定义标签与页面用法
themes/shoka/scripts/tags/gramophone.js改动位置:第 1–84 行
/* global hexo */ | |
'use strict' | |
const yaml = require('js-yaml') | |
const escapeHTML = value => String(value == null ? '' : value) | |
.replace(/&/g, '&') | |
.replace(/</g, '<') | |
.replace(/>/g, '>') | |
.replace(/"/g, '"') | |
.replace(/'/g, ''') | |
function renderGramophone(args, content) { | |
const playlists = yaml.load(content) | |
if(!Array.isArray(playlists) || !playlists.length) | |
throw new TypeError('gramophone 标签内需要填写至少一个歌单') | |
const playlistNames = playlists.map((playlist, index) => | |
playlist && playlist.title ? playlist.title : `歌单 ${index + 1}`) | |
const source = escapeHTML(JSON.stringify(playlists)) | |
const names = escapeHTML(JSON.stringify(playlistNames)) | |
return `<section id="about-gramophone" class="about-gramophone" data-gramophone-playlists="${names}" aria-label="可交互的三维黑胶留声机"> | |
<div class="about-gramophone__visual"> | |
<canvas id="about-gramophone-canvas" aria-label="三维黑胶留声机,拖动可以旋转视角,点击唱片可以播放或暂停"></canvas> | |
<button class="about-gramophone__fullscreen" type="button" data-gramophone-fullscreen aria-label="全屏欣赏留声机"><i class="ic i-expand"></i></button> | |
<div class="gramophone-tools" aria-label="留声机快捷操作"> | |
<button type="button" data-gramophone-reset title="恢复视角">↺<span>复位</span></button> | |
<button type="button" data-gramophone-interact aria-pressed="false">◎<span>操作模型</span></button> | |
<button type="button" data-gramophone-shelf aria-expanded="false">▤<span>挑选唱片</span></button> | |
<button type="button" data-gramophone-console>♫<span>播放室</span></button> | |
</div> | |
<div class="gramophone-hotspot" data-gramophone-hotspot hidden></div> | |
<div class="gramophone-shelf-status" data-gramophone-shelf-status hidden role="status"></div> | |
<div class="gramophone-shelf-access" data-gramophone-shelf-access hidden aria-label="选择歌单唱片"></div> | |
<div class="about-gramophone__loading" data-gramophone-loading role="status"> | |
<span></span><strong>正在为留声机上弦</strong><small>唱片与曲库准备中</small> | |
</div> | |
</div> | |
<button class="about-gramophone__dialog-backdrop" type="button" data-gramophone-close aria-label="关闭播放面板"></button> | |
<div class="about-gramophone__console" data-gramophone-dialog role="dialog" aria-modal="false" aria-hidden="true" aria-label="留声机播放控制面板"> | |
<div class="about-gramophone__dialog-head"> | |
<span><i class="ic i-music"></i> 黑胶播放室</span> | |
<button type="button" data-gramophone-close aria-label="关闭播放面板"><i class="ic i-times"></i></button> | |
</div> | |
<div class="about-gramophone__cover"><img data-gramophone-cover src="/images/music-cover-default.svg" alt="当前歌曲封面" referrerpolicy="no-referrer"></div> | |
<div class="about-gramophone__track"> | |
<span>NOW SPINNING</span> | |
<strong data-gramophone-title>等待唱片</strong> | |
<small data-gramophone-artist>加载歌单后即可开始播放</small> | |
<em data-gramophone-playlist-name>默认歌单</em> | |
</div> | |
<div class="about-gramophone__lyrics" aria-live="polite"> | |
<span>实时歌词 <button type="button" data-gramophone-lyrics-toggle aria-expanded="false">展开歌词</button></span> | |
<strong data-gramophone-lyric-current>等待音乐响起</strong> | |
<small data-gramophone-lyric-next>歌词将在这里随播放进度更新</small> | |
<div class="gramophone-lyrics-reader" data-gramophone-lyrics-reader hidden aria-label="点击歌词跳转播放"></div> | |
</div> | |
<div class="about-gramophone__queue"> | |
<div class="about-gramophone__queue-head"> | |
<span><i class="ic i-music"></i> 当前歌单曲目</span> | |
<small data-gramophone-track-count>正在读取</small> | |
</div> | |
<div class="gramophone-queue-tools"><input type="search" data-gramophone-search placeholder="搜索歌名或歌手" aria-label="搜索歌名或歌手"><button type="button" data-gramophone-current-track>回到正在播放</button></div> | |
<ol data-gramophone-track-list aria-label="当前歌单曲目列表"></ol> | |
</div> | |
<div class="about-gramophone__timeline"> | |
<time data-gramophone-current>00:00</time> | |
<input data-gramophone-progress type="range" min="0" max="1000" value="0" aria-label="播放进度"> | |
<time data-gramophone-duration>00:00</time> | |
</div> | |
<div class="about-gramophone__modes" role="group" aria-label="播放模式"> | |
<span>播放模式</span> | |
<button type="button" data-gramophone-mode="loop" aria-label="单曲循环"><i class="ic i-loop"></i><em>单曲循环</em></button> | |
<button type="button" data-gramophone-mode="order" aria-label="列表播放"><i class="ic i-list-ol"></i><em>列表播放</em></button> | |
<button type="button" data-gramophone-mode="random" aria-label="随机播放"><i class="ic i-random"></i><em>随机播放</em></button> | |
</div> | |
<div class="about-gramophone__controls"> | |
<button type="button" data-gramophone-prev aria-label="上一首"><i class="ic i-backward"></i></button> | |
<button type="button" data-gramophone-stop aria-label="停止"><span aria-hidden="true">■</span></button> | |
<button class="is-primary" type="button" data-gramophone-play aria-label="播放"><i class="ic i-play"></i></button> | |
<button type="button" data-gramophone-next aria-label="下一首"><i class="ic i-forward"></i></button> |
source/about/index.md改动位置:第 1–41 行
--- | |
title: 关于博主 | |
date: 2021-07-08 22:51:13 | |
type: "about" | |
fancybox: false | |
gramophone: true # 仅在本页加载 Three.js 留声机、唱片架及播放面板 | |
waline: | |
placeholder: "1. 这里也可以作为留言板!\n2. 欢迎留言一起学习交流!🍻" | |
copyright: true | |
nocopy: true | |
--- | |
# 听听音乐吧 | |
{% gramophone %} | |
- title: MyLove | |
list: | |
- https://music.163.com/playlist?id=5297225655 | |
- title: 轻音乐1 | |
list: | |
- https://music.163.com/playlist?id=100106023 | |
- title: 轻音乐2 | |
list: | |
- https://music.163.com/playlist?id=106541570 | |
- title: 轻音乐3 | |
list: | |
- https://music.163.com/playlist?id=964230606 | |
- title: 轻音乐4 | |
list: | |
- https://music.163.com/playlist?id=368529707 | |
- title: Ori | |
list: | |
- https://music.163.com/playlist?id=118595089 | |
- title: 电音 | |
list: | |
- https://music.163.com/playlist?id=2219988682 | |
- title: Minecraft C418 | |
list: | |
- https://music.163.com/playlist?id=164521381 | |
{% endgramophone %} |
#PJAX 按需加载与销毁
themes/shoka/source/js/_app/pjax.js改动位置:第 3、13–17、19–35、185、257 行
var gramophoneLoadId = 0 | |
⋯ 未改动代码已省略 ⋯ | |
const destroyAboutGramophone = function() { | |
gramophoneLoadId++ | |
if(window.AboutGramophone && typeof window.AboutGramophone.destroy === 'function') | |
window.AboutGramophone.destroy() | |
} | |
⋯ 未改动代码已省略 ⋯ | |
const mountAboutGramophone = function() { | |
if(!LOCAL.gramophone || !$('#about-gramophone')) | |
return | |
var currentLoadId = ++gramophoneLoadId | |
vendorCss('gramophone') | |
vendorJs('gramophone', function() { | |
if(currentLoadId !== gramophoneLoadId || !LOCAL.gramophone || !$('#about-gramophone')) | |
return | |
vendorModule('three', function(THREE) { | |
if(currentLoadId !== gramophoneLoadId || !LOCAL.gramophone || !$('#about-gramophone')) | |
return | |
if(window.AboutGramophone && typeof window.AboutGramophone.mount === 'function') | |
window.AboutGramophone.mount({ THREE: THREE }) | |
}) | |
}, window.AboutGramophone) | |
} | |
⋯ 未改动代码已省略 ⋯ | |
destroyAboutGramophone() | |
⋯ 未改动代码已省略 ⋯ | |
mountAboutGramophone() |
#Three.js 场景与音乐交互
主脚本较长,下面按功能展示关键改动,未列出的模型顶点和重复几何体参数可在实际文件中继续查看:
themes/shoka/source/js/gramophone.js改动位置:第 1–44、97–148、166–196、207–239、394–483、539–618、739–825、855–945、958–1108、1236–1296、1332–1415、1423–1463、1488–1528、1598–1643、1659–1708、1768–1843、1953–2082、2083–2194、2298–2332 行
(function() { | |
'use strict' | |
var active = null | |
var motionMediaQuery = null | |
function prefersReducedMotion() { | |
if(window.ShokaMotion && typeof window.ShokaMotion.reduced === 'function') return window.ShokaMotion.reduced() | |
// The writing-room iframe can run without the site's bootstrap. A cached | |
// MediaQueryList still reflects system changes on every rendered frame. | |
if(!motionMediaQuery && typeof window.matchMedia === 'function') motionMediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') | |
return document.documentElement.getAttribute('data-motion') === 'reduce' || !!(motionMediaQuery && motionMediaQuery.matches) | |
} | |
function clamp(value, min, max) { | |
return Math.max(min, Math.min(max, value)) | |
} | |
function smootherStep(value) { | |
value = clamp(value, 0, 1) | |
return value * value * value * (value * (value * 6 - 15) + 10) | |
} | |
function timeText(value) { | |
if(!isFinite(value) || value < 0) value = 0 | |
var minutes = Math.floor(value / 60) | |
var seconds = Math.floor(value % 60) | |
return String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0') | |
} | |
function disposeObject(root) { | |
root.traverse(function(object) { | |
if(object.geometry) object.geometry.dispose() | |
if(object.material) { | |
var materials = Array.isArray(object.material) ? object.material : [object.material] | |
materials.forEach(function(material) { material.dispose() }) | |
} | |
}) | |
} | |
function mount(options) { | |
destroy() | |
var THREE = options && options.THREE | |
⋯ 未改动代码已省略 ⋯ | |
} catch(error) { | |
playlistNames = [] | |
} | |
if(!playlistNames.length) playlistNames = ['默认歌单'] | |
var listeners = [] | |
var timers = [] | |
var raf = 0 | |
var disposed = false | |
var visible = true | |
var dragging = false | |
var moved = false | |
var downX = 0 | |
var downY = 0 | |
var lastX = 0 | |
var lastY = 0 | |
var targetYaw = -0.16 | |
var targetPitch = -0.12 | |
var distance = 17.4 | |
var targetDistance = distance | |
var fallbackFullscreen = false | |
var fullscreenActive = false | |
var fullscreenScrollY = 0 | |
var audioContext = null | |
var analyser = null | |
var analyserSource = null | |
var frequencyData = null | |
var reactiveEnergy = 0 | |
var recordCoverTexture = null | |
var recordCoverRequest = 0 | |
var recordSwapStarted = 0 | |
var recordSwapMidpoint = false | |
var recordSwapAction = null | |
var selectedPlaylistIndex = 0 | |
var activePlaylistIndex = 0 | |
var shelfSelectionTouched = false | |
var displayTitle = '等待唱片' | |
var displayArtist = '点击侧边唱片放入唱盘' | |
var ledTexture = null | |
var shelfTexture = null | |
var tonearmPlayingState = null | |
var tonearmTransitionStarted = 0 | |
var tonearmTransitionFrom = 1.42 | |
var tonearmPlaneTilt = 0.018 | |
var interactiveTargets = [] | |
var panelIconTextures = [] | |
var playlistCardTextures = [] | |
var panelPlayIcon = null | |
var panelPlayIconContext = null | |
var shelfOpen = false | |
var shelfExpansion = 0 | |
var hoveredPlaylistIndex = -1 | |
var playlistCards = [] | |
⋯ 未改动代码已省略 ⋯ | |
function resetView() { | |
targetYaw = -0.16; targetPitch = -0.12; targetDistance = 17.4 | |
shelfView = null | |
} | |
function setModelInteraction(enabled) { | |
modelInteraction = enabled | |
root.classList.toggle('is-model-interactive', enabled) | |
var button = root.querySelector('[data-gramophone-interact]') | |
if(button) button.setAttribute('aria-pressed', String(enabled)) | |
} | |
function playlistAvailability(index) { | |
var tab = playlistTabs()[index] | |
if(!tab) return '曲库加载中' | |
var tracks = tab.querySelectorAll('li') | |
return tracks.length ? (playlistTrackCount(index) ? playlistTrackCount(index) + ' 首可播放' : '暂无可播放歌曲') : '暂无曲目,请稍后重试' | |
} | |
function listen(target, type, handler, opts) { | |
if(!target) return | |
target.addEventListener(type, handler, opts) | |
listeners.push(function() { target.removeEventListener(type, handler, opts) }) | |
} | |
function later(fn, delay) { | |
var id = window.setTimeout(fn, delay) | |
timers.push(id) | |
return id | |
} | |
⋯ 未改动代码已省略 ⋯ | |
renderer.shadowMap.type = THREE.PCFShadowMap | |
if('outputColorSpace' in renderer && THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace | |
if('toneMapping' in renderer && THREE.ACESFilmicToneMapping) renderer.toneMapping = THREE.ACESFilmicToneMapping | |
renderer.toneMappingExposure = 1.16 | |
// Soft environment lighting plus coloured practical lights keeps the toon | |
// palette readable without pushing it back toward photorealistic metal. | |
scene.add(new THREE.AmbientLight(0xfffaff, 0.58)) | |
scene.add(new THREE.HemisphereLight(0xeaf3ff, 0x765582, 0.9)) | |
var key = new THREE.DirectionalLight(0xfff7fb, 1.75) | |
key.position.set(4.8, 9, 7) | |
key.castShadow = true | |
key.shadow.mapSize.set(1024, 1024) | |
scene.add(key) | |
var violet = new THREE.PointLight(0xc17aff, 5.5, 16, 1.65) | |
violet.position.set(-4.5, 3.6, 3.2) | |
scene.add(violet) | |
var cyan = new THREE.PointLight(0x54efff, 4.5, 15, 1.8) | |
cyan.position.set(4.8, 2.4, -3.4) | |
scene.add(cyan) | |
var rose = new THREE.PointLight(0xff79c5, 4.5, 12, 1.9) | |
rose.position.set(0, 5.6, 1.5) | |
scene.add(rose) | |
var machine = new THREE.Group() | |
machine.position.set(-0.18, -1.72, 0) | |
scene.add(machine) | |
// Video-inspired pastel palette: dark square music box, lavender deck, | |
// pink flower horn and a pale hand-drawn silhouette. | |
var wood = new THREE.MeshToonMaterial({ color: 0x766188, emissive: 0x25182d, emissiveIntensity: 0.25 }) | |
var woodDark = new THREE.MeshToonMaterial({ color: 0x59466b, emissive: 0x160c20, emissiveIntensity: 0.24 }) | |
var woodEdge = new THREE.MeshToonMaterial({ color: 0x9b7fb0, emissive: 0x2b1835, emissiveIntensity: 0.24 }) | |
⋯ 未改动代码已省略 ⋯ | |
ledTexture.needsUpdate = true | |
} | |
var panelButtons = {} | |
function makePanelButton(x, action, color) { | |
var buttonMaterial = new THREE.MeshStandardMaterial({ color: color, emissive: color, emissiveIntensity: 0.18, roughness: 0.32, metalness: 0.58 }) | |
var button = mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.12, 24), buttonMaterial, [x, 0.43, 3.32], [Math.PI / 2, 0, 0]) | |
button.userData.action = action | |
interactiveTargets.push(button) | |
mesh(new THREE.TorusGeometry(0.22, 0.025, 7, 28), brassLight, [x, 0.43, 3.39]) | |
panelButtons[action] = button | |
return button | |
} | |
makePanelButton(1.3, 'prev', 0x75dfee) | |
makePanelButton(1.78, 'play', 0xd776ff) | |
makePanelButton(2.26, 'next', 0xff78b9) | |
function drawPanelIcon(context, kind, playing) { | |
context.clearRect(0, 0, 128, 128) | |
context.fillStyle = '#fff9ff' | |
context.strokeStyle = '#fff9ff' | |
context.lineWidth = 13 | |
context.lineCap = 'round' | |
context.lineJoin = 'round' | |
context.shadowColor = kind === 'play' ? '#fff2a4' : '#bffaff' | |
context.shadowBlur = 12 | |
if(kind === 'play' && playing) { | |
context.fillRect(38, 30, 17, 68) | |
context.fillRect(73, 30, 17, 68) | |
} else if(kind === 'play') { | |
context.beginPath() | |
context.moveTo(43, 27) | |
context.lineTo(96, 64) | |
context.lineTo(43, 101) | |
context.closePath() | |
context.fill() | |
} else { | |
var backward = kind === 'prev' | |
context.fillRect(backward ? 29 : 87, 29, 12, 70) | |
context.beginPath() | |
context.moveTo(backward ? 91 : 37, 28) | |
context.lineTo(backward ? 43 : 85, 64) | |
context.lineTo(backward ? 91 : 37, 100) | |
context.closePath() | |
context.fill() | |
} | |
} | |
function makePanelIcon(x, kind) { | |
var iconCanvas = document.createElement('canvas') | |
iconCanvas.width = 128 | |
iconCanvas.height = 128 | |
var context = iconCanvas.getContext('2d') | |
drawPanelIcon(context, kind, false) | |
var texture = new THREE.CanvasTexture(iconCanvas) | |
texture.needsUpdate = true | |
panelIconTextures.push(texture) | |
var icon = mesh(new THREE.PlaneGeometry(0.2, 0.2), new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false }), [x, 0.43, 3.405]) | |
icon.castShadow = false | |
if(kind === 'play') { | |
panelPlayIcon = texture | |
panelPlayIconContext = context | |
} | |
} | |
makePanelIcon(1.3, 'prev') | |
makePanelIcon(1.78, 'play') | |
makePanelIcon(2.26, 'next') | |
// Side winding crank: the entire group rotates around its axle only while | |
// the real audio element is playing. | |
var crankGroup = new THREE.Group() | |
crankGroup.position.set(-3.18, 0.48, 0.55) | |
machine.add(crankGroup) | |
mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.52, 12), brassLight, [-0.2, 0, 0], [0, 0, Math.PI / 2], crankGroup) | |
mesh(new THREE.BoxGeometry(0.09, 0.84, 0.1), brass, [-0.44, -0.36, 0], null, crankGroup) | |
mesh(new THREE.CylinderGeometry(0.105, 0.125, 0.58, 14), woodEdge, [-0.72, -0.75, 0], [0, 0, Math.PI / 2], crankGroup) | |
var platter = new THREE.Group() | |
platter.position.set(-0.42, 0.91, 0.28) | |
machine.add(platter) | |
outlined(mesh(new THREE.CylinderGeometry(1.66, 1.71, 0.095, 72), woodDark, [0, 0, 0], null, platter), 1.011) | |
var vinylGroup = new THREE.Group() | |
platter.add(vinylGroup) | |
var record = mesh(new THREE.CylinderGeometry(1.54, 1.54, 0.045, 96), vinylMaterial, [0, 0.072, 0], null, vinylGroup) | |
var recordGroup = new THREE.Group() | |
recordGroup.position.y = 0.102 | |
vinylGroup.add(recordGroup) | |
var grooveMaterials = [ | |
new THREE.MeshBasicMaterial({ color: 0xbab0ca, transparent: true, opacity: 0.23, depthWrite: false }), | |
new THREE.MeshBasicMaterial({ color: 0x665c73, transparent: true, opacity: 0.3, depthWrite: false }) | |
⋯ 未改动代码已省略 ⋯ | |
// Oversized flower horn lifted well above the deck so it frames, rather than | |
// covers, the record and tonearm. | |
var horn = new THREE.Group() | |
horn.position.set(1.68, 4.18, -1.62) | |
horn.rotation.x = 0.98 | |
horn.rotation.z = 0.52 | |
machine.add(horn) | |
outlined(mesh(new THREE.CylinderGeometry(0.205, 0.145, 0.5, 24), brassLight, [0, 0.2, 0], null, horn), 1.025) | |
mesh(new THREE.TorusGeometry(0.205, 0.045, 9, 32), hornDark, [0, 0.43, 0], [Math.PI / 2, 0, 0], horn) | |
// The pipe enters the horn along its own axis. Slim overlapping collars | |
// replace the old ball joint so throat, bend and bell read as one assembly. | |
mesh(new THREE.CylinderGeometry(0.19, 0.155, 0.32, 28), hornDark, [0, -0.1, 0], null, horn) | |
mesh(new THREE.TorusGeometry(0.176, 0.024, 9, 36), flowerPink, [0, 0.055, 0], [Math.PI / 2, 0, 0], horn) | |
var hornLength = 3.25 | |
var hornPetals = 12 | |
var hornRadius = function(progress, angle) { | |
var flare = 0.13 + progress * 0.08 + Math.pow(progress, 2.75) * 1.83 | |
var flower = Math.pow(clamp((progress - 0.5) / 0.5, 0, 1), 1.45) | |
return flare * (1 + Math.cos(angle * hornPetals) * 0.078 * flower) | |
} | |
var hornPositions = [] | |
var hornUvs = [] | |
var hornIndices = [] | |
var hornAxial = 30 | |
var hornRadial = 96 | |
for(var hy = 0; hy <= hornAxial; hy++) { | |
var hp = hy / hornAxial | |
for(var hs = 0; hs <= hornRadial; hs++) { | |
var ha = hs / hornRadial * Math.PI * 2 | |
var hr = hornRadius(hp, ha) | |
hornPositions.push(Math.cos(ha) * hr, hp * hornLength, Math.sin(ha) * hr) | |
hornUvs.push(hs / hornRadial, hp) | |
} | |
} | |
for(var hi = 0; hi < hornAxial; hi++) { | |
for(var hj = 0; hj < hornRadial; hj++) { | |
var row = hornRadial + 1 | |
var a = hi * row + hj | |
var b = a + row | |
hornIndices.push(a, b, a + 1, b, b + 1, a + 1) | |
} | |
} | |
var hornGeometry = new THREE.BufferGeometry() | |
hornGeometry.setAttribute('position', new THREE.Float32BufferAttribute(hornPositions, 3)) | |
hornGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(hornUvs, 2)) | |
hornGeometry.setIndex(hornIndices) | |
hornGeometry.computeVertexNormals() | |
var hornColors = [] | |
for(var colorIndex = 0; colorIndex < hornPositions.length; colorIndex += 3) { | |
var depth = clamp(hornPositions[colorIndex + 1] / hornLength, 0, 1) | |
var hornColor = new THREE.Color(0x4c215e).lerp(new THREE.Color(0xffd4e9), Math.pow(depth, 0.68)) | |
hornColors.push(hornColor.r, hornColor.g, hornColor.b) | |
} | |
hornGeometry.setAttribute('color', new THREE.Float32BufferAttribute(hornColors, 3)) | |
brassGlow.vertexColors = true; brassGlow.color.setHex(0xffffff) | |
// The flower rim already provides a silhouette. A scaled back-face outline | |
// here would cover the entire bell interior when viewed from the front. | |
mesh(hornGeometry, brassGlow, null, null, horn) | |
// Raised radial ribs and the scalloped flower rim make the bell read as a horn. | |
for(var ribIndex = 0; ribIndex < hornPetals; ribIndex++) { | |
var ribAngle = ribIndex / hornPetals * Math.PI * 2 | |
var ribPoints = [] | |
for(var ribStep = 1; ribStep <= 14; ribStep++) { | |
var ribProgress = ribStep / 14 | |
var ribRadius = hornRadius(ribProgress, ribAngle) + 0.022 | |
ribPoints.push(new THREE.Vector3(Math.cos(ribAngle) * ribRadius, ribProgress * hornLength, Math.sin(ribAngle) * ribRadius)) | |
} | |
mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(ribPoints), 30, 0.024 + ribIndex % 2 * 0.006, 7, false), hornDark, null, null, horn) | |
} | |
var bandPoints = [] | |
for(var bandStep = 0; bandStep < hornRadial; bandStep++) { | |
var bandAngle = bandStep / hornRadial * Math.PI * 2 | |
var bandRadius = hornRadius(0.79, bandAngle) + 0.018 | |
bandPoints.push(new THREE.Vector3(Math.cos(bandAngle) * bandRadius, hornLength * 0.79, Math.sin(bandAngle) * bandRadius)) | |
} | |
mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(bandPoints, true), 128, 0.026, 8, true), hornDark, null, null, horn) | |
var rimPoints = [] | |
for(var rimStep = 0; rimStep < hornRadial; rimStep++) { | |
⋯ 未改动代码已省略 ⋯ | |
leafShape.moveTo(0, -0.26) | |
leafShape.bezierCurveTo(-0.16, -0.18, -0.2, 0.09, 0, 0.28) | |
leafShape.bezierCurveTo(0.2, 0.09, 0.16, -0.18, 0, -0.26) | |
var leafGeometry = new THREE.ShapeGeometry(leafShape, 16) | |
var leafPositions = leafGeometry.attributes.position | |
var leafUvs = leafGeometry.attributes.uv | |
for(var leafVertex = 0; leafVertex < leafPositions.count; leafVertex++) { | |
var leafX = leafPositions.getX(leafVertex) | |
var leafY = leafPositions.getY(leafVertex) | |
leafPositions.setZ(leafVertex, (1 - Math.min(1, Math.abs(leafX) / 0.2)) * 0.043 + Math.sin((leafY + 0.26) * 5) * 0.03 + leafX * leafY * 0.35) | |
leafUvs.setXY(leafVertex, (leafX + 0.2) / 0.4, (leafY + 0.26) / 0.54) | |
} | |
leafPositions.needsUpdate = true | |
leafUvs.needsUpdate = true | |
leafGeometry.computeVertexNormals() | |
function makeLeaf(parent, position, scale, rotation, material) { | |
var leafGroup = new THREE.Group() | |
leafGroup.position.set(position[0], position[1], position[2]) | |
if(rotation) leafGroup.rotation.set(rotation[0], rotation[1], rotation[2]) | |
parent.add(leafGroup) | |
var texturedMaterial = material === leafBlue ? blueLeafMaterial : mintLeafMaterial | |
var leaf = mesh(leafGeometry, texturedMaterial, [0, 0, 0], null, leafGroup) | |
leaf.scale.set(scale[0], scale[1], Math.max(0.7, scale[2] || 1)) | |
var stemCurve = new THREE.QuadraticBezierCurve3( | |
new THREE.Vector3(0, -0.31, 0.02), | |
new THREE.Vector3(-0.01, 0, 0.055), | |
new THREE.Vector3(0, 0.265, 0.03) | |
) | |
var veinMesh = mesh(new THREE.TubeGeometry(stemCurve, 16, 0.008, 5, false), brassLight, null, null, leafGroup) | |
veinMesh.scale.set(scale[0], scale[1], 1) | |
veinMesh.castShadow = false | |
return leafGroup | |
} | |
function makeFlower(parent, position, material, scale) { | |
var blossom = new THREE.Group() | |
blossom.position.set(position[0], position[1], position[2]) | |
parent.add(blossom) | |
for(var petal = 0; petal < 6; petal++) { | |
var angle = petal / 6 * Math.PI * 2 | |
var piece = mesh(new THREE.SphereGeometry(0.12, 10, 6), material, [Math.cos(angle) * 0.18, Math.sin(angle) * 0.18, 0], null, blossom) | |
piece.scale.set(0.72, 1.3, 0.4) | |
piece.rotation.z = angle - Math.PI / 2 | |
var innerPetal = mesh(new THREE.SphereGeometry(0.075, 9, 5), material, [Math.cos(angle + 0.18) * 0.1, Math.sin(angle + 0.18) * 0.1, 0.045], null, blossom) | |
innerPetal.scale.set(0.68, 1.12, 0.36) | |
innerPetal.rotation.z = angle - Math.PI / 2 | |
} | |
mesh(new THREE.SphereGeometry(0.092, 12, 8), flowerYellow, [0, 0, 0.08], null, blossom) | |
blossom.scale.setScalar(scale || 1) | |
return blossom | |
} | |
function makeGrassCluster(position, material, size) { | |
var grass = new THREE.Group() | |
grass.position.set(position[0], position[1], position[2]) | |
machine.add(grass) | |
for(var blade = 0; blade < 9; blade++) { | |
var angle = blade / 9 * Math.PI * 2 | |
var height = (0.28 + (blade % 4) * 0.065) * (size || 1) | |
var lean = 0.12 + (blade % 3) * 0.045 | |
var curve = new THREE.QuadraticBezierCurve3( | |
new THREE.Vector3(0, 0, 0), | |
new THREE.Vector3(Math.cos(angle) * lean * 0.35, height * 0.62, Math.sin(angle) * lean * 0.35), | |
new THREE.Vector3(Math.cos(angle) * lean, height, Math.sin(angle) * lean) | |
) | |
mesh(new THREE.TubeGeometry(curve, 10, 0.012, 5, false), blade % 2 ? material : mint, null, null, grass) | |
} | |
return grass | |
} | |
function makePlant(position, tall) { | |
var plant = new THREE.Group() | |
plant.position.set(position[0], position[1], position[2]) | |
machine.add(plant) | |
outlined(mesh(new THREE.CylinderGeometry(0.29, 0.37, 0.42, 10), flowerPink, [0, 0.21, 0], null, plant), 1.03) | |
mesh(new THREE.TorusGeometry(0.31, 0.045, 6, 24), brassLight, [0, 0.41, 0], [Math.PI / 2, 0, 0], plant) | |
mesh(new THREE.CylinderGeometry(0.275, 0.275, 0.035, 20), soil, [0, 0.415, 0], null, plant) | |
mesh(new THREE.TorusGeometry(0.25, 0.014, 5, 20), woodEdge, [0, 0.435, 0], [Math.PI / 2, 0, 0], plant) | |
var stemHeight = tall ? 1.36 : 0.82 | |
;[-0.21, -0.08, 0.06, 0.23].forEach(function(x, index) { | |
var height = stemHeight * (0.64 + index * 0.075 + (index % 2) * 0.08) | |
var z = (index % 3 - 1) * 0.08 | |
var stemBaseY = 0.41 | |
mesh(new THREE.CylinderGeometry(0.018, 0.027, height, 8), mint, [x, stemBaseY + height / 2, z], [0, 0, x * -0.42], plant) | |
// Four smaller leaves alternate along each stem. The side changes for | |
// neighbouring stems as well, creating a natural staggered bouquet. | |
⋯ 未改动代码已省略 ⋯ | |
// visible while preserving the asymmetrical garden composition. | |
makePlant([2.28, 0.87, 0.32], true) | |
makePlant([-2.35, 0.87, 2.0], false) | |
makeGrassCluster([0.92, 0.88, 2.54], leafBlue, 0.72) | |
makeGrassCluster([-1.72, 0.88, 2.36], mint, 0.88) | |
makeGrassCluster([1.92, 0.88, -2.28], leafBlue, 0.78) | |
;[[1.42, 0.91, 2.48], [2.67, 0.9, 0.48], [-1.48, 0.9, 2.52]].forEach(function(p, index) { | |
var pebble = mesh(new THREE.SphereGeometry(0.1 + index * 0.018, 10, 6), index % 2 ? brassLight : woodEdge, p) | |
pebble.scale.set(1.35, 0.38, 1) | |
}) | |
// A two-tier fountain sits closer to the visual centre. Two continuous, | |
// translucent curtains contain layered blue flow lines; no particle spray | |
// is used, keeping the small decorative model calm and readable. | |
var fountain = new THREE.Group() | |
fountain.position.set(1.66, 0.88, 1.94) | |
fountain.scale.setScalar(1.16) | |
machine.add(fountain) | |
outlined(mesh(new THREE.CylinderGeometry(0.61, 0.69, 0.18, 40), woodEdge, [0, 0.09, 0], null, fountain), 1.025) | |
mesh(new THREE.TorusGeometry(0.59, 0.082, 9, 52), brass, [0, 0.19, 0], [Math.PI / 2, 0, 0], fountain) | |
var fountainSurfaceMaterial = new THREE.MeshBasicMaterial({ | |
color: 0x3ddff5, | |
transparent: true, | |
opacity: 0.76, | |
depthWrite: false | |
}) | |
mesh(new THREE.CylinderGeometry(0.55, 0.55, 0.04, 52), fountainSurfaceMaterial, [0, 0.21, 0], null, fountain) | |
outlined(mesh(new THREE.CylinderGeometry(0.11, 0.18, 0.4, 28), brassLight, [0, 0.42, 0], null, fountain), 1.024) | |
outlined(mesh(new THREE.CylinderGeometry(0.35, 0.23, 0.105, 40), woodEdge, [0, 0.63, 0], null, fountain), 1.022) | |
mesh(new THREE.TorusGeometry(0.33, 0.052, 8, 44), brass, [0, 0.69, 0], [Math.PI / 2, 0, 0], fountain) | |
mesh(new THREE.CylinderGeometry(0.295, 0.295, 0.03, 44), fountainSurfaceMaterial, [0, 0.7, 0], null, fountain) | |
mesh(new THREE.CylinderGeometry(0.052, 0.082, 0.28, 20), brassLight, [0, 0.85, 0], null, fountain) | |
var fountainWaterMaterial = new THREE.MeshBasicMaterial({ | |
color: 0x147fb8, | |
transparent: true, | |
opacity: 0.58, | |
side: THREE.DoubleSide, | |
depthWrite: false | |
}) | |
var fountainSheetMaterial = new THREE.MeshBasicMaterial({ | |
color: 0x118fbd, | |
transparent: true, | |
opacity: 0.46, | |
side: THREE.DoubleSide, | |
depthWrite: false | |
}) | |
var fountainUpperWater = new THREE.Group() | |
var fountainLowerWater = new THREE.Group() | |
fountain.add(fountainUpperWater) | |
fountain.add(fountainLowerWater) | |
var fountainUpperSheet = mesh(new THREE.LatheGeometry([ | |
new THREE.Vector2(0.025, 1.25), | |
new THREE.Vector2(0.11, 1.28), | |
new THREE.Vector2(0.28, 1.17), | |
new THREE.Vector2(0.43, 0.91), | |
new THREE.Vector2(0.47, 0.72) | |
], 56), fountainSheetMaterial, null, null, fountainUpperWater) | |
var fountainUpperInner = mesh(fountainUpperSheet.geometry.clone(), new THREE.MeshBasicMaterial({ | |
color: 0x55ddf2, | |
transparent: true, | |
opacity: 0.2, | |
side: THREE.DoubleSide, | |
depthWrite: false | |
}), null, null, fountainUpperWater) | |
fountainUpperInner.scale.set(0.94, 0.985, 0.94) | |
var waterSheets = [fountainUpperSheet] | |
var fountainLowerSheet = mesh(new THREE.CylinderGeometry(0.34, 0.57, 0.43, 56, 1, true), fountainSheetMaterial.clone(), [0, 0.46, 0], null, fountainLowerWater) | |
fountainLowerSheet.material.color.setHex(0x0f7fae) | |
fountainLowerSheet.material.opacity = 0.42 | |
waterSheets.push(fountainLowerSheet) | |
waterSheets.forEach(function(sheet) { | |
sheet.userData.restPositions = new Float32Array(sheet.geometry.attributes.position.array) | |
}) | |
var fountainLowerInner = mesh(new THREE.CylinderGeometry(0.32, 0.54, 0.41, 56, 1, true), new THREE.MeshBasicMaterial({ | |
color: 0x48d5ec, | |
transparent: true, | |
opacity: 0.18, | |
side: THREE.DoubleSide, | |
depthWrite: false | |
}), [0, 0.47, 0], null, fountainLowerWater) | |
mesh(new THREE.CylinderGeometry(0.052, 0.036, 0.43, 18), fountainWaterMaterial, [0, 1.06, 0], null, fountainUpperWater) | |
var fountainFlowLines = [] | |
var fountainLineColors = [0xa2f8ff, 0x62e8fa, 0x2cc8ee, 0x158bc8, 0x0c67a8] | |
function fountainNoise(index, salt) { | |
var value = Math.sin((index + 1) * 12.9898 + (salt + 1) * 78.233) * 43758.5453 | |
return value - Math.floor(value) | |
} | |
function makeFountainFlowLine(parent, curve, index, opacity) { | |
var geometry = new THREE.BufferGeometry().setFromPoints(curve.getPoints(30)) | |
var material = new THREE.LineBasicMaterial({ | |
⋯ 未改动代码已省略 ⋯ | |
parent.add(line) | |
fountainFlowLines.push(line) | |
} | |
var upperFlowCount = 17 | |
for(var upperFlowIndex = 0; upperFlowIndex < upperFlowCount; upperFlowIndex++) { | |
var upperFlowAngle = (upperFlowIndex / upperFlowCount + (fountainNoise(upperFlowIndex, 7) - 0.5) * 0.052) * Math.PI * 2 | |
var upperFlowTurnA = upperFlowAngle + (fountainNoise(upperFlowIndex, 8) - 0.5) * 0.22 | |
var upperFlowTurnB = upperFlowAngle + (fountainNoise(upperFlowIndex, 9) - 0.5) * 0.34 | |
var upperFlowTurnC = upperFlowAngle + (fountainNoise(upperFlowIndex, 10) - 0.5) * 0.42 | |
var upperFlowRadiusA = 0.135 + fountainNoise(upperFlowIndex, 11) * 0.085 | |
var upperFlowRadiusB = 0.285 + fountainNoise(upperFlowIndex, 12) * 0.105 | |
var upperFlowRadiusC = 0.415 + fountainNoise(upperFlowIndex, 13) * 0.075 | |
makeFountainFlowLine(fountainUpperWater, new THREE.CatmullRomCurve3([ | |
new THREE.Vector3(Math.cos(upperFlowAngle) * (0.025 + fountainNoise(upperFlowIndex, 14) * 0.025), 1.285, Math.sin(upperFlowAngle) * (0.025 + fountainNoise(upperFlowIndex, 14) * 0.025)), | |
new THREE.Vector3(Math.cos(upperFlowTurnA) * upperFlowRadiusA, 1.25 + (fountainNoise(upperFlowIndex, 15) - 0.5) * 0.07, Math.sin(upperFlowTurnA) * upperFlowRadiusA), | |
new THREE.Vector3(Math.cos(upperFlowTurnB) * upperFlowRadiusB, 1.045 + (fountainNoise(upperFlowIndex, 16) - 0.5) * 0.09, Math.sin(upperFlowTurnB) * upperFlowRadiusB), | |
new THREE.Vector3(Math.cos(upperFlowTurnC) * upperFlowRadiusC, 0.735 + (fountainNoise(upperFlowIndex, 17) - 0.5) * 0.055, Math.sin(upperFlowTurnC) * upperFlowRadiusC) | |
]), upperFlowIndex, 0.34 + fountainNoise(upperFlowIndex, 18) * 0.2) | |
} | |
var lowerFlowCount = 22 | |
for(var lowerFlowIndex = 0; lowerFlowIndex < lowerFlowCount; lowerFlowIndex++) { | |
var lowerFlowAngle = (lowerFlowIndex / lowerFlowCount + (fountainNoise(lowerFlowIndex, 19) - 0.5) * 0.044) * Math.PI * 2 | |
var lowerFlowTurnA = lowerFlowAngle + (fountainNoise(lowerFlowIndex, 20) - 0.5) * 0.2 | |
var lowerFlowTurnB = lowerFlowAngle + (fountainNoise(lowerFlowIndex, 21) - 0.5) * 0.31 | |
var lowerFlowTurnC = lowerFlowAngle + (fountainNoise(lowerFlowIndex, 22) - 0.5) * 0.38 | |
var lowerFlowRadiusA = 0.315 + fountainNoise(lowerFlowIndex, 23) * 0.06 | |
var lowerFlowRadiusB = 0.39 + fountainNoise(lowerFlowIndex, 24) * 0.07 | |
var lowerFlowRadiusC = 0.475 + fountainNoise(lowerFlowIndex, 25) * 0.09 | |
var lowerFlowRadiusD = 0.535 + fountainNoise(lowerFlowIndex, 26) * 0.055 | |
makeFountainFlowLine(fountainLowerWater, new THREE.CatmullRomCurve3([ | |
new THREE.Vector3(Math.cos(lowerFlowAngle) * lowerFlowRadiusA, 0.69 + (fountainNoise(lowerFlowIndex, 27) - 0.5) * 0.035, Math.sin(lowerFlowAngle) * lowerFlowRadiusA), | |
new THREE.Vector3(Math.cos(lowerFlowTurnA) * lowerFlowRadiusB, 0.57 + (fountainNoise(lowerFlowIndex, 28) - 0.5) * 0.065, Math.sin(lowerFlowTurnA) * lowerFlowRadiusB), | |
new THREE.Vector3(Math.cos(lowerFlowTurnB) * lowerFlowRadiusC, 0.39 + (fountainNoise(lowerFlowIndex, 29) - 0.5) * 0.075, Math.sin(lowerFlowTurnB) * lowerFlowRadiusC), | |
new THREE.Vector3(Math.cos(lowerFlowTurnC) * lowerFlowRadiusD, 0.245 + (fountainNoise(lowerFlowIndex, 30) - 0.5) * 0.045, Math.sin(lowerFlowTurnC) * lowerFlowRadiusD) | |
]), lowerFlowIndex + upperFlowCount, 0.29 + fountainNoise(lowerFlowIndex, 31) * 0.22) | |
} | |
var fountainRippleMaterial = fountainWaterMaterial.clone() | |
fountainRippleMaterial.opacity = 0.52 | |
var fountainRipple = mesh(new THREE.TorusGeometry(0.34, 0.015, 6, 48), fountainRippleMaterial, [0, 0.235, 0], [Math.PI / 2, 0, 0], fountain) | |
var fountainUpperRipple = mesh(new THREE.TorusGeometry(0.15, 0.012, 6, 40), fountainRippleMaterial.clone(), [0, 0.715, 0], [Math.PI / 2, 0, 0], fountain) | |
// Two-direction MUSIC sign, built from low-poly arrow boards. | |
function arrowGeometry(width, height) { | |
var shape = new THREE.Shape() | |
shape.moveTo(-width / 2, -height / 2) | |
shape.lineTo(width * 0.22, -height / 2) | |
shape.lineTo(width / 2, 0) | |
shape.lineTo(width * 0.22, height / 2) | |
shape.lineTo(-width / 2, height / 2) | |
shape.closePath() | |
return new THREE.ExtrudeGeometry(shape, { depth: 0.09, bevelEnabled: false }) | |
} | |
var sign = new THREE.Group() | |
sign.position.set(-2.35, 0.88, 0.88) | |
machine.add(sign) | |
mesh(new THREE.CylinderGeometry(0.055, 0.075, 1.35, 8), brassLight, [0, 0.67, 0], null, sign) | |
var upperArrow = outlined(mesh(arrowGeometry(1.2, 0.36), hornDark, [0.18, 1.15, 0], [0, 0.18, 0.02], sign), 1.04) | |
var signCanvas = document.createElement('canvas') | |
signCanvas.width = 256 | |
signCanvas.height = 80 | |
var signContext = signCanvas.getContext('2d') | |
signContext.clearRect(0, 0, signCanvas.width, signCanvas.height) | |
signContext.fillStyle = '#f7efff' | |
signContext.font = '700 46px sans-serif' | |
signContext.textAlign = 'center' | |
signContext.textBaseline = 'middle' | |
signContext.fillText('MUSIC', 112, 42) | |
var signTexture = new THREE.CanvasTexture(signCanvas) | |
if('colorSpace' in signTexture && THREE.SRGBColorSpace) signTexture.colorSpace = THREE.SRGBColorSpace | |
mesh(new THREE.PlaneGeometry(0.76, 0.22), new THREE.MeshBasicMaterial({ map: signTexture, transparent: true, depthWrite: false }), [-0.05, 0, 0.102], null, upperArrow) | |
var lowerArrow = outlined(mesh(arrowGeometry(1.0, 0.31), flowerPink, [-0.08, 0.78, 0], [0, Math.PI + 0.1, 0], sign), 1.04) | |
lowerArrow.scale.set(0.9, 0.9, 0.9) | |
;[-0.18, 0.1, 0.34].forEach(function(x, index) { | |
makeFlower(machine, [x - 1.8, 0.98 + index * 0.04, 2.42 - index * 0.08], index % 2 ? mint : flowerPink, 0.4) | |
}) | |
// A physical side rack replaces the web-style playlist selector. Turn its | |
// two brass buttons to browse sleeves, then click the sleeve to change disc. | |
var recordShelf = new THREE.Group() | |
recordShelf.position.set(-2.3, 0.89, -1.52) | |
recordShelf.rotation.y = 0.12 | |
machine.add(recordShelf) | |
var shelfRecordCount = Math.max(1, Math.min(playlistNames.length, 10)) | |
var shelfWidth = clamp(1.5 + (shelfRecordCount - 1) * 0.11, 1.5, 2.5) | |
var shelfBase = outlined(mesh(new THREE.BoxGeometry(shelfWidth, 0.17, 0.68), woodDark, [0, 0.08, 0], null, recordShelf), 1.025) | |
var shelfBack = outlined(mesh(new THREE.BoxGeometry(shelfWidth - 0.14, 0.92, 0.11), woodEdge, [0, 0.57, -0.25], [-0.12, 0, 0], recordShelf), 1.022) | |
;[shelfBase, shelfBack].forEach(function(part) { | |
part.userData.action = 'open-shelf' | |
interactiveTargets.push(part) | |
}) | |
;[-shelfWidth / 2 + 0.13, shelfWidth / 2 - 0.13].forEach(function(x) { | |
var shelfPost = mesh(new THREE.BoxGeometry(0.1, 0.96, 0.62), brass, [x, 0.52, 0], [-0.08, 0, 0], recordShelf) | |
shelfPost.userData.action = 'open-shelf' | |
interactiveTargets.push(shelfPost) | |
}) | |
// Keep a generous invisible picking surface over the wooden rack. Once its | |
// records have fanned out, users can still click the physical rack itself | |
// to put the collection away without having to select a playlist. | |
var shelfHitArea = mesh(new THREE.BoxGeometry(shelfWidth + 0.2, 1.16, 0.08), new THREE.MeshBasicMaterial({ | |
transparent: true, | |
opacity: 0, | |
depthWrite: false, | |
side: THREE.DoubleSide | |
}), [0, 0.64, 0.42], null, recordShelf) | |
shelfHitArea.castShadow = false | |
shelfHitArea.receiveShadow = false | |
shelfHitArea.userData.action = 'open-shelf' | |
interactiveTargets.push(shelfHitArea) | |
// Each visible edge represents one playlist (up to ten). A coloured rim and | |
// a wider rack keep the collection readable instead of forming a black lump. | |
var shelfStoredRecords = new THREE.Group() | |
recordShelf.add(shelfStoredRecords) | |
var shelfRecordSpan = (shelfRecordCount - 1) * 0.11 | |
for(var rackRecordIndex = shelfRecordCount - 1; rackRecordIndex >= 0; rackRecordIndex--) { | |
var rackX = -shelfRecordSpan / 2 + rackRecordIndex * 0.11 + 0.12 | |
var rackY = 0.89 + Math.sin(rackRecordIndex * 1.7) * 0.018 | |
var rackZ = 0.015 - rackRecordIndex * 0.013 | |
var rackTilt = (rackRecordIndex - (shelfRecordCount - 1) / 2) * 0.022 | |
var rackRecordGroup = new THREE.Group() | |
rackRecordGroup.position.set(rackX, rackY, rackZ) | |
rackRecordGroup.rotation.z = rackTilt | |
shelfStoredRecords.add(rackRecordGroup) | |
var rackRimColor = new THREE.Color() | |
rackRimColor.setHSL(((278 + rackRecordIndex * 37) % 360) / 360, 0.82, 0.68) | |
mesh(new THREE.CircleGeometry(0.572, 64), new THREE.MeshBasicMaterial({ | |
color: rackRimColor, | |
transparent: true, | |
opacity: 0.9, | |
depthWrite: false | |
}), [0, 0, -0.002], null, rackRecordGroup) | |
var rackDisc = mesh(new THREE.CircleGeometry(0.548, 64), vinylMaterial, [0, 0, 0], null, rackRecordGroup) | |
rackDisc.userData.action = 'open-shelf' | |
interactiveTargets.push(rackDisc) | |
var rackRim = mesh(new THREE.TorusGeometry(0.553, 0.014, 7, 64), new THREE.MeshBasicMaterial({ | |
color: rackRimColor, | |
transparent: true, | |
opacity: 0.92, | |
depthWrite: false | |
}), [0, 0, 0.008], null, rackRecordGroup) | |
rackRim.userData.action = 'open-shelf' | |
interactiveTargets.push(rackRim) | |
;[0.39, 0.47, 0.52].forEach(function(radius, grooveIndex) { | |
mesh(new THREE.TorusGeometry(radius, 0.005, 4, 64), grooveMaterials[(grooveIndex + rackRecordIndex) % grooveMaterials.length], [0, 0, 0.01], null, rackRecordGroup) | |
}) | |
var rackLabel = mesh(new THREE.CircleGeometry(0.125, 28), new THREE.MeshBasicMaterial({ | |
color: rackRimColor, | |
transparent: true, | |
opacity: 0.94, | |
depthWrite: false | |
}), [0, 0, 0.016], null, rackRecordGroup) | |
rackLabel.userData.action = 'open-shelf' | |
⋯ 未改动代码已省略 ⋯ | |
transparent: true, | |
depthTest: false, | |
depthWrite: false | |
})) | |
arrow.position.set(x, 3.6, 3.02) | |
arrow.scale.set(0.54, 0.54, 1) | |
arrow.renderOrder = 30 | |
galleryNavigation.add(arrow) | |
return button | |
} | |
var galleryPrevButton = makeGalleryNavigationButton(-2.78, 'gallery-prev', galleryPrevMaterial, -1) | |
var galleryNextButton = makeGalleryNavigationButton(2.78, 'gallery-next', galleryNextMaterial, 1) | |
function playlistTrackCount(index) { | |
var tab = playlistTabs()[index] | |
return tab ? tab.querySelectorAll('li:not(.error)').length : 0 | |
} | |
function createPlaylistArtwork(index, name) { | |
var artCanvas = document.createElement('canvas') | |
artCanvas.width = 512 | |
artCanvas.height = 512 | |
var context = artCanvas.getContext('2d') | |
var hue = (282 + index * 41) % 360 | |
var gradient = context.createLinearGradient(0, 0, 512, 512) | |
gradient.addColorStop(0, 'hsl(' + hue + ', 86%, 68%)') | |
gradient.addColorStop(0.55, 'hsl(' + ((hue + 48) % 360) + ', 80%, 51%)') | |
gradient.addColorStop(1, 'hsl(' + ((hue + 110) % 360) + ', 82%, 36%)') | |
roundedRect(context, 8, 8, 496, 496, 34) | |
context.fillStyle = gradient | |
context.fill() | |
context.strokeStyle = 'rgba(255,255,255,.7)' | |
context.lineWidth = 7 | |
context.stroke() | |
context.save() | |
roundedRect(context, 8, 8, 496, 496, 34) | |
context.clip() | |
context.globalAlpha = 0.18 | |
for(var orbit = 0; orbit < 7; orbit++) { | |
context.strokeStyle = orbit % 2 ? '#ffffff' : '#130d24' | |
context.lineWidth = 7 + orbit * 2 | |
context.beginPath() | |
context.arc(410 - orbit * 25, 84 + orbit * 28, 70 + orbit * 36, 0, Math.PI * 2) | |
context.stroke() | |
} | |
context.restore() | |
context.textAlign = 'left' | |
context.textBaseline = 'middle' | |
context.fillStyle = 'rgba(18,12,30,.74)' | |
roundedRect(context, 36, 278, 440, 174, 22) | |
context.fill() | |
context.fillStyle = '#9bfbff' | |
context.font = '700 20px sans-serif' | |
context.fillText('VINYL COLLECTION ' + String(index + 1).padStart(2, '0'), 60, 316) | |
context.fillStyle = '#fffaff' | |
context.font = '700 38px sans-serif' | |
context.fillText(shortenText(context, name, 388), 60, 370) | |
context.fillStyle = 'rgba(255,255,255,.75)' | |
context.font = '600 20px sans-serif' | |
context.fillText('悬停查看 · 点击换盘', 60, 416) | |
var artTexture = new THREE.CanvasTexture(artCanvas) | |
⋯ 未改动代码已省略 ⋯ | |
} | |
playlistNames.forEach(function(name, index) { | |
var card = new THREE.Group() | |
card.visible = false | |
playlistGallery.add(card) | |
var disc = mesh(new THREE.CircleGeometry(0.47, 56), vinylMaterial, [0.16, 0.06, -0.055], null, card) | |
;[0.28, 0.35, 0.42].forEach(function(radius, grooveNumber) { | |
mesh(new THREE.TorusGeometry(radius, 0.006, 4, 56), grooveMaterials[grooveNumber % grooveMaterials.length], [0.16, 0.06, -0.045], null, card) | |
}) | |
var artwork = new THREE.MeshBasicMaterial({ map: createPlaylistArtwork(index, name), transparent: true, depthWrite: false }) | |
var cardSleeve = mesh(new THREE.PlaneGeometry(0.86, 0.86), artwork, [-0.08, 0, 0], null, card) | |
cardSleeve.userData.action = 'choose-playlist' | |
cardSleeve.userData.playlistIndex = index | |
disc.userData.action = 'choose-playlist' | |
disc.userData.playlistIndex = index | |
interactiveTargets.push(cardSleeve, disc) | |
var detailMaterial = new THREE.MeshBasicMaterial({ map: createPlaylistDetail(index, name), transparent: true, depthWrite: false }) | |
var detail = mesh(new THREE.PlaneGeometry(0.98, 0.34), detailMaterial, [0, -0.62, 0.04], null, card) | |
detail.scale.y = 0.001 | |
detail.visible = false | |
playlistCards.push({ | |
group: card, | |
sleeve: cardSleeve, | |
disc: disc, | |
detail: detail, | |
index: index, | |
hover: 0, | |
carouselX: -2.7, | |
carouselVisibility: 0 | |
}) | |
}) | |
// AudioPlayer-inspired circular spectrum, standing vertically around the | |
// record so the analyser rises from the deck instead of lying on it. | |
var spectrumGroup = new THREE.Group() | |
spectrumGroup.position.y = 0.105 | |
spectrumGroup.visible = false | |
vinylGroup.add(spectrumGroup) | |
var spectrumCount = 96 | |
var spectrumRadius = 1.74 | |
var spectrumLevels = new Float32Array(spectrumCount) | |
var spectrumBars = [] | |
var spectrumGlows = [] | |
var spectrumTips = [] | |
var spectrumPalette = [ | |
0x30e9ff, 0x38ffd1, 0xa8ff55, 0xffe854, | |
0xff9b45, 0xff5c91, 0xff4fd8, 0xb45cff, | |
0x765dff, 0x4d9dff, 0x32d6ff, 0x30e9ff | |
] | |
var spectrumGeometry = new THREE.CylinderGeometry(0.012, 0.025, 1, 6) | |
var spectrumGlowGeometry = new THREE.CylinderGeometry(0.04, 0.068, 1, 6) | |
var spectrumTipGeometry = new THREE.SphereGeometry(0.032, 8, 6) | |
var spectrumMaterials = spectrumPalette.map(function(color) { | |
return new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.98, depthWrite: false }) | |
}) | |
var spectrumGlowMaterials = spectrumPalette.map(function(color) { | |
return new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.2, depthWrite: false }) | |
}) | |
for(var spectrumIndex = 0; spectrumIndex < spectrumCount; spectrumIndex++) { | |
var spectrumPivot = new THREE.Group() | |
spectrumPivot.rotation.y = spectrumIndex / spectrumCount * Math.PI * 2 | |
var spectrumMaterialIndex = Math.floor(spectrumIndex / spectrumCount * spectrumMaterials.length) % spectrumMaterials.length | |
var spectrumBar = new THREE.Mesh(spectrumGeometry, spectrumMaterials[spectrumMaterialIndex]) | |
spectrumBar.position.set(0, 0.085, spectrumRadius) | |
spectrumBar.scale.y = 0.06 | |
spectrumBar.castShadow = false | |
var spectrumGlow = new THREE.Mesh(spectrumGlowGeometry, spectrumGlowMaterials[spectrumMaterialIndex]) | |
spectrumGlow.position.copy(spectrumBar.position) | |
spectrumGlow.scale.y = 0.06 | |
spectrumGlow.castShadow = false | |
var spectrumTip = new THREE.Mesh(spectrumTipGeometry, spectrumMaterials[spectrumMaterialIndex]) | |
spectrumTip.position.set(0, 0.16, spectrumRadius) | |
spectrumTip.castShadow = false | |
spectrumPivot.add(spectrumGlow, spectrumBar, spectrumTip) | |
spectrumGroup.add(spectrumPivot) | |
spectrumBars.push(spectrumBar) | |
spectrumGlows.push(spectrumGlow) | |
spectrumTips.push(spectrumTip) | |
} | |
function ringGeometry(radius, colorAttribute) { | |
var positions = new Float32Array((spectrumCount + 1) * 3) | |
var colors = colorAttribute ? new Float32Array((spectrumCount + 1) * 3) : null | |
for(var point = 0; point <= spectrumCount; point++) { | |
⋯ 未改动代码已省略 ⋯ | |
colors[point * 3 + 1] = ringColor.g | |
colors[point * 3 + 2] = ringColor.b | |
} | |
} | |
var geometry = new THREE.BufferGeometry() | |
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) | |
if(colors) geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)) | |
return geometry | |
} | |
var spectrumInner = new THREE.Mesh( | |
new THREE.TorusGeometry(spectrumRadius - 0.045, 0.022, 6, 128), | |
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.82, depthWrite: false }) | |
) | |
spectrumInner.position.y = 0.055 | |
spectrumInner.rotation.x = Math.PI / 2 | |
var spectrumOuter = new THREE.Line( | |
ringGeometry(spectrumRadius + 0.1, true), | |
new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 1, depthWrite: false }) | |
) | |
var spectrumEcho = new THREE.Line( | |
ringGeometry(spectrumRadius + 0.04, true), | |
new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.42, depthWrite: false }) | |
) | |
spectrumGroup.add(spectrumInner, spectrumEcho, spectrumOuter) | |
var glowHalo = mesh(new THREE.CircleGeometry(2.65, 56), new THREE.MeshBasicMaterial({ color: 0x9869d8, transparent: true, opacity: 0.08, depthWrite: false }), [-0.42, 0.89, 0.28], [-Math.PI / 2, 0, 0]) | |
var floor = new THREE.Mesh(new THREE.CircleGeometry(10, 72), new THREE.ShadowMaterial({ color: 0x57456d, opacity: 0.07 })) | |
floor.rotation.x = -Math.PI / 2 | |
floor.position.y = -1.94 | |
floor.receiveShadow = true | |
scene.add(floor) | |
var raycaster = new THREE.Raycaster() | |
var pointer = new THREE.Vector2() | |
;[record, label, labelBase].forEach(function(target) { | |
target.userData.action = 'play' | |
interactiveTargets.push(target) | |
}) | |
function updateCamera() { | |
⋯ 未改动代码已省略 ⋯ | |
} | |
if(panelButtons.play) { | |
panelButtons.play.material.color.setHex(playing ? 0x62f0ca : 0xd776ff) | |
panelButtons.play.material.emissive.setHex(playing ? 0x27dca9 : 0x8c35bf) | |
panelButtons.play.material.emissiveIntensity = playing ? 0.72 : 0.18 | |
} | |
if(panelPlayIcon && panelPlayIconContext) { | |
drawPanelIcon(panelPlayIconContext, 'play', playing) | |
panelPlayIcon.needsUpdate = true | |
} | |
renderLedPanel() | |
} | |
function setupReactiveAudio() { | |
if(analyser || !audio || !isPlaying()) return | |
var capture = audio.captureStream || audio.mozCaptureStream | |
var AudioContext = window.AudioContext || window.webkitAudioContext | |
if(!capture || !AudioContext) return | |
try { | |
var stream = capture.call(audio) | |
if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return | |
audioContext = audioContext || new AudioContext() | |
if(audioContext.state === 'suspended') audioContext.resume().catch(function() {}) | |
analyser = audioContext.createAnalyser() | |
analyser.fftSize = 512 | |
analyser.smoothingTimeConstant = 0.58 | |
analyserSource = audioContext.createMediaStreamSource(stream) | |
analyserSource.connect(analyser) | |
frequencyData = new Uint8Array(analyser.frequencyBinCount) | |
} catch(error) { | |
analyser = null | |
analyserSource = null | |
} | |
} | |
function togglePlay() { | |
root.scrollTop = 0 | |
if(!source || !audio) return | |
if(isPlaying()) { | |
if(source.player && source.player.pause) source.player.pause() | |
else audio.pause() | |
⋯ 未改动代码已省略 ⋯ | |
syncShelfUI() | |
drawShelfRecord() | |
displayTitle = playlistNames[index] || ('歌单 ' + (index + 1)) | |
displayArtist = index === activePlaylistIndex ? '这张唱片已经在唱盘上' : '唱片架正在收拢,准备换片…' | |
renderLedPanel() | |
later(function() { | |
if(!switchPlaylist(index)) { | |
displayArtist = '唱片目录还在整理,请稍后再试' | |
if(artistNode) artistNode.textContent = displayArtist | |
renderLedPanel() | |
} | |
}, prefersReducedMotion() ? 20 : 720) | |
} | |
function switchTrack(direction) { | |
root.scrollTop = 0 | |
if(!source) return | |
var button = sourceContainer.querySelector(direction > 0 ? '.controller .forward.btn' : '.controller .backward.btn') | |
if(button) button.click() | |
later(function() { updateMetadata(); setPlayingState() }, 180) | |
} | |
function playlistTabs() { | |
return sourceContainer ? Array.prototype.slice.call(sourceContainer.querySelectorAll('.playlist .tab[data-title]')) : [] | |
} | |
function activePlaylistTab() { | |
var tabs = playlistTabs() | |
return sourceContainer && sourceContainer.querySelector('.playlist .tab.active[data-title]') | |
|| tabs[activePlaylistIndex] | |
|| tabs[0] | |
} | |
function renderDialogTrackList() { | |
if(!trackListNode) return | |
var tab = activePlaylistTab() | |
var items = tab ? Array.prototype.slice.call(tab.querySelectorAll('li')) : [] | |
var currentIndex = items.findIndex(function(item) { return item.classList.contains('current') }) | |
var listKey = (tab && tab.getAttribute('data-title') || activePlaylistIndex) + '|' + currentIndex + '|' + items.map(function(item) { | |
return (item.getAttribute('title') || item.textContent.trim()) + ':' + item.classList.contains('error') | |
}).join('~') | |
if(listKey === renderedTrackListKey) return | |
renderedTrackListKey = listKey | |
trackListNode.innerHTML = '' | |
if(trackCountNode) trackCountNode.textContent = items.length ? items.length + ' 首' : '正在读取' | |
if(!items.length) { | |
⋯ 未改动代码已省略 ⋯ | |
button.disabled = item.classList.contains('error') | |
row.dataset.search = (title + ' ' + artist).toLocaleLowerCase() | |
if(index === currentIndex) button.className = 'is-current' | |
var order = document.createElement('span') | |
order.textContent = String(index + 1).padStart(2, '0') | |
var copy = document.createElement('span') | |
var strong = document.createElement('strong') | |
strong.textContent = title | |
var small = document.createElement('small') | |
small.textContent = artist + (button.disabled ? ' · 音源暂不可用' : '') | |
copy.appendChild(strong) | |
copy.appendChild(small) | |
var state = document.createElement('i') | |
state.className = index === currentIndex ? 'ic i-play' : 'ic i-music' | |
button.appendChild(order) | |
button.appendChild(copy) | |
button.appendChild(state) | |
row.appendChild(button) | |
trackListNode.appendChild(row) | |
}) | |
filterTracks() | |
revealCurrentDialogTrack() | |
} | |
function filterTracks() { | |
if(!trackListNode) return | |
var query = searchNode ? searchNode.value.trim().toLocaleLowerCase() : '' | |
var count = 0 | |
Array.prototype.forEach.call(trackListNode.children, function(row) { | |
row.hidden = Boolean(query && !(row.dataset.search || '').includes(query)) | |
if(!row.hidden && !row.classList.contains('is-empty')) count++ | |
}) | |
if(trackCountNode) trackCountNode.textContent = query ? count + ' 首匹配' : trackListNode.querySelectorAll('button').length + ' 首' | |
} | |
function syncShelfUI() { | |
var button = root.querySelector('[data-gramophone-shelf]') | |
if(button) button.setAttribute('aria-expanded', String(shelfOpen)) | |
if(shelfStatus) shelfStatus.hidden = !shelfOpen | |
if(shelfAccess) shelfAccess.hidden = !shelfOpen | |
} | |
function revealCurrentDialogTrack() { | |
if(!trackListNode) return | |
var currentButton = trackListNode.querySelector('button.is-current') | |
if(currentButton) { | |
var listRect = trackListNode.getBoundingClientRect() | |
var currentRect = currentButton.getBoundingClientRect() | |
var currentTop = currentRect.top - listRect.top + trackListNode.scrollTop | |
var currentBottom = currentTop + currentButton.offsetHeight | |
⋯ 未改动代码已省略 ⋯ | |
recordSwapAction = action | |
recordSwapMidpoint = false | |
recordSwapStarted = performance.now() | |
root.classList.add('is-changing-record') | |
displayArtist = '唱针归位,正在更换唱片…' | |
renderLedPanel() | |
return true | |
} | |
function switchPlaylist(index) { | |
if(index === activePlaylistIndex) { | |
displayTitle = playlistNames[index] || displayTitle | |
displayArtist = '这张唱片已经在唱盘上' | |
renderLedPanel() | |
return true | |
} | |
var tabs = playlistTabs() | |
var tab = tabs[index] | |
var firstTrack = tab && tab.querySelector('li:not(.error)') | |
if(!firstTrack) return false | |
return startRecordSwap(function() { | |
firstTrack.click() | |
activePlaylistIndex = index | |
selectedPlaylistIndex = index | |
shelfSelectionTouched = false | |
drawShelfRecord() | |
later(function() { updateMetadata(); setPlayingState() }, 180) | |
}) | |
} | |
function updateMetadata() { | |
if(!source) return | |
var hiddenTitle = sourceContainer.querySelector('.player-info .preview .title') | |
var hiddenArtist = sourceContainer.querySelector('.player-info .preview .info > span') | |
var hiddenCover = sourceContainer.querySelector('.player-info .preview img') | |
var title = hiddenTitle && hiddenTitle.textContent.trim() | |
var artist = hiddenArtist && hiddenArtist.textContent.trim() | |
if(!title && audio && audio.title) { | |
var titleParts = audio.title.split(' - ') | |
title = titleParts.shift().trim() | |
artist = titleParts.join(' - ').trim() || artist | |
} | |
if(title && title !== 'Loading') { | |
displayTitle = title | |
if(titleNode) titleNode.textContent = title | |
} | |
if(artist) { | |
displayArtist = artist | |
if(artistNode) artistNode.textContent = artist | |
} | |
if(hiddenCover && hiddenCover.src) { | |
if(!hiddenCover.dataset.gramophoneCoverBound) { | |
hiddenCover.dataset.gramophoneCoverBound = 'true' | |
listen(hiddenCover, 'load', function() { | |
if(hiddenCover.naturalWidth) { | |
coverNode.src = hiddenCover.currentSrc || hiddenCover.src | |
updateRecordCover(coverNode.src) | |
} | |
}) | |
} | |
var coverUrl = hiddenCover.currentSrc || hiddenCover.src || hiddenCover.getAttribute('data-src') | |
if(coverUrl && coverNode.src !== coverUrl) coverNode.src = coverUrl | |
if(coverUrl) updateRecordCover(coverUrl) | |
} | |
if(audio && (audio.src || title)) loading.classList.add('is-hidden') | |
syncPlaylistState() | |
playlistCards.forEach(function(card, index) { | |
var tab = playlistTabs()[index] | |
var item = tab && tab.querySelector('li:not(.error)') | |
var url = item && item.dataset.cover | |
if(url && card.coverUrl !== url) { | |
card.coverUrl = url | |
var image = new Image(); image.crossOrigin = 'anonymous'; image.referrerPolicy = 'no-referrer' | |
image.onload = function() { | |
if(disposed || card.coverUrl !== url) return | |
var texture = card.sleeve.material.map, ctx = texture.userData.context | |
⋯ 未改动代码已省略 ⋯ | |
renderLedPanel() | |
return | |
} | |
selectedPlaylistIndex = (selectedPlaylistIndex + direction + playlistNames.length) % playlistNames.length | |
shelfSelectionTouched = true | |
drawShelfRecord() | |
displayTitle = playlistNames[selectedPlaylistIndex] || ('歌单 ' + (selectedPlaylistIndex + 1)) | |
displayArtist = selectedPlaylistIndex === activePlaylistIndex ? '这张唱片正在播放' : (shelfOpen ? '点击展开后的唱片完成换盘' : '点击唱片架展开全部唱片') | |
renderLedPanel() | |
} | |
function setRay(event) { | |
var rect = canvas.getBoundingClientRect() | |
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 | |
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 | |
raycaster.setFromCamera(pointer, camera) | |
var hits = raycaster.intersectObjects(interactiveTargets, false) | |
for(var hitIndex = 0; hitIndex < hits.length; hitIndex++) { | |
var hitAction = hits[hitIndex].object.userData.action | |
if(hitAction === 'choose-playlist') { | |
var playlistIndex = Number(hits[hitIndex].object.userData.playlistIndex) | |
var playlistCard = playlistCards[playlistIndex] | |
if(shelfExpansion < 0.42 || !playlistCard || playlistCard.carouselVisibility < 0.35) continue | |
} | |
if((hitAction === 'gallery-prev' || hitAction === 'gallery-next') && !galleryNavigation.visible) continue | |
return hits[hitIndex] | |
} | |
return null | |
} | |
function activateHit(hit) { | |
if(!hit || !hit.object) return | |
var action = hit.object.userData.action | |
if(action === 'play') togglePlay() | |
else if(action === 'prev') switchTrack(-1) | |
else if(action === 'next') switchTrack(1) | |
else if(action === 'screen') setDialogOpen(true) | |
else if(action === 'open-shelf') openRecordShelf() | |
else if(action === 'shelf-prev') browseShelf(-1) | |
else if(action === 'shelf-next') browseShelf(1) | |
else if(action === 'gallery-prev') browseShelf(-1) | |
else if(action === 'gallery-next') browseShelf(1) | |
else if(action === 'choose-playlist') choosePlaylist(Number(hit.object.userData.playlistIndex)) | |
} | |
function clearModelHover() { | |
if(hoveredModel && hoveredModel.material && hoveredModel.material.emissive) hoveredModel.material.emissiveIntensity = hoveredModel.userData.hoverEmissive | |
hoveredModel = null | |
canvas.classList.remove('is-record-hovered') | |
if(hotspot) hotspot.hidden = true | |
} | |
function onPointerDown(event) { | |
if(event.button != null && event.button !== 0) return | |
clearModelHover() | |
dragging = true | |
moved = false | |
downX = lastX = event.clientX | |
downY = lastY = event.clientY | |
if(event.pointerType !== 'touch' || modelInteraction || fullscreenActive) canvas.setPointerCapture(event.pointerId) | |
canvas.classList.add('is-dragging') | |
} | |
function onPointerMove(event) { | |
if(!dragging) { | |
var hit = setRay(event) | |
clearModelHover() | |
hoveredModel = hit && hit.object | |
if(hoveredModel && hoveredModel.material && hoveredModel.material.emissive) { | |
hoveredModel.userData.hoverEmissive = hoveredModel.material.emissiveIntensity | |
hoveredModel.material.emissiveIntensity += 0.22 | |
} | |
if(hotspot) { | |
var labels = { play: '播放 / 暂停', prev: '上一首', next: '下一首', screen: '打开播放室', 'open-shelf': '挑选唱片', 'gallery-prev': '上一组唱片', 'gallery-next': '下一组唱片', 'choose-playlist': '点击选择唱片' } | |
hotspot.hidden = !hit | |
if(hit) { | |
hotspot.textContent = labels[hit.object.userData.action] || '点击操作' | |
var hoverRect = visual.getBoundingClientRect() | |
hotspot.style.left = clamp(event.clientX - hoverRect.left + 12, 8, hoverRect.width - 155) + 'px' | |
hotspot.style.top = clamp(event.clientY - hoverRect.top - 35, 55, hoverRect.height - 45) + 'px' | |
} | |
} | |
var nextHoveredPlaylist = hit && hit.object.userData.action === 'choose-playlist' | |
? Number(hit.object.userData.playlistIndex) | |
: -1 | |
if(nextHoveredPlaylist !== hoveredPlaylistIndex && shelfOpen) hoveredPlaylistIndex = nextHoveredPlaylist | |
canvas.classList.toggle('is-record-hovered', !!hit) | |
return | |
} | |
var dx = event.clientX - lastX | |
var dy = event.clientY - lastY | |
if(Math.hypot(event.clientX - downX, event.clientY - downY) > 5) moved = true | |
if(event.pointerType === 'touch' && !modelInteraction && !fullscreenActive) return | |
targetYaw += dx * 0.007 | |
targetPitch = clamp(targetPitch + dy * 0.0048, -0.38, 0.32) | |
lastX = event.clientX | |
lastY = event.clientY | |
} | |
function onPointerUp(event) { | |
if(!dragging) return | |
dragging = false | |
canvas.classList.remove('is-dragging') | |
if(event.type !== 'pointercancel' && !moved) activateHit(setRay(event)) | |
} | |
function toggleFullscreen() { | |
var currentlyFullscreen = document.fullscreenElement === root || fallbackFullscreen | |
if(!currentlyFullscreen) | |
fullscreenScrollY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0 | |
if(document.fullscreenElement === root) { | |
document.exitFullscreen().catch(function() {}) | |
} else if(fallbackFullscreen) { | |
fallbackFullscreen = false | |
syncFullscreen() | |
} else if(root.requestFullscreen) { | |
root.requestFullscreen().catch(function() { | |
fallbackFullscreen = true | |
syncFullscreen() | |
}) | |
} else { | |
fallbackFullscreen = true | |
syncFullscreen() | |
} | |
} | |
function syncFullscreen() { | |
var fullscreen = document.fullscreenElement === root || fallbackFullscreen | |
var wasFullscreen = fullscreenActive | |
fullscreenActive = fullscreen | |
⋯ 未改动代码已省略 ⋯ | |
root.classList.toggle('is-gramophone-fullscreen', fullscreen) | |
document.body.classList.toggle('gramophone-fullscreen-lock', fullscreen) | |
var fullscreenButton = root.querySelector('[data-gramophone-fullscreen]') | |
if(fullscreenButton) fullscreenButton.setAttribute('aria-label', fullscreen ? '退出留声机全屏' : '全屏欣赏留声机') | |
var icon = root.querySelector('[data-gramophone-fullscreen] i') | |
if(icon) { | |
icon.classList.toggle('i-expand', !fullscreen) | |
icon.classList.toggle('i-compress', fullscreen) | |
} | |
later(resize, 80) | |
if(wasFullscreen && !fullscreen) { | |
var restoreY = fullscreenScrollY | |
requestAnimationFrame(function() { | |
requestAnimationFrame(function() { window.scrollTo(0, restoreY) }) | |
}) | |
// Some browsers apply their own post-fullscreen scroll after the first | |
// frame; a second idempotent restore keeps the article at the exact | |
// position where the user entered fullscreen. | |
later(function() { window.scrollTo(0, restoreY) }, 140) | |
} | |
} | |
function animate(now) { | |
if(disposed) return | |
raf = requestAnimationFrame(animate) | |
var frameDelta = Math.min(0.05, Math.max(0, (now - (lastFrameTime || now)) / 1000)) | |
lastFrameTime = now | |
if(!visible || document.hidden) return | |
var reducedMotion = prefersReducedMotion() | |
var t = now * 0.001 | |
var playing = isPlaying() | |
var audioTime = audio && isFinite(audio.currentTime) ? audio.currentTime : t | |
var measuredEnergy = 0 | |
var hasMeasuredSpectrum = false | |
if(playing && !reducedMotion && analyser && frequencyData) { | |
analyser.getByteFrequencyData(frequencyData) | |
var total = 0 | |
var peak = 0 | |
var samples = Math.min(frequencyData.length, 112) | |
for(var sample = 2; sample < samples; sample++) { | |
total += frequencyData[sample] | |
peak = Math.max(peak, frequencyData[sample]) | |
} | |
measuredEnergy = total / Math.max(1, samples - 2) / 255 | |
hasMeasuredSpectrum = peak > 7 && measuredEnergy > 0.004 | |
} | |
reactiveEnergy += ((measuredEnergy > 0.004 ? measuredEnergy : 0) - reactiveEnergy) * (measuredEnergy > reactiveEnergy ? 0.46 : 0.13) | |
var fallbackEnergy = 0.13 + 0.035 * Math.sin(audioTime * 0.7) | |
var energy = playing ? (reactiveEnergy > 0.004 ? reactiveEnergy * 2.55 : fallbackEnergy) : 0 | |
energy = reducedMotion ? 0 : clamp(energy, 0.1, 1) | |
machine.rotation.y += (targetYaw - machine.rotation.y) * (reducedMotion ? 1 : 0.075) | |
machine.rotation.x += (targetPitch - machine.rotation.x) * (reducedMotion ? 1 : 0.075) | |
distance += (targetDistance - distance) * (reducedMotion ? 1 : 0.08) | |
updateCamera() | |
if(shelfOpen && shelfStatus) { | |
var candidate = hoveredPlaylistIndex >= 0 ? hoveredPlaylistIndex : selectedPlaylistIndex | |
var statusText = '第 ' + (candidate + 1) + ' / ' + playlistNames.length + ' 张 · ' + playlistNames[candidate] + ' · ' + playlistAvailability(candidate) | |
if(statusText !== lastShelfStatus) { shelfStatus.textContent = statusText; lastShelfStatus = statusText } | |
} | |
var shelfTarget = shelfOpen ? 1 : 0 | |
shelfExpansion += (shelfTarget - shelfExpansion) * (reducedMotion ? 1 : 0.105) | |
if(Math.abs(shelfTarget - shelfExpansion) < 0.002) shelfExpansion = shelfTarget | |
var rackExtraction = clamp(shelfExpansion / 0.76, 0, 1) | |
rackExtraction = rackExtraction * rackExtraction * (3 - 2 * rackExtraction) | |
shelfStoredRecordGroups.forEach(function(stored) { | |
var fan = shelfRecordCount > 1 ? stored.index / (shelfRecordCount - 1) - 0.5 : 0 | |
var localExtraction = clamp(rackExtraction * 1.08 - stored.index * 0.012, 0, 1) | |
stored.group.visible = localExtraction < 0.995 | |
stored.group.position.x = stored.homeX + fan * 0.5 * localExtraction | |
stored.group.position.y = stored.homeY + (0.34 + Math.abs(fan) * 0.08) * localExtraction | |
stored.group.position.z = stored.homeZ + 0.34 * localExtraction | |
stored.group.rotation.z = stored.homeTilt + fan * 0.2 * localExtraction | |
stored.group.scale.setScalar(Math.max(0.001, 1 - localExtraction * 0.94)) | |
}) | |
sleeve.visible = rackExtraction < 0.995 | |
sleeve.position.x = sleeveHomeX - 0.18 * rackExtraction | |
sleeve.position.y = 0.87 + 0.44 * rackExtraction | |
sleeve.position.z = 0.09 + 0.38 * rackExtraction | |
sleeve.rotation.z = -0.11 * rackExtraction | |
sleeve.scale.setScalar(Math.max(0.001, 1 - rackExtraction * 0.94)) | |
playlistGallery.visible = shelfExpansion > 0.012 | |
var cardTotal = Math.max(1, playlistCards.length) | |
var galleryMaxStart = Math.max(0, cardTotal - galleryVisibleCount) | |
galleryWindowStart = clamp(galleryWindowStart, 0, galleryMaxStart) | |
galleryNavigation.visible = shelfExpansion > 0.08 && cardTotal > galleryVisibleCount | |
galleryNavigation.scale.setScalar(0.72 + shelfExpansion * 0.28) | |
galleryPrevMaterial.opacity = galleryWindowStart > 0 ? 0.98 : 0.7 | |
galleryNextMaterial.opacity = galleryWindowStart < galleryMaxStart ? 0.98 : 0.7 | |
galleryPrevButton.scale.setScalar(galleryWindowStart > 0 ? 1 : 0.92) | |
galleryNextButton.scale.setScalar(galleryWindowStart < galleryMaxStart ? 1 : 0.92) | |
playlistCards.forEach(function(card, index) { | |
var gallerySlot = index - galleryWindowStart | |
var inGalleryWindow = gallerySlot >= 0 && gallerySlot < galleryVisibleCount | |
card.carouselVisibility += (((inGalleryWindow ? 1 : 0)) - card.carouselVisibility) * (reducedMotion ? 1 : 0.16) | |
if(Math.abs(card.carouselVisibility - (inGalleryWindow ? 1 : 0)) < 0.002) | |
card.carouselVisibility = inGalleryWindow ? 1 : 0 | |
var galleryFirstX = -2.36 | |
var gallerySpacing = 1.18 | |
var galleryTargetX = inGalleryWindow | |
? galleryFirstX + gallerySlot * gallerySpacing | |
: (gallerySlot < 0 ? galleryFirstX - 1.05 : galleryFirstX + galleryVisibleCount * gallerySpacing) | |
card.carouselX += (galleryTargetX - card.carouselX) * (reducedMotion ? 1 : 0.14) | |
var stagger = inGalleryWindow ? gallerySlot * 0.032 : 0 | |
var spread = clamp((shelfExpansion - stagger) / (1 - 0.13), 0, 1) | |
spread = spread * spread * (3 - 2 * spread) | |
var isHovered = shelfOpen && inGalleryWindow && hoveredPlaylistIndex === index | |
card.hover += ((isHovered ? 1 : 0) - card.hover) * (reducedMotion ? 1 : 0.16) | |
card.group.visible = shelfExpansion > 0.012 && card.carouselVisibility > 0.012 | |
card.group.position.x = -2.57 + (card.carouselX + 2.57) * spread | |
card.group.position.y = 1.77 + (2.48 - 1.77) * spread + card.hover * 0.22 | |
⋯ 未改动代码已省略 ⋯ | |
glowHalo.material.opacity += (((playing ? 0.09 + energy * 0.075 : 0.045)) - glowHalo.material.opacity) * (reducedMotion ? 1 : 0.08) | |
glowHalo.scale.setScalar(playing ? 1 + energy * 0.045 : 1) | |
ledMaterial.opacity = playing ? 0.94 + energy * 0.06 : 0.9 | |
ledScreen.scale.y += (((playing ? 1 + energy * 0.018 : 1)) - ledScreen.scale.y) * (reducedMotion ? 1 : 0.12) | |
hornWaveGroup.visible = playing && !recordSwapping && !reducedMotion | |
hornWaves.forEach(function(soundWave, index) { | |
if(!hornWaveGroup.visible) { | |
soundWave.material.opacity = 0 | |
return | |
} | |
var wavePhase = (t * 0.24 + index / hornWaves.length) % 1 | |
var waveScale = 0.9 + wavePhase * 0.62 + energy * 0.035 | |
soundWave.position.y = hornLength + 0.12 + wavePhase * 1.3 | |
soundWave.scale.setScalar(waveScale) | |
soundWave.material.opacity = Math.pow(1 - wavePhase, 1.55) * (0.07 + energy * 0.18) | |
soundWave.rotation.z = t * 0.04 + index * 0.17 | |
}) | |
var fountainUpperSurge = reducedMotion ? 0 : Math.sin(t * 1.72) | |
var fountainLowerSurge = reducedMotion ? 0 : Math.sin(t * 1.48 + 0.8) | |
fountainUpperWater.position.y = fountainUpperSurge * 0.035 | |
fountainUpperWater.scale.set(1 + fountainUpperSurge * 0.018, 1 + fountainUpperSurge * 0.045, 1 + fountainUpperSurge * 0.018) | |
fountainLowerWater.position.y = fountainLowerSurge * 0.022 | |
fountainLowerWater.scale.set(1 + fountainLowerSurge * 0.012, 1 + fountainLowerSurge * 0.035, 1 + fountainLowerSurge * 0.012) | |
fountainUpperWater.rotation.y += reducedMotion ? 0 : 0.00105 | |
fountainLowerWater.rotation.y -= reducedMotion ? 0 : 0.00072 | |
waterSheets.forEach(function(sheet, sheetIndex) { | |
var attribute = sheet.geometry.attributes.position | |
var rest = sheet.userData.restPositions | |
for(var vertex = 0; vertex < attribute.count; vertex++) { | |
var vx = rest[vertex * 3], vy = rest[vertex * 3 + 1], vz = rest[vertex * 3 + 2] | |
var angle = Math.atan2(vz, vx) | |
var flow = reducedMotion ? 0 : Math.sin(angle * 7 + vy * 9 - t * 1.7) * 0.018 + Math.sin(angle * 13 - t * 0.9) * 0.008 | |
attribute.setXYZ(vertex, vx * (1 + flow), vy + flow * 0.45, vz * (1 + flow)) |
#留声机样式与默认封面
themes/shoka/source/css/gramophone.styl改动位置:第 1–18、22–107、109–177、180–239、285–550、558–591 行
.about-gramophone { | |
--gramophone-primary: var(--palette-primary-light, #b15cff); | |
--gramophone-secondary: var(--palette-primary-light, #ff5fc8); | |
--gramophone-cyan: var(--palette-secondary, #27e4ff); | |
--gramophone-gold: #ffd05d; | |
position: relative; | |
min-height: 0; | |
margin: 18px 0 34px; | |
overflow: hidden; | |
isolation: isolate; | |
color: var(--palette-primary-mist, #f7f1ff); | |
border: 0; | |
border-radius: 26px; | |
background: transparent; | |
box-shadow: none; | |
} | |
.about-gramophone::before { | |
⋯ 未改动代码已省略 ⋯ | |
.about-gramophone__visual { | |
position: relative; | |
z-index: 1; | |
min-height: 720px; | |
overflow: hidden; | |
} | |
#about-gramophone-canvas { | |
position: absolute; | |
z-index: 2; | |
inset: 0; | |
width: 100%; | |
height: 100%; | |
display: block; | |
outline: 0; | |
cursor: grab; | |
touch-action: pan-y; | |
} | |
#about-gramophone-canvas.is-dragging { cursor: grabbing; } | |
#about-gramophone-canvas.is-record-hovered { cursor: pointer; } | |
.about-gramophone__fullscreen { | |
position: absolute; | |
z-index: 7; | |
top: 17px; | |
right: 18px; | |
width: 36px; | |
height: 36px; | |
display: grid; | |
place-items: center; | |
padding: 0; | |
color: var(--palette-button, #7f559f); | |
font: inherit; | |
font-size: 12px; | |
border: 1px solid unquote('rgba(var(--palette-primary-rgb, 162, 103, 206), .22)'); | |
border-radius: 11px; | |
outline: 0; | |
background: rgba(255, 255, 255, .66); | |
box-shadow: 0 8px 24px unquote('rgba(var(--palette-primary-shade-rgb, 100, 60, 130), .11)'); | |
backdrop-filter: blur(12px); | |
cursor: pointer; | |
transition: transform .2s ease, color .2s ease, background .2s ease; | |
} | |
.about-gramophone__fullscreen:hover { | |
color: #fff; | |
background: var(--palette-action-gradient, linear-gradient(135deg, rgba(171, 102, 255, .88), rgba(124, 114, 255, .78))); | |
transform: translateY(-1px); | |
} | |
.about-gramophone__loading { | |
position: absolute; | |
z-index: 6; | |
inset: 0; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
gap: 5px; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 238, 228, 248), .7)'); | |
text-align: center; | |
background: unquote('rgba(var(--palette-primary-deep-rgb, 8, 7, 14), .62)'); | |
backdrop-filter: blur(12px); | |
transition: opacity .45s ease, visibility .45s ease; | |
} | |
.about-gramophone__loading.is-hidden { | |
opacity: 0; | |
visibility: hidden; | |
pointer-events: none; | |
} | |
.about-gramophone__loading > span { | |
width: 48px; | |
height: 48px; | |
margin-bottom: 8px; | |
border: 1px solid unquote('rgba(var(--palette-primary-light-rgb, 171, 102, 255), .28)'); | |
border-top-color: var(--gramophone-primary); | |
border-radius: 50%; | |
box-shadow: inset 0 0 16px unquote('rgba(var(--palette-primary-light-rgb, 171, 102, 255), .13)'), 0 0 20px unquote('rgba(var(--palette-primary-light-rgb, 171, 102, 255), .15)'); | |
animation: gramophone-spinner 1.8s linear infinite; | |
} | |
.about-gramophone__loading strong { color: var(--palette-primary-mist, #f5efff); font-size: 12px; } | |
.about-gramophone__loading small { font-size: 8px; } | |
⋯ 未改动代码已省略 ⋯ | |
.about-gramophone__console { | |
position: absolute; | |
z-index: 10; | |
top: 50%; | |
left: 50%; | |
width: 620px; | |
max-width: calc(100% - 40px); | |
display: grid; | |
grid-template-columns: 116px minmax(0, 1fr); | |
gap: 14px 18px; | |
padding: 18px; | |
box-sizing: border-box; | |
margin: 0; | |
max-height: calc(100% - 30px); | |
overflow-x: hidden; | |
overflow-y: auto; | |
scrollbar-width: thin; | |
scrollbar-color: unquote('rgba(var(--palette-primary-light-rgb, 177, 92, 255), .42)') transparent; | |
border: 1px solid unquote('rgba(var(--palette-primary-pale-rgb, 225, 205, 255), .34)'); | |
border-radius: 22px; | |
background: linear-gradient(145deg, unquote('rgba(var(--palette-primary-deep-rgb, 30, 22, 43), .96)'), unquote('rgba(var(--palette-secondary-deep-rgb, 12, 16, 29), .94)')); | |
box-shadow: 0 28px 75px unquote('rgba(var(--palette-primary-deep-rgb, 18, 8, 31), .4)'), inset 0 1px 0 rgba(255, 255, 255, .08); | |
backdrop-filter: blur(22px); | |
opacity: 0; | |
visibility: hidden; | |
pointer-events: none; | |
transform: translate(-50%, -46%) scale(.9); | |
transition: opacity .28s ease, visibility .28s ease, transform .36s cubic-bezier(.2, .85, .25, 1.1); | |
} | |
.about-gramophone__dialog-backdrop { | |
position: absolute; | |
z-index: 9; | |
inset: 0; | |
width: 100%; | |
height: 100%; | |
padding: 0; | |
border: 0; | |
background: unquote('rgba(var(--palette-primary-deep-rgb, 34, 24, 46), .08)'); | |
backdrop-filter: blur(0); | |
opacity: 0; | |
visibility: hidden; | |
pointer-events: none; | |
transition: opacity .25s ease, visibility .25s ease, backdrop-filter .25s ease; | |
} | |
.about-gramophone.is-console-open .about-gramophone__console { | |
position: fixed; | |
z-index: 10070; | |
max-height: calc(100vh - 30px); | |
opacity: 1; | |
visibility: visible; | |
pointer-events: auto; | |
transform: translate(-50%, -50%) scale(1); | |
} | |
.about-gramophone.is-console-open { | |
z-index: 10050; | |
overflow: visible; | |
} | |
.about-gramophone.is-console-open .about-gramophone__dialog-backdrop { | |
position: fixed; | |
z-index: 10060; | |
opacity: 1; | |
visibility: visible; | |
pointer-events: auto; | |
background: unquote('rgba(var(--palette-primary-deep-rgb, 30, 20, 43), .28)'); | |
backdrop-filter: blur(8px); | |
⋯ 未改动代码已省略 ⋯ | |
.about-gramophone__dialog-head { | |
grid-column: 1 / -1; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
color: var(--palette-primary-mist, #eee3ff); | |
font-size: 11px; | |
font-weight: 700; | |
letter-spacing: .12em; | |
} | |
.about-gramophone__dialog-head > span i { color: var(--gramophone-secondary); margin-right: 7px; } | |
.about-gramophone__dialog-head > button { | |
width: 30px; | |
height: 30px; | |
display: grid; | |
place-items: center; | |
padding: 0; | |
color: unquote('rgba(var(--palette-primary-mist-rgb, 242, 233, 255), .7)'); | |
border: 1px solid unquote('rgba(var(--palette-primary-pale-rgb, 219, 192, 249), .18)'); | |
border-radius: 9px; | |
background: rgba(255, 255, 255, .05); | |
cursor: pointer; | |
transition: color .2s ease, background .2s ease, transform .2s ease; | |
} | |
.about-gramophone__dialog-head > button:hover { | |
color: #fff; | |
background: unquote('rgba(var(--palette-primary-light-rgb, 255, 102, 183), .18)'); | |
transform: rotate(6deg); | |
} | |
.about-gramophone__cover { | |
position: relative; | |
width: 116px; | |
height: 116px; | |
grid-row: span 2; | |
overflow: hidden; | |
border: 1px solid unquote('rgba(var(--palette-primary-pale-rgb, 224, 199, 255), .16)'); | |
border-radius: 13px; | |
background: var(--palette-primary-deep, #15101f); | |
box-shadow: 0 8px 22px rgba(0, 0, 0, .24); | |
} | |
.about-gramophone__cover::after { | |
position: absolute; | |
inset: 0; | |
content: ''; | |
pointer-events: none; | |
background: linear-gradient(135deg, rgba(255, 255, 255, .14), transparent 42%); | |
} | |
.about-gramophone__cover img { | |
width: 100%; | |
height: 100%; | |
display: block; | |
object-fit: cover; | |
} | |
⋯ 未改动代码已省略 ⋯ | |
.about-gramophone__lyrics { | |
min-width: 0; | |
min-height: 68px; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
padding: 10px 13px; | |
box-sizing: border-box; | |
border: 1px solid unquote('rgba(var(--palette-primary-soft-rgb, 203, 168, 241), .12)'); | |
border-radius: 13px; | |
background: linear-gradient(125deg, unquote('rgba(var(--palette-primary-light-rgb, 171, 92, 255), .09)'), unquote('rgba(var(--palette-secondary-rgb, 39, 228, 255), .05)')); | |
} | |
.about-gramophone__lyrics span { | |
color: var(--gramophone-secondary); | |
font-size: 7px; | |
font-weight: 700; | |
letter-spacing: .14em; | |
} | |
.about-gramophone__lyrics strong, | |
.about-gramophone__lyrics small { | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
.about-gramophone__lyrics strong { | |
margin-top: 5px; | |
color: #fff; | |
font-size: 12px; | |
} | |
.about-gramophone__lyrics small { | |
margin-top: 2px; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 226, 216, 240), .48)'); | |
font-size: 8px; | |
} | |
.about-gramophone__queue { | |
min-width: 0; | |
grid-column: 1 / -1; | |
padding: 9px 10px 10px; | |
box-sizing: border-box; | |
border: 1px solid unquote('rgba(var(--palette-primary-soft-rgb, 203, 168, 241), .13)'); | |
border-radius: 13px; | |
background: linear-gradient(135deg, unquote('rgba(var(--palette-primary-light-rgb, 255, 95, 200), .055)'), unquote('rgba(var(--palette-secondary-rgb, 39, 228, 255), .045)')); | |
} | |
.about-gramophone__queue-head { | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
margin-bottom: 7px; | |
color: unquote('rgba(var(--palette-primary-mist-rgb, 242, 234, 252), .78)'); | |
font-size: 8px; | |
font-weight: 700; | |
letter-spacing: .08em; | |
} | |
.about-gramophone__queue-head i { margin-right: 5px; color: var(--gramophone-cyan); } | |
.about-gramophone__queue-head small { color: unquote('rgba(var(--palette-primary-pale-rgb, 228, 218, 242), .45)'); font-size: 7px; } | |
.about-gramophone__queue ol { | |
max-height: 116px; | |
display: grid; | |
grid-template-columns: repeat(2, minmax(0, 1fr)); | |
gap: 5px; | |
padding: 0 3px 0 0; | |
margin: 0; | |
overflow: auto; | |
list-style: none; | |
scrollbar-width: thin; | |
scrollbar-color: unquote('rgba(var(--palette-primary-light-rgb, 177, 92, 255), .48)') transparent; | |
scroll-behavior: smooth; | |
} | |
.about-gramophone__queue li { padding: 0; margin: 0; } | |
.about-gramophone__queue ol li::before { display: none !important; content: none !important; } | |
.about-gramophone__queue li.is-empty { | |
grid-column: 1 / -1; | |
padding: 12px; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 230, 220, 242), .48)'); | |
font-size: 8px; | |
text-align: center; | |
} | |
.about-gramophone__queue button { | |
width: 100%; | |
min-width: 0; | |
display: grid; | |
grid-template-columns: 22px minmax(0, 1fr) 16px; | |
align-items: center; | |
gap: 6px; | |
padding: 6px 7px; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 239, 232, 248), .66)'); | |
text-align: left; | |
border: 1px solid unquote('rgba(var(--palette-primary-pale-rgb, 217, 192, 244), .08)'); | |
border-radius: 9px; | |
outline: 0; | |
background: rgba(255, 255, 255, .035); | |
cursor: pointer; | |
transition: color .2s ease, border-color .2s ease, background .2s ease, transform .2s ease; | |
} | |
.about-gramophone__queue button > span:first-child { | |
color: unquote('rgba(var(--palette-secondary-rgb, 39, 228, 255), .56)'); | |
font-size: 7px; | |
font-variant-numeric: tabular-nums; | |
} | |
.about-gramophone__queue button > span:nth-child(2) { | |
min-width: 0; | |
display: flex; | |
flex-direction: column; | |
} | |
.about-gramophone__queue button strong, | |
.about-gramophone__queue button small { | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
.about-gramophone__queue button strong { color: inherit; font-size: 9px; font-weight: 600; } | |
.about-gramophone__queue button small { margin-top: 1px; color: unquote('rgba(var(--palette-primary-pale-rgb, 225, 215, 238), .4)'); font-size: 7px; } | |
.about-gramophone__queue button > i { color: unquote('rgba(var(--palette-primary-light-rgb, 255, 95, 200), .52)'); font-size: 8px; text-align: center; } | |
.about-gramophone__queue button:hover { | |
color: #fff; | |
border-color: unquote('rgba(var(--palette-secondary-rgb, 93, 227, 241), .24)'); | |
background: unquote('rgba(var(--palette-secondary-rgb, 89, 212, 235), .09)'); | |
transform: translateY(-1px); | |
} | |
.about-gramophone__queue button.is-current { | |
color: #fff; | |
border-color: unquote('rgba(var(--palette-primary-light-rgb, 177, 92, 255), .35)'); | |
background: linear-gradient(120deg, unquote('rgba(var(--palette-primary-light-rgb, 177, 92, 255), .2)'), unquote('rgba(var(--palette-secondary-rgb, 39, 228, 255), .1)')); | |
box-shadow: inset 2px 0 0 var(--gramophone-secondary); | |
} | |
.about-gramophone__queue button.is-current > i { color: var(--gramophone-cyan); } | |
.about-gramophone__timeline { | |
grid-column: 1 / -1; | |
display: grid; | |
grid-template-columns: auto minmax(0, 1fr) auto; | |
align-items: center; | |
gap: 8px; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 230, 220, 242), .52)'); | |
font-size: 7px; | |
} | |
.about-gramophone__modes { | |
grid-column: 1 / -1; | |
display: grid; | |
grid-template-columns: auto repeat(3, minmax(0, 1fr)); | |
align-items: center; | |
gap: 6px; | |
padding: 5px; | |
border: 1px solid unquote('rgba(var(--palette-primary-soft-rgb, 203, 168, 241), .12)'); | |
border-radius: 12px; | |
background: rgba(255, 255, 255, .028); | |
} | |
.about-gramophone__modes > span { | |
padding: 0 7px 0 5px; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 225, 215, 238), .46)'); | |
font-size: 7px; | |
letter-spacing: .08em; | |
white-space: nowrap; | |
} | |
.about-gramophone__modes button { | |
min-width: 0; | |
height: 28px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
gap: 5px; | |
padding: 0 7px; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 239, 232, 248), .6)'); | |
font: inherit; | |
font-size: 8px; | |
border: 1px solid transparent; | |
border-radius: 8px; | |
outline: 0; | |
background: transparent; | |
cursor: pointer; | |
transition: color .2s ease, border-color .2s ease, background .2s ease, box-shadow .2s ease, transform .2s ease; | |
} | |
.about-gramophone__modes button i { color: unquote('rgba(var(--palette-secondary-light-rgb, 128, 224, 245), .72)'); font-size: 9px; } | |
.about-gramophone__modes button em { overflow: hidden; font-style: normal; text-overflow: ellipsis; white-space: nowrap; } | |
.about-gramophone__modes button:hover { color: #fff; background: rgba(255, 255, 255, .055); transform: translateY(-1px); } | |
.about-gramophone__modes button.is-active { | |
color: #fff; | |
border-color: unquote('rgba(var(--palette-primary-light-rgb, 180, 123, 246), .34)'); | |
background: linear-gradient(120deg, unquote('rgba(var(--palette-primary-light-rgb, 177, 92, 255), .2)'), unquote('rgba(var(--palette-secondary-rgb, 39, 228, 255), .1)')); | |
box-shadow: inset 0 0 12px unquote('rgba(var(--palette-primary-light-rgb, 177, 92, 255), .08)'), 0 4px 12px unquote('rgba(var(--palette-primary-deep-rgb, 14, 10, 24), .14)'); | |
} | |
.about-gramophone__modes button.is-active i { color: var(--gramophone-cyan); } | |
.about-gramophone input[type='range'] { | |
min-width: 0; | |
height: 3px; | |
margin: 0; | |
appearance: none; | |
border-radius: 999px; | |
outline: 0; | |
background: linear-gradient(90deg, var(--gramophone-primary) var(--gramophone-progress, 0%), rgba(255, 255, 255, .13) var(--gramophone-progress, 0%)); | |
cursor: pointer; | |
} | |
.about-gramophone input[type='range']::-webkit-slider-thumb { | |
width: 10px; | |
height: 10px; | |
appearance: none; | |
border: 2px solid var(--palette-primary-mist, #f5ecff); | |
border-radius: 50%; | |
background: var(--gramophone-primary); | |
box-shadow: 0 0 10px unquote('rgba(var(--palette-primary-light-rgb, 171, 102, 255), .65)'); | |
} | |
.about-gramophone input[type='range']::-moz-range-thumb { | |
width: 8px; | |
height: 8px; | |
border: 2px solid var(--palette-primary-mist, #f5ecff); | |
border-radius: 50%; | |
background: var(--gramophone-primary); | |
box-shadow: 0 0 10px unquote('rgba(var(--palette-primary-light-rgb, 171, 102, 255), .65)'); | |
} | |
.about-gramophone__controls { | |
grid-column: 1 / -1; | |
display: flex; | |
align-items: center; | |
gap: 7px; | |
} | |
.about-gramophone__controls > button { | |
width: 30px; | |
height: 30px; | |
flex: 0 0 30px; | |
display: grid; | |
place-items: center; | |
padding: 0; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 239, 230, 249), .72)'); | |
font: inherit; | |
font-size: 9px; | |
border: 1px solid unquote('rgba(var(--palette-primary-soft-rgb, 206, 178, 242), .13)'); | |
border-radius: 10px; | |
outline: 0; | |
background: rgba(255, 255, 255, .04); | |
cursor: pointer; | |
transition: transform .2s ease, color .2s ease, background .2s ease; | |
} | |
.about-gramophone__controls > button:hover { color: #fff; transform: translateY(-1px); } | |
.about-gramophone__controls > button.is-primary { | |
width: 38px; | |
height: 38px; | |
flex-basis: 38px; | |
color: #fff; | |
font-size: 11px; | |
⋯ 未改动代码已省略 ⋯ | |
min-width: 82px; | |
display: flex; | |
align-items: center; | |
gap: 7px; | |
margin-left: auto; | |
color: unquote('rgba(var(--palette-primary-pale-rgb, 230, 220, 242), .58)'); | |
font-size: 9px; | |
} | |
.about-gramophone__volume input { width: 64px; } | |
.about-gramophone__source { display: none !important; } | |
.about-gramophone.is-playing .about-gramophone__cover img { | |
animation: gramophone-cover-breathe 3.2s ease-in-out infinite; | |
} | |
.about-gramophone.is-gramophone-fullscreen, | |
.about-gramophone:fullscreen { | |
position: fixed; | |
z-index: 10060; | |
inset: 0; | |
width: 100vw; | |
height: 100vh; | |
min-height: 0; | |
margin: 0; | |
border: 0; | |
border-radius: 0; | |
} | |
.about-gramophone.is-gramophone-fullscreen .about-gramophone__visual, | |
.about-gramophone:fullscreen .about-gramophone__visual { min-height: 100vh; } | |
.about-gramophone.is-gramophone-fullscreen .about-gramophone__console, | |
.about-gramophone:fullscreen .about-gramophone__console { |
themes/shoka/source/images/music-cover-default.svg完整文件
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="默认音乐封面"> | |
<defs> | |
<radialGradient id="bg" cx="32%" cy="24%" r="88%"> | |
<stop offset="0" stop-color="#d9b7ff"/> | |
<stop offset="0.46" stop-color="#9568df"/> | |
<stop offset="1" stop-color="#35244f"/> | |
</radialGradient> | |
<linearGradient id="note" x1="0" y1="0" x2="1" y2="1"> | |
<stop stop-color="#fff4ff"/> | |
<stop offset="0.54" stop-color="#8fe6ef"/> | |
<stop offset="1" stop-color="#f4a6d5"/> | |
</linearGradient> | |
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%"> | |
<feGaussianBlur stdDeviation="7" result="blur"/> | |
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge> | |
</filter> | |
</defs> | |
<rect width="240" height="240" rx="120" fill="url(#bg)"/> | |
<circle cx="120" cy="120" r="88" fill="none" stroke="#fff" stroke-opacity=".12" stroke-width="2"/> | |
<circle cx="120" cy="120" r="64" fill="none" stroke="#8fe6ef" stroke-opacity=".12"/> | |
<g fill="#fff" opacity=".42"> | |
<circle cx="60" cy="70" r="3"/><circle cx="180" cy="58" r="2"/><circle cx="190" cy="151" r="3"/> | |
<circle cx="49" cy="161" r="2"/><circle cx="151" cy="194" r="2"/> | |
</g> | |
<path d="M137 60v88.5c0 17.2-13.8 31.5-31.2 31.5-14.9 0-26.8-9.2-26.8-21.1 0-12.5 13-22.6 29-22.6 5.3 0 10.2 1.1 14 3V82l61-13v67.5c0 17.2-13.8 31.5-31.2 31.5-14.9 0-26.8-9.2-26.8-21.1 0-12.5 13-22.6 29-22.6 5.3 0 10.2 1.1 14 3V49z" fill="url(#note)" filter="url(#glow)"/> | |
</svg> | |
完成!现在关于页面拥有一台与真实音乐状态联动的 Three.js 三维留声机,并且不会给其他页面增加 Three.js 加载负担。