Skip to content

Commit 17cf854

Browse files
committed
release: v0.8.9 统一Flow交互内核与Library升级
1 parent 2afe653 commit 17cf854

16 files changed

Lines changed: 4421 additions & 150 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ TikLocal provides some configuration options that you can adjust to your needs.
124124
## Documentation
125125

126126
- Docs index: `docs/README.md`
127+
- Flow interaction unification: `docs/flow-interaction-unification.md`
127128
- Release notes: `docs/release_notes.md`
128129

129130

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
## 文档列表
88

99
- `docs/mixed-feed-design.md`:首页混合流(视频+图片)设计与实现说明。
10+
- `docs/flow-interaction-unification.md`:Home / Library / Favorites 统一交互内核设计与落地说明。
1011
- `docs/release_notes.md`:版本发布记录与未发布变更清单。
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Flow 交互统一架构(Home / Library / Favorites)
2+
3+
- 状态: 已落地
4+
- 更新时间: 2026-02-22
5+
6+
## 背景/目标
7+
8+
- 首页(`/`)与 Library/Favorites(`/library``/favorite`)曾长期维护两套交互状态机,导致行为分叉与回归风险上升。
9+
- 典型问题:
10+
1. 首页与 Library 的沉浸模式切换规则不一致。
11+
2. 图片放大镜与沉浸状态存在冲突,跨媒体切换后行为不稳定。
12+
3. 同类逻辑(时间格式化、放大镜取样几何)在两个模板重复实现。
13+
- 目标:建立“统一交互内核 + 页面适配器”模型,保证三入口行为一致并降低维护成本。
14+
15+
## 结论/方案
16+
17+
采用两层共享 + 页面适配的收敛方案:
18+
19+
1. 共享状态层:`flow_state_controller.js`
20+
- 提供统一状态机:`immersive` / `magnifying`
21+
- 统一规则:
22+
1. 进入沉浸时,若放大镜开启则自动关闭放大镜。
23+
2. 开启放大镜时,自动退出沉浸。
24+
3. 当前媒体非图片时,放大镜强制不可用。
25+
4. 媒体切换时执行 `onMediaChanged()`,确保状态不残留。
26+
27+
2. 共享 UI 工具层:`flow_ui_shared.js`
28+
- 统一函数:
29+
1. `formatTime(seconds)`
30+
2. `getImageContainRect(imgEl)`
31+
3. `setMagnifierPosition(lensEl, x, y)`
32+
4. `updateMagnifierContent(...)`
33+
- 目的:消除首页与 Library 的重复几何/时间逻辑,避免单点修复失效。
34+
35+
3. 页面适配层(保留页面特有渲染)
36+
- 首页:`tiklocal/templates/tiktok.html`
37+
- Library/Favorites:`tiklocal/templates/library.html`
38+
- 仅保留页面差异(数据源、DOM 结构、按钮布局),核心状态流与通用算法走共享模块。
39+
40+
## 统一交互约定
41+
42+
1. 单击:统一切换沉浸状态(不再区分“视频沉浸 / 图片专注”双模式)。
43+
2. 双击(视频):播放/暂停。
44+
3. 放大镜:仅图片可用;激活即退出沉浸;切换媒体自动关闭。
45+
4. 关闭入口:保持右上角关闭按钮可用(Library/Favorites)。
46+
5. Favorites:复用 `library.html`,天然继承统一规则。
47+
48+
## 影响范围
49+
50+
- 模板:
51+
1. `tiklocal/templates/tiktok.html`
52+
2. `tiklocal/templates/library.html`
53+
- 静态资源:
54+
1. `tiklocal/static/flow_state_controller.js`
55+
2. `tiklocal/static/flow_ui_shared.js`
56+
- 测试:
57+
1. `tests/test_library_upgrade.py`
58+
59+
## 风险与权衡
60+
61+
- 权衡:引入共享脚本文件会增加少量模块边界,但显著降低模板内重复和状态分叉。
62+
- 风险:
63+
1. 模板脚本注入顺序错误会导致运行时找不到共享对象。
64+
2. 共享层改动会同时影响首页与库页,需要明确回归清单。
65+
66+
## 回归清单(建议固定执行)
67+
68+
1. 首页视频进入沉浸后,滑到图片保持沉浸状态一致。
69+
2. 图片开启放大镜时自动退出沉浸,且可正常拖拽镜头。
70+
3. Library 与 Favorites 的手势行为一致(上下滑切换、按钮可用)。
71+
4. 首页与 Library 的放大镜取样均无横向压扁。
72+
73+
## 后续事项
74+
75+
- [ ] 抽取第三层共享(视频进度条与 AI 标题面板渲染助手),进一步减少模板内脚本体积。
76+
- [ ] 增加端到端交互测试,覆盖“沉浸 ↔ 放大镜 ↔ 媒体切换”链路。
77+
- [ ] 评估将共享脚本迁移到打包流程,减少模板内内联逻辑规模。

docs/mixed-feed-design.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212

1313
- 后端新增 `GET /api/feed/mix`,统一返回 typed items(`video` / `image`)。
1414
- 前端首页改为单一 Feed 状态机,按条目类型切换播放与交互能力。
15-
- 图片条目复用 Gallery 的关键体验:AI 标题/标签、圆形放大镜、单击专注模式(仅隐藏左下信息层)。
15+
- 图片条目复用 Gallery 的关键体验:AI 标题/标签、圆形放大镜。
16+
- 后续收敛:沉浸行为已统一为“视频/图片同一沉浸模型”,不再维护单独图片专注模式分支。
1617

1718
## 关键决策与理由
1819

1920
- 混排策略使用“目标比例 + 轻随机 + 连续段约束”,而不是固定 `4V+1I`:降低用户可预测性,保持内容新鲜感。
2021
- 图片不自动切换:避免打断阅读图片细节,改为手动滑动控制节奏。
2122
- 视频与图片工具分离:视频不显示 AI 按钮,图片不显示倍速按钮,减少认知噪音。
22-
- 专注模式只隐藏左下 AI 信息层:保留右侧操作按钮,避免关键操作入口丢失
23+
- 沉浸模式统一跨媒体保持,放大镜作为图片工具态独立控制:降低状态切换歧义并减少交互冲突
2324

2425
## 实施要点
2526

@@ -36,7 +37,7 @@
3637

3738
- 首页沉浸流入口:`/`
3839
- API:`/api/feed/mix`
39-
- 交互行为:单击、双击、滑动、图片专注、图片放大与 AI 信息层展示。
40+
- 交互行为:单击沉浸切换、双击播放控制、上下滑切换、图片放大镜与 AI 信息层展示。
4041

4142
## 风险与权衡
4243

@@ -54,5 +55,8 @@
5455

5556
- `tiklocal/app.py`
5657
- `tiklocal/templates/tiktok.html`
58+
- `tiklocal/static/flow_state_controller.js`
59+
- `tiklocal/static/flow_ui_shared.js`
60+
- `docs/flow-interaction-unification.md`
5761
- `tests/test_feed_mix.py`
5862
- `docs/release_notes.md`

docs/release_notes.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# Release Notes
22

33
## Unreleased
4+
- 待补充
5+
6+
## v0.8.9 (2026-02-22)
7+
- 信息架构升级:底部导航调整为 `Flow / Library / Favorites / Download / Settings`,其中 `Library` 成为视频+图片统一入口,`Favorites` 独立为一级入口。
8+
- Library 交互重构为极简 Masonry:移除顶部传统筛选表单与卡片冗余文本,仅保留媒体本身的沉浸式浏览。
9+
- 新增 `/api/library/items` 与前端无限加载:支持按 `scope=all/favorite` 分页拉取,满足大规模素材连续浏览。
10+
- 新增 Library Quick Viewer Flow:列表内就地预览视频/图片,支持上下滑切换并保留“进入独立详情页”入口,降低跳转割裂感。
11+
- 交互统一收敛:首页与 Library/Favorites 改为同一沉浸状态模型(视频/图片一致),放大镜作为图片工具态独立控制。
12+
- 新增共享交互内核:`flow_state_controller.js`(状态)与 `flow_ui_shared.js`(时间/放大镜几何),降低多入口行为分叉风险。
13+
- 兼容迁移:`/browse``/gallery` 改为重定向到 `/library`,详情页与删除后的回跳统一指向新 Library 入口。
414

515
## v0.8.8 (2026-02-22)
616
- 首页沉浸流升级为混合媒体 Feed:在同一滑动流中混排视频与图片,替代原纯视频首页链路。

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "TikLocal"
3-
version = "0.8.8"
3+
version = "0.8.9"
44
description = "A local media server that combines the features of TikTok and Pinterest"
55
authors = ["ChanMo <chan.mo@outlook.com>"]
66
readme = "README.md"

tests/test_library_upgrade.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import os
2+
3+
import pytest
4+
5+
from tiklocal.app import create_app
6+
7+
8+
@pytest.fixture
9+
def client(tmp_path, monkeypatch):
10+
media_root = tmp_path / "media"
11+
media_root.mkdir(parents=True, exist_ok=True)
12+
for i in range(6):
13+
(media_root / f"v{i:02d}.mp4").write_bytes(b"00")
14+
for i in range(6):
15+
(media_root / f"i{i:02d}.jpg").write_bytes(b"00")
16+
17+
# Create one large video for big_files mode.
18+
(media_root / "big.mp4").write_bytes(b"0" * (2 * 1024 * 1024))
19+
20+
# Keep stable ordering for latest mode.
21+
for idx, p in enumerate(sorted(media_root.iterdir())):
22+
ts = 1_700_000_000 + idx
23+
os.utime(p, (ts, ts))
24+
25+
data_root = tmp_path / "tiklocal-data"
26+
monkeypatch.setenv("MEDIA_ROOT", str(media_root))
27+
monkeypatch.setenv("TIKLOCAL_INSTANCE", str(data_root))
28+
29+
app = create_app({"TESTING": True, "MEDIA_ROOT": media_root})
30+
return app.test_client()
31+
32+
33+
def test_library_page_has_mode_tabs_and_no_masonry_label(client):
34+
res = client.get("/library")
35+
assert res.status_code == 200
36+
body = res.data.decode("utf-8")
37+
assert "data-mode=\"all\"" in body
38+
assert "data-mode=\"image_random\"" in body
39+
assert "data-mode=\"video_latest\"" in body
40+
assert "data-mode=\"big_files\"" in body
41+
assert "Masonry" not in body
42+
assert "id=\"quick-source\"" in body
43+
assert "id=\"quick-close-top\"" in body
44+
assert "id=\"quick-speed\"" in body
45+
assert "id=\"quick-caption\"" in body
46+
assert "id=\"quick-magnifier\"" in body
47+
assert "id=\"quick-play-status\"" in body
48+
assert "#quick-view.immersive .quick-caption-panel" in body
49+
assert "image-focus" not in body
50+
assert "flow_ui_shared.js" in body
51+
assert "flow_state_controller.js" in body
52+
assert "createFlowStateController(" in body
53+
assert "uiShared.updateMagnifierContent({" in body
54+
55+
56+
def test_home_feed_uses_unified_immersive_model(client):
57+
res = client.get("/")
58+
assert res.status_code == 200
59+
body = res.data.decode("utf-8")
60+
assert "body.immersive-mode .caption-panel:not(.is-hidden)" in body
61+
assert "flow_ui_shared.js" in body
62+
assert "flow_state_controller.js" in body
63+
assert "createFlowStateController(" in body
64+
assert "flowState.toggleImmersive()" in body
65+
assert "flowState.setMagnifying(active)" in body
66+
assert "uiShared.updateMagnifierContent({" in body
67+
assert "image-focus-mode" not in body
68+
69+
70+
def test_api_library_items_supports_modes_and_seed(client):
71+
all_res = client.get("/api/library/items?scope=all&mode=all&offset=0&limit=20")
72+
assert all_res.status_code == 200
73+
all_data = all_res.get_json()
74+
assert all_data["success"] is True
75+
all_items = all_data["data"]["items"]
76+
assert any(item["type"] == "video" for item in all_items)
77+
assert any(item["type"] == "image" for item in all_items)
78+
79+
video_res = client.get("/api/library/items?scope=all&mode=video_latest&offset=0&limit=20")
80+
video_items = video_res.get_json()["data"]["items"]
81+
assert len(video_items) > 0
82+
assert all(item["type"] == "video" for item in video_items)
83+
84+
image_res = client.get("/api/library/items?scope=all&mode=image_random&offset=0&limit=20&seed=fixed")
85+
image_items = image_res.get_json()["data"]["items"]
86+
assert len(image_items) > 0
87+
assert all(item["type"] == "image" for item in image_items)
88+
assert image_res.get_json()["data"]["seed"] == "fixed"
89+
90+
big_res = client.get("/api/library/items?scope=all&mode=big_files&offset=0&limit=20&min_mb=1")
91+
big_items = big_res.get_json()["data"]["items"]
92+
assert len(big_items) >= 1
93+
assert all(item["type"] == "video" for item in big_items)
94+
assert any(item["name"] == "big.mp4" for item in big_items)
95+
96+
97+
def test_api_library_items_no_duplicates_across_offsets(client):
98+
seen = set()
99+
offset = 0
100+
for _ in range(8):
101+
res = client.get(f"/api/library/items?scope=all&mode=all&offset={offset}&limit=4")
102+
assert res.status_code == 200
103+
data = res.get_json()["data"]
104+
names = [item["name"] for item in data["items"]]
105+
for name in names:
106+
assert name not in seen
107+
seen.add(name)
108+
if not data["has_more"]:
109+
break
110+
offset = int(data["next_offset"])
111+
112+
113+
def test_api_library_items_dedupes_symlink_aliases(tmp_path, monkeypatch):
114+
media_root = tmp_path / "media"
115+
media_root.mkdir(parents=True, exist_ok=True)
116+
(media_root / "origin.mp4").write_bytes(b"abc")
117+
(media_root / "img.jpg").write_bytes(b"abc")
118+
119+
alias = media_root / "alias.mp4"
120+
try:
121+
alias.symlink_to(media_root / "origin.mp4")
122+
except (OSError, NotImplementedError):
123+
pytest.skip("Symlink not supported in this environment")
124+
125+
data_root = tmp_path / "tiklocal-data"
126+
monkeypatch.setenv("MEDIA_ROOT", str(media_root))
127+
monkeypatch.setenv("TIKLOCAL_INSTANCE", str(data_root))
128+
app = create_app({"TESTING": True, "MEDIA_ROOT": media_root})
129+
local_client = app.test_client()
130+
131+
res = local_client.get("/api/library/items?scope=all&mode=all&offset=0&limit=50")
132+
assert res.status_code == 200
133+
items = res.get_json()["data"]["items"]
134+
video_items = [item for item in items if item["type"] == "video"]
135+
assert len(video_items) == 1
136+
assert video_items[0]["name"] in {"origin.mp4", "alias.mp4"}
137+
138+
139+
def test_legacy_browse_and_gallery_redirects(client):
140+
browse = client.get("/browse", follow_redirects=False)
141+
assert browse.status_code in {301, 302, 308}
142+
assert browse.headers.get("Location", "").startswith("/library?mode=video_latest")
143+
144+
browse_big = client.get("/browse?filter=big&min_mb=3", follow_redirects=False)
145+
assert browse_big.status_code in {301, 302, 308}
146+
assert "mode=big_files" in browse_big.headers.get("Location", "")
147+
assert "min_mb=3" in browse_big.headers.get("Location", "")
148+
149+
gallery = client.get("/gallery", follow_redirects=False)
150+
assert gallery.status_code in {301, 302, 308}
151+
assert gallery.headers.get("Location", "").startswith("/library?mode=image_random")
152+
153+
154+
def test_favorite_scope_and_detail_links(client):
155+
client.post("/api/favorite/v01.mp4")
156+
client.post("/api/favorite/i01.jpg")
157+
158+
res = client.get("/api/library/items?scope=favorite&mode=all&offset=0&limit=20")
159+
assert res.status_code == 200
160+
data = res.get_json()["data"]
161+
names = {item["name"] for item in data["items"]}
162+
assert "v01.mp4" in names
163+
assert "i01.jpg" in names
164+
assert any(item["detail_url"] == "/detail/v01.mp4" for item in data["items"])
165+
assert any(item["detail_url"] == "/image?uri=i01.jpg" for item in data["items"])

0 commit comments

Comments
 (0)