Files
MeBox/web/src/stores/readerSettings.ts
T
truewhile 2494f0c0cd 优化
2026-10-02 23:09:35 +08:00

281 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// 阅读主题(色值对齐 legado assets/defaultData/readConfig.json 的 6 套内置主题,
// 含日/夜两态,见 docs/reader-ui-spec.md 第 2/10 节)。
export interface ReaderTheme {
id: string
name: string
bg: string
text: string
accent: string
nightBg: string
nightText: string
nightAccent: string
}
export const READER_THEMES: ReaderTheme[] = [
{ id: 'weread', name: '微信读书', bg: '#C0EDC6', text: '#0B0B0B', accent: '#E53935', nightBg: '#000000', nightText: '#ADADAD', nightAccent: '#FE4D55' },
{ id: 'preset1', name: '预设1', bg: '#FFFFFF', text: '#000000', accent: '#E53935', nightBg: '#000000', nightText: '#FFFFFF', nightAccent: '#FE4D55' },
{ id: 'preset2', name: '羊皮纸', bg: '#DDC090', text: '#3E3422', accent: '#834E00', nightBg: '#3C3F43', nightText: '#DCDFE1', nightAccent: '#FE4D55' },
{ id: 'preset3', name: '护眼绿', bg: '#C2D8AA', text: '#596C44', accent: '#E53935', nightBg: '#3C3F43', nightText: '#88C16F', nightAccent: '#FE4D55' },
{ id: 'preset4', name: '粉紫', bg: '#DBB8E2', text: '#68516C', accent: '#801314', nightBg: '#3C3F43', nightText: '#F6AEAE', nightAccent: '#90BFF5' },
{ id: 'preset5', name: '淡蓝', bg: '#ABCEE0', text: '#3D4C54', accent: '#E53935', nightBg: '#3C3F43', nightText: '#90BFF5', nightAccent: '#FE4D55' },
]
export function getReaderTheme(themeId: string, night: boolean): { bg: string; text: string; accent: string } {
const t = READER_THEMES.find((x) => x.id === themeId) ?? READER_THEMES[1]
return night
? { bg: t.nightBg, text: t.nightText, accent: t.nightAccent }
: { bg: t.bg, text: t.text, accent: t.accent }
}
export type ReaderPageMode = 'page' | 'scroll'
export type ReaderHomeMode = 'media' | 'reading'
/** 书架布局:网格封面 / 常规列表 / 紧凑列表(对应 legado AppConfig.bookshelfLayout)。 */
export type ReaderShelfLayout = 'grid' | 'list' | 'compact'
/**
* 书架排序(对应 legado AppConfig 的 bookSort):
* recent=最近阅读、update=最近更新、name=书名、author=作者、mixed=综合、manual=手动顺序。
*/
export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed' | 'manual'
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
/**
* 漫画图片的显示尺寸档位(上下滚动模式用)。
*
* legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),
* 桌面端看不到也放不大,所以补上这一组档位。屏幕越宽越该放大,属于设备级偏好,
* 和 comicDoublePage 一样只存本机、不参与账号同步。
*
* - `default` 现状:正文列封顶 900px 居中,图片铺满该列
* - `width` 适应宽度:正文列不再封顶,图片铺满窗口宽度
* - `height` 适应高度:每页高度贴合一屏(一屏一页),宽度按原始比例
* - `long` 适应长边:整页完整可见,宽高都不超出视口
* - `original` 原始像素 1:1:既不放大也不缩小,超出部分横向滚动
*/
export type ComicImageFit = 'default' | 'width' | 'height' | 'long' | 'original'
/** 漫画图片尺寸档位的展示顺序与标签(界面面板按此渲染)。 */
export const COMIC_IMAGE_FITS: { id: ComicImageFit; label: string }[] = [
{ id: 'default', label: '默认' },
{ id: 'width', label: '适应宽度' },
{ id: 'height', label: '适应高度' },
{ id: 'long', label: '适应长边' },
{ id: 'original', label: '原图' },
]
interface ReaderSettingsState {
// 首页模式切换(影视 / 阅读)
homeMode: ReaderHomeMode
setHomeMode: (mode: ReaderHomeMode) => void
// ── 书架布局与展示(对应 legado 的书架设置)──
/** 布局样式。 */
shelfLayout: ReaderShelfLayout
setShelfLayout: (layout: ReaderShelfLayout) => void
/** 网格列数,0 为自适应。 */
shelfGridColumns: number
setShelfGridColumns: (columns: number) => void
/** 排序方式。 */
shelfSort: ReaderShelfSort
setShelfSort: (sort: ReaderShelfSort) => void
/** 是否显示未读章数徽标。 */
shelfShowUnread: boolean
setShelfShowUnread: (show: boolean) => void
/** 是否显示「更新时间」一行(仅列表布局)。 */
shelfShowUpdateTime: boolean
setShelfShowUpdateTime: (show: boolean) => void
themeId: string
setThemeId: (id: string) => void
night: boolean
toggleNight: () => void
setNight: (night: boolean) => void
pageMode: ReaderPageMode
setPageMode: (mode: ReaderPageMode) => void
/** 漫画左右「双页铺开」偏好(legado 无对应项,是 Web 端桌面化补充)。 */
comicDoublePage: boolean
setComicDoublePage: (on: boolean) => void
/** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
comicImageFit: ComicImageFit
setComicImageFit: (fit: ComicImageFit) => void
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
/**
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
* 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把
* searchScope 存在本机 AppConfig),换设备重新默认全选即可。
*/
searchScopeIds: string[]
setSearchScopeIds: (ids: string[]) => void
fontSize: number
setFontSize: (size: number) => void
lineHeight: number
setLineHeight: (lh: number) => void
paragraphSpacing: number
setParagraphSpacing: (v: number) => void
// ── 听书(音频源播放器)偏好,对齐 legado AudioPlayService ──
/** 播放倍速(AudioPlay.playSpeed),0.5–3.0,步进 0.1。 */
audioSpeed: number
setAudioSpeed: (v: number) => void
/** 定时关闭默认分钟数(AppConfig.ttsTimer 语义),0 表示不定时。 */
audioTimerMinutes: number
setAudioTimerMinutes: (v: number) => void
}
/** 听书倍速可选值(legado 是 0.1 步进的浮点,这里收在 0.5–3.0)。 */
export const AUDIO_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]
/** 定时关闭预设分钟数(legado ReadAloudDialog times 数组)。 */
export const AUDIO_TIMERS = [0, 5, 10, 15, 30, 60, 90, 180]
/** 片头/片尾秒数上限(0 表示不跳过)。 */
export const AUDIO_CREDITS_MAX = 300
export const useReaderSettingsStore = create<ReaderSettingsState>()(
persist(
(set) => ({
homeMode: 'media',
setHomeMode: (mode) => set({ homeMode: mode }),
shelfLayout: 'grid',
setShelfLayout: (shelfLayout) => set({ shelfLayout }),
shelfGridColumns: 0,
setShelfGridColumns: (shelfGridColumns) =>
set({ shelfGridColumns: Math.min(6, Math.max(0, Math.round(shelfGridColumns))) }),
shelfSort: 'recent',
setShelfSort: (shelfSort) => set({ shelfSort }),
shelfShowUnread: true,
setShelfShowUnread: (shelfShowUnread) => set({ shelfShowUnread }),
shelfShowUpdateTime: true,
setShelfShowUpdateTime: (shelfShowUpdateTime) => set({ shelfShowUpdateTime }),
themeId: 'preset1',
setThemeId: (themeId) => set({ themeId }),
night: false,
toggleNight: () => set((s) => ({ night: !s.night })),
setNight: (night) => set({ night }),
pageMode: 'page',
setPageMode: (pageMode) => set({ pageMode }),
// 默认开启:桌面端(窗口够宽)漫画自动两页铺开,窄屏(手机/窗口拉窄)
// 由渲染层按窗口宽度自动退回单页,所以这里开着不会影响手机阅读。
// 刻意不做跨设备同步:双页是否合适取决于屏幕宽度,属于设备级偏好,
// 和 homeMode 同理(见 utils/readerSettingsSync.ts 的载荷字段)。
comicDoublePage: true,
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
// 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
// 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
// 点「原图」。
comicImageFit: 'default',
setComicImageFit: (comicImageFit) => set({ comicImageFit }),
searchScopeIds: [],
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
fontSize: 20,
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
lineHeight: 1.8,
setLineHeight: (lineHeight) => set({ lineHeight: Math.min(2.6, Math.max(1.4, Math.round(lineHeight * 10) / 10)) }),
paragraphSpacing: 8,
setParagraphSpacing: (paragraphSpacing) =>
set({ paragraphSpacing: Math.min(32, Math.max(0, paragraphSpacing)) }),
audioSpeed: 1,
setAudioSpeed: (audioSpeed) =>
set({ audioSpeed: Math.min(3, Math.max(0.5, Math.round(audioSpeed * 10) / 10)) }),
audioTimerMinutes: 0,
setAudioTimerMinutes: (audioTimerMinutes) =>
set({ audioTimerMinutes: Math.min(180, Math.max(0, Math.round(audioTimerMinutes))) }),
}),
{ name: 'mebox-reader-settings' },
),
)
/**
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
* (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
* 取决于屏幕宽度和分辨率。
*/
export interface ReaderSettingsProfile {
theme_id: string
night: boolean
page_mode: ReaderPageMode
font_size: number
line_height: number
paragraph_spacing: number
audio_speed: number
audio_timer_minutes: number
shelf_layout: ReaderShelfLayout
shelf_grid_columns: number
shelf_sort: ReaderShelfSort
shelf_show_unread: boolean
shelf_show_update_time: boolean
}
/** 把本地状态整理成 /reader/profile 的载荷(只含参与同步的字段)。 */
export function readerSettingsPayload(s: ReaderSettingsState): ReaderSettingsProfile {
return {
theme_id: s.themeId,
night: s.night,
page_mode: s.pageMode,
font_size: s.fontSize,
line_height: s.lineHeight,
paragraph_spacing: s.paragraphSpacing,
audio_speed: s.audioSpeed,
audio_timer_minutes: s.audioTimerMinutes,
shelf_layout: s.shelfLayout,
shelf_grid_columns: s.shelfGridColumns,
shelf_sort: s.shelfSort,
shelf_show_unread: s.shelfShowUnread,
shelf_show_update_time: s.shelfShowUpdateTime,
}
}
/**
* 用服务端的偏好覆盖本地状态(服务端是权威来源)。
* 走 store 的 setter 而不是 setState,保证范围收敛只有一处实现。
*/
export function applyReaderSettingsProfile(p: ReaderSettingsProfile): void {
const s = useReaderSettingsStore.getState()
s.setThemeId(p.theme_id)
s.setNight(p.night)
s.setPageMode(p.page_mode)
s.setFontSize(p.font_size)
s.setLineHeight(p.line_height)
s.setParagraphSpacing(p.paragraph_spacing)
s.setAudioSpeed(p.audio_speed)
s.setAudioTimerMinutes(p.audio_timer_minutes)
s.setShelfLayout(p.shelf_layout)
s.setShelfGridColumns(p.shelf_grid_columns)
s.setShelfSort(p.shelf_sort)
s.setShelfShowUnread(p.shelf_show_unread)
s.setShelfShowUpdateTime(p.shelf_show_update_time)
}