Lavender's Blog

浩瀚众星,皆降为尘

读一段故事,赴一场相遇

把 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.jsthree.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 });
  });

这样加载顺序就是:

  1. 优先请求配置的 CDN;
  2. CDN 失败时请求站点自己的 /js/three.module.min.js
  3. 本地入口再加载同目录的 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, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;')

为什么必须转义?因为歌单数据最终会进入 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 %}

添加新歌单时,只需要继续增加一组 titlelist

- title: 新歌单名称
  list:
    - https://music.163.com/playlist?id=新的歌单ID

YAML 对缩进非常敏感。 listtitle 对齐,歌单地址要比 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 }
})()

这里有三个容易忽略的地方:

  1. alpha: true 让 WebGL 画布支持透明背景;
  2. setClearColor(..., 0) 才会真正清空默认黑色背景;
  3. 像素比限制为 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()
}

允许的值分别是 looporderrandom ,对应单曲循环、列表播放和随机播放。

歌词同样直接读取隐藏播放器已经同步好的节点:

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)

#常见问题

#页面完全没有留声机

依次检查:

  1. source/about/index.md 是否写了 gramophone: true
  2. 是否同时写了 gramophone 开始标记和 endgramophone 结束标记;
  3. 控制台是否提示 Cannot find module 'js-yaml'
  4. 页面源代码中是否存在 id="about-gramophone"
  5. CONFIG.js.threeCONFIG.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 。然后检查换片中点前后的坐标、旋转和缩放是否完全连续;同一个进度点不能突然从大唱片跳成小唱片。动画结束时还要恢复 positionrotationscale

#退出全屏后跳到页面顶部

进入全屏前保存 window.pageYOffset ,退出后在两个动画帧以及短延时后恢复一次。只恢复一次通常会被浏览器自己的全屏收尾滚动覆盖。

#手机端模型被裁切

优先调整摄像机距离和容器高度,不要直接缩放整个网页。画布应保持 width: 100% ,并在 ResizeObserver 中重新计算渲染尺寸和相机宽高比。

#测试

完成修改后执行:

hexo clean
hexo generate --bail
hexo server

建议按下面顺序验收:

  1. 直接打开关于页面,留声机可以正常显示;
  2. 从首页通过菜单进入关于页面,模型仍能初始化;
  3. 播放时唱片、摇把、唱针、喇叭声浪和频谱都有对应变化;
  4. 暂停后唱片、摇把和声浪停止,唱针归位;
  5. 点击唱片架可展开歌单,不选择唱片时再次点击架体可以收回;
  6. 换片过程中唱片会平滑改变大小和方向,没有缺角或突然跳动;
  7. 点击正面屏幕可打开面板,并能选择歌单中的具体歌曲;
  8. 打开面板时,曲目列表会自动滚动到当前播放歌曲;
  9. 单曲循环、列表播放和随机播放都能切换,并与原播放器保持一致;
  10. 封面、歌词、进度、音量、上一首和下一首同步正确;
  11. 进入再退出全屏,页面仍停在原来的位置;
  12. 连续进入、离开页面三次,控制台没有 WebGL 或重复监听报错;
  13. 打开其他文章,网络面板中没有加载 Three.js、 gramophone.jsgramophone.css
  14. 手机端画布、弹窗和曲目列表没有横向溢出;
  15. 执行 hexo generate --bail 能完整生成站点。

#总结

这个留声机不是一个独立的新播放器,而是 Shoka 原音乐系统的三维交互外壳。这样的拆分带来三个好处:

  1. 歌单解析、音源切换和歌词继续复用主题成熟逻辑;
  2. Three.js 只负责视觉表现,后续可以单独调整模型而不破坏音乐功能;
  3. 通过页面开关、PJAX 生命周期和资源销毁,复杂三维效果不会拖慢其他页面。

以后想继续扩展时,可以增加唱片收藏信息、不同材质主题或更多环境动画,但仍建议保持 “音乐状态唯一、三维表现可销毁、资源按页加载” 这三个原则。

#涉及改动的代码

代码卡片按属性名、函数名或带内容校验的具名片段定位,并显示当前源码行号。片段前后插行可自动重新定位;片段本身改变或位置不唯一时会停止生成,核对教程后再更新摘录,避免误引其他功能。

下面仅展示这项功能涉及的改动位置,不展示站点配置文件中的其他私人内容。行号以当前项目为准,后续修改后可能发生偏移,定位时优先搜索函数名或选择器。

three.module.min.jsthree.core.min.jslib/build-three.cjs 在生成站点时从 npm 包中的未压缩 ESM 制作,不要手工编辑,也不在文章中展开生成结果。构建辅助文件的完整内容见上面的第 1 步;下方资源代码卡片展示它在生成器中的实际调用。 public 中的文件同样由 Hexo 自动生成。

#依赖、页面开关与资源地址

package.json改动位置:第 82 行 1 行 · 23 B
package.json
    "three": "0.186.0",

themes/shoka/_config.yml 只展示本教程新增的 Three.js 地址,不公开其余站点配置:

以下是应合并到 themes/shoka/_config.yml 的脱敏示例,不再按行号读取实际站点配置:

source/code-examples/shoka-tutorial/gramophone.yml完整文件 6 行 · 181 B
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 行 1 行 · 58 B
themes/shoka/layout/_partials/layout.njk
    {%- if page.gramophone %}gramophone: true,{%- endif %}

themes/shoka/scripts/generaters/script.js改动位置:第 5、47、49、60、72、74、180–183 行 10 行 · 503 B
themes/shoka/scripts/generaters/script.js
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 行 84 行 · 5.4 KB
themes/shoka/scripts/tags/gramophone.js
/* global hexo */
 
'use strict'
 
const yaml = require('js-yaml')
 
const escapeHTML = value => String(value == null ? '' : value)
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;')
 
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 行 41 行 · 973 B
source/about/index.md
---
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 行 25 行 · 911 B
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 || !$('#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 行 1335 行 · 64.1 KB
themes/shoka/source/js/gramophone.js
(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 行 533 行 · 16.0 KB
themes/shoka/source/css/gramophone.styl
.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完整文件 27 行 · 1.5 KB
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 加载负担。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Lavender 微信支付

微信支付

Lavender 支付宝

支付宝