客户端功能 & 设计
每一项设计都解一个具体问题。下面是 iOS 客户端值得说的功能 + 对应的「为什么这么做」。
浏览
三轴艺人导航
页面顶部一条艺人横滑列表,下面是这位艺人的专辑 grid,再下面是专辑里的曲目。三层一目了然,不用点进点出三层导航。
为什么这么设计:
- 你脑子里通常先想到「我想听 XXX 的歌」(艺人优先),而不是从一堆封面里找
- 横滑 row 比纵向 list 更适合手机大屏的水平空间利用
- 国别 chip(中国 / 日本 / 欧美)+ 字母 chip 双过滤 —— 一千个艺人也能两三下定位
选中艺人自动滚到视野中心
搜索结果跳过来、初次进 App 加载上次留的艺人,被选中的头像会自动滚到艺人 row 的中间,加 1.05× 放大 + 紫色描边 + 阴影。
为什么: 不滚的话你大概率看不到被选中的人,因为它可能在屏幕外。视觉反馈解决「我点了,但没看到反应」这种焦虑。
搜索
递进字符搜索
输入框打一个字,下面立刻出「这字后面是哪个字?」的 chips。点 chip 接力一字一字筛,匹配 ≤ 8 项时直接列结果。
为什么这么设计:
- 手机敲字累,中文输入法尤其。「接力点字」比连续打字快得多
- 老歌名字总记不清完整(“那啥心动的浪漫……”),但能记起来开头一两字
- chip 列表本身就是数据库里存在的候选,永远不会出现「打完发现没结果」的挫败
后退一字 vs 全清空
输入框右边的按钮不是 ✗(全清),是 ⌫(删一字)。
为什么: 搜不到时第一反应是「太窄了,回退一字接着试」。全清会把上下文也清掉,反而要重新打。后退一字 = 优化你下一次尝试,不是惩罚你这一次失败。
保留上次搜索
App 关掉再打开,搜索框里上次打的字还在,下面的结果也立刻刷出来。
为什么: 你关掉 App 通常不是「搜完了」,而是「先去做别的,等下回来接着搜」。让 App 替你记住状态,比你又敲一遍要尊重你的时间。
封面 / 元数据
4 源切换换封面
长按专辑封面弹封面选择器,4 种来源:
- 本地 —— 相册里你自己的图(带剪裁 + 缩放 + 旋转)
- 服务器 —— 已经下载过的 / 你之前选过的
- CAA (Cover Art Archive) —— MusicBrainz 官方源
- Google 图片搜索 —— 内置 WebView,搜「歌手 空格 专辑名」
为什么:
- CAA 不全 / 经常是低分辨率 / 偶尔是错版本
- 你自己手头可能有最高清那张专辑实拍(CD 扫的)
- Google 搜出来的图你可以”看着对了”再选 —— 算法没法替你做这判断
手动指认曲目(含本地指纹库)
长按曲目 → 弹 MusicBrainz 搜索 → 选对的 recording。绑完,MusicTidy 自动算这首歌的 chromaprint 存到你本地指纹库。
为什么:
- 老 demo / 小厂牌发行 / 独立音乐人作品 → AcoustID 数据库根本没有
- 你做的每次指认是真知识,应该被沉淀,不能下次又从零来
- 以后你扒新文件时,自己指纹库先比对一遍 —— “我是不是已经有了”立刻有答案
长按 + 滑动:物理操作的轻重区分
- 轻点曲目 → 加到当前队列接着听(一秒钟反馈)
- 长按曲目 → 进重操作(指认 / 改 tag)—— 给你 0.5 秒”我是不是要这么做”的反悔时间
- 左滑队列里的曲目 → 出删除按钮(半屏)—— 比”全屏一滑就删”安全
为什么: 不同破坏性的动作配不同物理强度,符合直觉,手滑成本低。
队列 / 播放
队列追加不替换
进新专辑点第一首,不是把当前队列清掉重排。是把整张专辑追加到队列末尾,跳到该首开始播。老队列保留作 history。
为什么:
- 你换专辑很多时候是好奇试一下,不是放弃前面那张
- 「想再回去听前面那张刚跳过去那首」的概率,比你想的高
- 队列空间不要钱,保留比丢掉合理
队列页两级 + 保存为播放列表
队列页按专辑分组,每组可展开 / 收起。点专辑头切收起;点曲目跳到该首播。当前队列可一键保存为命名播放列表,存本地 UserDefaults。
为什么:
- 一次试听几张专辑下来队列就 100 多首,分组省下大量滚动
- 临时挑了一组顺序的歌,舍不得它消失 —— “保存为播放列表”是兜底
Tab 自切换:再点队列 → 当前曲目滚入视野
已经在队列 tab 时再点一次「队列」标题,自动展开当前播的专辑组 + 滚到该首。
为什么: 最常见的操作是「我刚加了一首,它在哪?」自动滚比手动翻整个队列快十倍。
播放器
顶部 tab 只能从标题滑
曲库 / 队列两个 tab,左右滑切换的手势只绑在 title 上,不绑内容区。
为什么: 内容区里到处都是可横滑的元素(艺人 row、专辑 grid)。绑在内容上的切换手势会跟内容滑动打架,造成”我想滑横向 chip 结果跳页了”。标题区是清晰、专属的切换入口。
锁屏控制 / CarPlay / 蓝牙耳机
完整支持 iOS 标准 MPRemoteCommandCenter:锁屏、控制中心、AirPods、车载方向盘按钮全都能 play / pause / next / prev。Now Playing 信息里有专辑封面(异步加载)。
为什么: 和系统集成不是可选项,是默认期待。
别的 app 抢音频也能正确恢复
YouTube / 电话 / Siri / 闹钟接管音频后,MusicTidy 的 UI 状态正确切到 paused;外部结束后如果系统暗示 .shouldResume,自动继续。
为什么:
长期手机 App 最容易出问题的就是「状态跟实际不一致」—— UI 显示在播但 AVPlayer 早就被踢停了。3 层保护:KVO timeControlStatus、AVAudioSessionInterruption、routeChangeNotification,保证状态始终是真的。
服务器 / 配置
服务器地址 3 字段分开(不是一个 URL 框)
scheme(https / http 下拉)+ host + port,三个独立字段。port 默认值跟着 scheme 走(https → 443 / http → 8765)。
为什么:
- 用户经常搞错协议写错端口写错冒号,整 URL 框踩坑率太高
- 三个字段分开,每个字段语义清楚、有 placeholder 提示、有错有针对性的报错
二维码扫描配置
服务器设置页有「扫二维码」按钮,从相册选一张包含 musictidy://server?scheme=...&host=...&port=... 的二维码图,App 用 Vision framework 解析后三字段自动填好。
为什么: 朋友推荐 / 网站 demo 这种场景,扫码比照着输 URL 友好太多。
服务器指纹握手
测试连接时,App 调 /healthz 检查响应里 "app": "MusicTidy" 字段。不是 MusicTidy 服务器会明确报错:「能连上,但不是 MusicTidy 服务器」。
为什么: 有时候 host 输错了打到别的 HTTP 服务上,普通连通性测试会绿勾欺骗你。指纹握手避免”看着像通了实际通不了”。
多服务器随时切换
设置页可以随时改服务器 URL(包括登录界面也有「更换服务器」按钮)。
为什么: 家里一个 NAS、出差时一个 VPS demo、试朋友的服务器 —— 不应该被锁死在一个。登录页加更换入口是关键 —— 不然你忘记密码 + 服务器换了就彻底死锁。
安全 / 登录
Face ID / Touch ID 登录
设置里开关一键启用。token 从 UserDefaults 搬进 Keychain(带 .biometryCurrentSet),下次打开 App 自动弹 Face ID 解锁。失败可以「改用密码登录」回退。
为什么:
- 每次重启 App 重输服务器密码很烦
- 单纯 UserDefaults 存 token 不安全(被 backup 偷走风险)
- Keychain + biometric guard 一键解决”既要省事又要安全”
退出登录会停掉正在播放
退出 → 先 player.stop() → 再清 token。
为什么: 不停的话退出之后还在偷偷播,下个用户启动 App 时尴尬。「退出」=「彻底结束这个会话」的物理直觉应该被尊重。
离线缓存
自动 LRU 5GB(默认开)
边听边自动落盘,超过 5GB 按 mtime 最旧的淘汰。不用任何手动操作。
为什么: 通勤一进地铁 / 飞行模式开启时,最近听的歌应该还能继续放。自动比要求用户「下载」直觉得多。
默认仅 WiFi
蜂窝时不被动缓存,省流量。
为什么: 不少用户流量套餐有限。默认仅 WiFi 是友好的,可在设置关掉。
主动整张下载 + 长按选码率
专辑详情页 ⬇️ 按钮:
- 短按:WiFi 直接源码下载;蜂窝弹「源码 30-40MB/首」码率选择 dialog
- 长按:任何时候都弹码率选择(源码 / AAC 256k / 192k / 128k / 96k)
为什么:
- 蜂窝下不弹个 confirm 一不小心 300MB 流量没了
- 同一张专辑出差 vs 平时 vs 高音质设备听 → 想要的码率不同。长按给细节控自由
缓存详情按专辑分组 + 左滑删
设置 → 当前用量 → 弹出列表,按专辑分组 + 总大小,左滑某行删整张。
为什么:
- 当用户想清出空间时,他想的是「那张专辑我已经不听了」,不是「那 17 首文件」
- 按专辑视图 = 用户脑中的单位
清缓存后 UI 实时刷新
清空 / 删某张专辑后,AlbumDetailView 上的绿色对号立即消失,不需要重新进页面才看到。
为什么: 状态和真实磁盘要绝对一致。否则用户看到对号还在会怀疑「删了没?」
微交互(每一次点击都是设计)
点底部 PlayerBar → 跳到正在播的专辑详情,自动滚到当前曲
不是弹一个浮窗,是直接 navigate 到那张专辑页 + 把当前曲目滚到列表中间。
为什么:
- “我现在听的这首歌是哪张专辑的第几首?前面后面是什么?” —— 这是听音乐时最高频的问题
- 一次点击直达上下文,比”打开播放器看下名字 → 退出 → 找艺人 → 找专辑”少 4 步
PlayerBar 跳过去时,如果你刚好在那张专辑页 → 弹 FullPlayer
聪明的兜底:当前 NavigationStack 已经在播放的那张专辑了,再点 PlayerBar 不重复 navigate,直接弹全屏播放器。
为什么: 不该让”已经达成目的的点击”反而变成 noop 或重复 push。
在专辑详情页点 PlayerBar 时切歌的话也保留
切歌后专辑详情页里的 EqualizerBars 动画跟着移动到新曲那行,封面、按钮”在播”状态都跟着新曲。
为什么: 专辑页是个连续观察界面,应该反映实时状态,不应该是静态截图。
切歌时自动滚到该曲那一行
ScrollViewReader .onChange(of: player.current?.id) 触发,动画把新当前曲目滚到屏幕中间。同时顺手刷一遍下载状态(被动缓存可能刚把上一首落盘,绿对号要亮起来)。
为什么: 肉眼能看到”现在在播第几首” + 顺手验证缓存状态 = 一举两得。
再点已选 tab → 触发上下文动作
- 已经在「队列」tab 再点「队列」→ 自动展开当前播放专辑组 + 滚到该首
- (未来可加) 已经在「曲库」tab 再点「曲库」→ 滚回顶部
为什么: 原生 iOS Tab Bar 就是这种行为 —— 重复点击是”回到这个 tab 的根/最重要状态”。我们的 custom pager 也跟着这套直觉。
长按 vs 短按的语义对照
| 触发 | 短按 | 长按 |
|---|---|---|
| 曲目 | 加入队列接着播 | 弹手动指认 sheet |
| 专辑封面 | (无) | 换封面 4 源选择器 |
| 下载 ⬇️ 按钮 | 源码下载(蜂窝时 confirm) | 选码率(5 档) |
| 艺人头像 | 选中这位艺人 + 加载专辑 | (无) |
为什么: 长按 = “我知道我在做不可逆/重操作”。给手指 0.5 秒收回的时间,符合所有手机 OS 的肌肉记忆。
搜索结果点击的明确含义
| 你点的是 | 跳到哪 |
|---|---|
| 艺人 | 那位艺人的专辑页 |
| 专辑 | 那位艺人的专辑页(专辑作为入口,而不是直接播) |
| 曲目 | 直接播这首(你点曲目就是想听) |
为什么: 点专辑直接播不合理 —— 你可能只是想看看里面有什么。曲目反过来:搜出来点一下就是想听,不绕弯。
“队列追加 vs 替换” 严格区分
- 进新专辑点单曲 → 整张专辑追加到队列末尾,跳到该首播。老队列保留。
- 主动点「加入队列」按钮 → 也是追加。
- 你唯一会清队列的操作 = 在队列页左滑删除该曲目 / 删除整张专辑。
为什么: 你的播放历史不应该因为你「想听点别的」就被清掉。听完想回去再听刚才那首 → 队列里还在。
拉刷新 = 触发服务器 scan
曲库页下拉 → 服务器跑一次 scan,等几秒再刷艺人列表。
为什么: 你刚把新文件 rsync 进服务器,下拉刷新是最直觉的”现在去看一下”动作。比专门进设置点按钮短得多。
退出登录前先停播
退出 → player.stop() → await api.logout() → 清 token。顺序固定。
为什么: 不停的话 token 都清了,AVPlayer 还在用旧 token 拉下一首流 → 401 → 但歌还放完已下载的部分。这种「半生不死」状态很违背直觉。
Toast 是非破坏性反馈
成功的操作(保存为播放列表 / 自定义封面成功 / 等等)用底部 toast 提示,2 秒自动消失。失败的操作用红字 inline 显示,不消失直到你重试。
为什么: 成功操作不需要用户做任何事,toast 自动消失最不打扰。失败需要用户决定下一步,所以要 inline + 保留信息直到处理。
调试 & 透明
模拟蜂窝 toggle(DEBUG 版本)
设置最下面调试 section(Release 构建里没有):勾选后强制把网络判定成蜂窝。
为什么: 模拟器 NWPathMonitor 永远报 WiFi,没这开关没法测蜂窝分支的所有 UX 流程(流量警告 / AAC 转码 / 下载 confirm 等)。
Bullhead 错误透明
服务器返回错误时不是黑屏,给具体的 toast / 红字:「连不上 xxx」/ “能连上但不是 MusicTidy 服务器” / “密码错误”等等。
为什么: 自托管用户经常调环境,错误信息能让他们一步定位问题。
不在这里看到的功能 + 设计原由可以问 GitHub Discussions 或 Issues。