feat(lan): experimental LAN web server for iOS Safari playback#2262
feat(lan): experimental LAN web server for iOS Safari playback#22620Chencc wants to merge 21 commits into
Conversation
Add the bare bones for an opt-in HTTP server that other devices on the LAN can reach. v0 only stands up the lifecycle plumbing: a shelf-based server, a mobx controller wired into the existing Modular tree, an entry under "我的 → 实验性功能", and an auto-start hook in InitPage that honours the new lanServerEnable setting. The server currently exposes nothing beyond `/` and `/healthz` — the plugin API, video proxy, HTML client and mDNS broadcast will land in subsequent slices.
LanServer now serves the three endpoints a future web client needs to
browse the catalogue:
- GET /api/plugins — list installed plugins
- GET /api/search — proxy Plugin.queryBangumi with proper
captcha / no-result / failure surfacing
- GET /api/episodes — proxy Plugin.querychapterRoads
The controller is now constructed with a lazy PluginsController
provider so we don't poke at Modular before its DI graph is ready. The
"实验性功能" entry in 我的 is now gated behind Utils.isDesktop() — mobile
builds have no use for it and we don't want it to look like an Android
toggle.
Video resolution + the HTML client + mDNS are still ahead.
Promote shelf to a direct dependency and add shelf_router + its transitive http_methods. No version changes to anything previously locked.
A remote browser can't run the WebView scraping pipeline or speak the plugin's Referer/Cookie game, so the LAN server now stands in for both: - LanSourceResolver wraps WebViewVideoSourceProvider behind a Lock so parallel /api/resolve calls don't trample each other's `_resolveId`. - /api/resolve drives the resolver, mints a ProxySession describing the upstream URL + the plugin's Referer / UA, hands back a random token, and returns the local play URL the client should hit. - ProxySessionStore keeps tokens in memory with a 2h TTL. Tokens are 16 bytes from Random.secure, URL-safe base64 encoded. - VideoProxyHandler streams /proxy/<token> and /proxy/<token>/<sub> through HttpClient with Referer/UA injected and Range passed through. Hop-by-hop headers and content-encoding are stripped on the way back so shelf doesn't double-encode. - M3u8Rewriter rewrites the resource URIs inside m3u8 playlists — EXTINF segments, EXT-X-STREAM-INF children, plus the URI="..." attribute on EXT-X-KEY / EXT-X-MAP — so segments keep flowing through us instead of escaping out to the origin and hitting CORS or Referer checks in the browser. CORS middleware is now permissive (Access-Control-Allow-Origin: *) and exposes Content-Range so browser video elements can seek.
A single-file, no-CDN, dark-themed SPA that lets an iOS Safari user (or any other browser on the LAN) drive the same search → episodes → play flow the desktop client offers, but without the native binary. - hash-router with three views: search, episode grid, player - <video playsinline webkit-playsinline> so iOS doesn't force full-screen takeover; relies on iOS Safari's native HLS support for the m3u8 output of the proxy - last-used plugin and keyword cached in localStorage so reopens land somewhere useful - safe-area-inset paddings + apple-mobile-web-app meta for sane home- screen-add behaviour on iOS No danmaku, no resume, no collection — that's deliberately out of scope for the MVP slice.
…(v1d) Add bonsoir-backed DNS-SD broadcast so Kazumi shows up under "Network" in the Finder, in iOS's network discovery sheet, in avahi-browse, etc. The broadcast is best-effort — if it fails (most commonly on a Windows host without Bonjour Print Services / iTunes), the HTTP server keeps running and we surface a neutral message in the settings page instead of an error. The settings page now also offers a `http://<hostname>.local:<port>` shortcut on macOS / Linux, where the OS's own mDNS responder already publishes the hostname. We deliberately don't claim this works on Windows — the user will need to access the LAN IP there. Locked plugin registrants regenerated for macOS / Windows targets to pick up bonsoir's native side. This closes out the v1 scope: a remote browser can search, pick an episode and play, and surface-level Bonjour discovery is in place.
Rewrites the served HTML/CSS to look like the desktop app instead of a generic dark page: - A proper M3-style colour token set (surface / surface-container-* / primary / on-primary / outline / outline-variant ...) with a dark branch that activates via [data-theme="dark"] or via prefers-color-scheme when [data-theme="auto"]. - /api/theme exposes the host's themeMode (system / light / dark) and primaryColor (parsed out of the SettingBoxKey.themeColor ARGB string, default Material green). Web client fetches this on boot and applies it before the first render so there's no theme flash. - /assets/<file> route serves binary assets out of rootBundle; the client uses it to load MiSans for type that matches the desktop app. - The shell is no longer a single scroll page — there's a sticky app bar with a contextual back button, an M3 search bar with a pill-shaped submit, episode chips that highlight in primary-container on press, and tonal buttons for secondary actions. No functional change yet — the views are still search → episodes → player, and HLS still relies on browser-native support (Safari only). hls.js fallback is the next slice.
Ship hls.js (1.5.20, ~415KB minified) as an asset and lazy-load it from
the player view only when the browser doesn't speak HLS natively. Safari
continues to use the OS-level HLS pipeline and never pays the JS cost.
- /api/resolve now also returns streamType ("hls" | "mp4" | "unknown")
inferred from the original URL extension. The client uses it together
with video.canPlayType to decide whether to attach hls.js or just set
video.src.
- ensureHlsLoaded() injects <script src="/assets/hls.min.js"> at most
once per session. The Hls instance is created with enableWorker, has
basic recovery for NETWORK_ERROR / MEDIA_ERROR, and is destroyed when
the route leaves /play.
- player-meta now labels the path it took (HLS 原生 / HLS hls.js / MP4 /
直链), which makes "why doesn't it play" reports much easier.
This unblocks Android Chrome, desktop Chrome / Edge / Firefox.
Switch the web home from a plugin-first quick lane to the same
bangumi-first flow the desktop client uses:
- New endpoints proxy the existing BangumiApi:
/api/bangumi/search, /api/bangumi/{id},
/api/bangumi/{id}/characters | /comments | /staff. Characters are
pre-sorted 主角 → 配角 → 客串 just like InfoPage does.
- The home view is now a Bangumi search bar that renders BangumiItem
cards (cover / titles / score / air date / summary clamp) and links
into a detail page.
- /bangumi?id=X view: hero header with a blurred-cover backdrop and a
proper cover card, score + stars + rank + vote count, tags chips,
and the same four tabs the desktop app shows (简介 / 角色 / 制作 /
吐槽). Tab bodies lazy-load on first activation.
- A floating "开始观看" button opens a modal sheet that re-uses the
/api/search plugin path: pick a rule, see plugin hits (pre-filled
with the Bangumi 中文名), tap one to jump into the existing
/episodes → /play flow. The bangumi id is carried as `bid` in the
hash so v3c can attach watch history to it.
- Modal sheet, bangumi cards, hero, tabs, chips, character/staff/
comment cards, and FAB are all new M3-style components.
Plugin-first search is retained internally as pluginSearchOnce() —
nothing else uses it as a primary view anymore.
GET /api/danmaku?bangumiId=X&episode=N walks the same bgm-id → dandan-id → comments chain as the desktop client (DanmakuApi). The desktop's Color values are encoded back to RGB ints, and the original [BiliBili] / [Gamer] / [DanDan] source string is carried through so the client can filter later. The player view now wraps <video> in a .player-wrap container that hosts a DanmakuLayer canvas overlay. The layer: - Sorts incoming comments by time and uses a binary-search seek detector so jumping the timeline doesn't dump a backlog of stale comments onto the screen - Allocates lanes for rolling / top / bottom comments and drops a comment when no lane is free (matches canvas_danmaku's behaviour) - Pauses emission while video.paused, throttles itself to the actual frame rate via requestAnimationFrame deltas - Persists config (enabled / fontSize / opacity / scroll-duration) in localStorage so settings survive page reloads A collapsible <details> panel below the player exposes those four knobs plus a comment-count badge. Danmaku only loads when both `bid` and `episode` are present in the route — the episode grid was updated to forward both, source picker already carries `bid` from the bangumi detail page. Carrying bid + episode also sets up v3c's watch-history sync.
The web client now reads from and writes to the same GStorage that
desktop Kazumi uses, so a session you started in one place keeps going
in the other.
Backend:
- GET /api/history / list — single record / all histories
- POST /api/history/progress — upserts a Progress, honours
privateMode by going through
IHistoryRepository directly
- DELETE /api/history — drop a record
- GET /api/collect / list — current CollectType for a bangumi /
every collectible (optional ?type
filter)
- PUT /api/collect — set CollectType 1..5, type=0 deletes
- DELETE /api/collect — explicit delete
_resolveBangumiItem first looks in the existing history, then in the
collectibles box, and only hits BangumiApi when the user has never
touched this bangumi locally — so /api/collect PUT doesn't burn a
network call on the common path.
Frontend:
- Detail page sprouts a "+ 收藏 / 已在看 / 已想看 ..." button below the
hero. Tapping it opens a sheet with the five CollectType options +
"取消收藏". State is fetched on entry.
- Player view's resolve flow now, when bid + episode + plugin are all
present, asks /api/history, seeks to progresses[episode] if the saved
position is past five seconds, then fires an interval every five
seconds that POSTs the current time. It also flushes on pause and on
pagehide so we don't lose the last few seconds when you swipe back.
- /episodes carries `road` (0-indexed) alongside `episode`, so we
match the desktop's Progress(road, episode, ms) shape exactly.
Re-shape the web home page to match the desktop app's bottom-tab structure. /home now accepts a ?tab= parameter and renders one of: - **推荐**: keeps the Bangumi search bar at the top so the entry point doesn't move, then renders the BangumiApi trending list as a poster grid below. - **时间表**: 7-day chips with the current weekday pre-selected (Mon=1 to match desktop's Calendar layout); tapping a chip switches the grid. - **追番**: groups the user's CollectedBangumi by CollectType (在看 / 想看 / 搁置 / 看过 / 抛弃) — same labels as CollectType.label. - **我的**: the History list (sorted desc by lastWatchTime), each row showing cover, last-watched episode name + date, and the plugin that served the source. Tapping a row jumps to the bangumi detail page. Backend gets two thin wrappers: - GET /api/popular?offset=&limit= → BangumiApi.getBangumiTrendsList - GET /api/timeline → BangumiApi.getCalendar, 7 days The bottom tab bar is a fixed glass bar (backdrop-filter blur) with safe-area-inset padding. A .bottom-spacer keeps content from being clipped by it. This closes v3 — the web client now mirrors the desktop client's main surfaces end-to-end.
Reworks the web UI from the ground up so it actually looks like Kazumi instead of a generic dark page: Layout - Drops the sticky top app bar. - Introduces a left NavigationRail (84px) that mirrors the desktop's menu: a green circular search button on top, four primary tabs (推荐 / 时间表 / 追番 / 我的), and a settings entry at the bottom. The active item gets a tonal pill background. - On narrow viewports (<600px) the rail collapses into a sticky bottom NavigationBar so phones stay usable. - Detail / episodes / player views render an inline back-button + page-title pair inside .content instead of relying on the removed app bar. Colour - Dark palette is now near-black (--surface #060708) to match the desktop screenshot, with the multi-level surface-container ladder toned down. outline-variant is nearly invisible by default so cards don't carry borders. Cards - Bangumi search result rows, episode tiles, history rows, character / staff / comment cards and the danmaku panel all drop their borders. They show a quiet hover/active state on surface-container instead. - Poster grid renders only cover + 2-line title (no score / no air date overlay) at 7:10 aspect with 12px corners, three across on mobile. - Detail page summary turns into prose (no card chrome); chips stay pill-shaped but borderless. Search - The Bangumi search bar is no longer pinned to 推荐. Tapping the green search button in the rail opens a dedicated /search view with the input auto-focused. FAB - 56dp filled square FAB with the host primary colour, lifted above the bottom nav on narrow screens. The four-tab content (popular / timeline / collect / my) shows a 28px weighted page title (with optional ▾ chevron on 推荐 to match the desktop's tag-picker affordance). No functional changes — danmaku, progress sync, collect management, and the resolve / proxy chain are all untouched.
Stop hand-rolling Material 3 tokens in the web client. The web visual
system is now driven by the same ColorScheme / textTheme the desktop
app uses, pushed live over SSE.
Server side
- lib/bean/settings/effective_color_scheme_notifier.dart: a global
ValueNotifier<EffectiveColorScheme?> that exposes whichever
ColorScheme app_widget.dart's DynamicColorBuilder ended up choosing
(which is the same one Flutter's MaterialApp.router will render).
- lib/app_widget.dart: in the DynamicColorBuilder.builder body, push
the resolved light + dark colorSchemes into the notifier (microtask
defers it past the current build so we don't fire listeners during
build).
- lib/lan/theme_export.dart: buildScheme()/exportColorTokens()/
exportTypographyTokens(). Goes through ColorScheme.fromSeed (same
call Flutter makes internally) instead of poking at
material_color_utilities, so SDK upgrades won't drift. OLED enhance
matches Utils.oledDarkTheme: only surface/onSurface/onPrimary/
onSecondary get overridden in dark mode.
- lib/lan/lan_server.dart:
* /api/theme upgraded to v2 schema. Keeps the v1 fields
(themeMode/primaryColor/useDynamicColor) for compat; adds
oledEnhance + schemes.{light,dark} (33 hex fields each, mapped
1:1 from ColorScheme) + typography (15 M3 type roles with size /
weight / letterSpacing / height).
* /api/theme/stream is a new SSE endpoint. Listens on
GStorage.setting.watch() filtered to theme-related keys, and on
effectiveColorSchemeNotifier. CORS middleware short-circuits this
path so shelf doesn't buffer the stream.
* Initial Color hex parsing now uses Color.r/g/b (Flutter 3.27+)
to stay current with the deprecation of Color.value.
Web side
- lib/lan/web_index_html.dart shrinks to a thin shell that interpolates
three new constants from lib/lan/web/:
* web_styles.dart — all CSS (the old <style> block)
* web_app_script.dart — router / views / theme / nav rail
* web_player_script.dart — DanmakuLayer / HLS / progress reporting
Cleaner diffs going forward — CSS changes won't touch JS files.
- applyTheme() now rebuilds a <style id="theme-tokens"> element that
defines :root[data-theme="light"] / [dark] / [auto] blocks with
every ColorScheme field as a CSS variable (camelCase → kebab-case).
Typography roles emit --display-large-size / -weight / -letter-
spacing / -height etc. They're declared now but consumed in v5b.
- EventSource("/api/theme/stream") drives applyTheme() live, with a
5s reconnect on error (handles iOS Safari background tab freezes
and server restarts).
No visible UX change in this commit — CSS still references the same
variable names it did in v4. v5b will rewrite DOM/CSS to consume the
new tokens.
The web client now consumes the ColorScheme + textTheme v5a started
streaming, and the DOM/CSS layout mirrors the desktop client's
NavigationRail / Card / SliverAppBar widget tree instead of a
generic dark page.
Layout (matches lib/pages/menu/menu.dart exactly)
- body / .nav-rail background: surfaceContainer
- .content background: primaryContainer + top-left/bottom-left
16px radius, which is the M3 NavigationRail-vs-content visual
separation the desktop app draws via ClipRRect.
- On portrait < 600px the rail collapses into a bottom NavigationBar,
search drops out of the bar (also matching menu.dart's
bottomMenuWidget), .content stretches edge-to-edge with no radius.
NavigationRail (M3 spec, mirrors desktop destinations)
- 56dp circular FAB on top using primaryContainer / on-primaryContainer,
search SVG icon.
- 4 destinations bottom-aligned (groupAlignment: 1.0 from menu.dart).
- Each destination has an inline pill `nav-indicator` (56x32 dp);
active gets secondaryContainer fill, inactive shows just the icon.
- Filled / outlined icon pairing for the 4 destinations
(home / timeline / favorite / settings), driven by the new
Material Symbols SVG path table embedded in the script.
- labelType: selected — label only renders for the active item, just
like NavigationRailLabelType.selected on desktop.
Tokens
- All colour CSS now resolves through CSS variables that applyTheme()
(v5a) populates from the server's ColorScheme.fromSeed output —
not hand-tuned approximations.
- Typography: --display-large-{size,weight,letter-spacing,height}
through --label-small-* are declared with M3 defaults and rebuilt
every time applyTheme runs. Selectors across the page now refer to
the right role (page-title → headline-medium, .item → body-large,
.ep → label-large, .chip → label-small, .modal-title → title-large,
etc).
State layer
- Drops the "swap background colour" hack in favour of M3-spec
semi-transparent overlays: every interactive surface has a
::before pseudo-element whose opacity flips between 0 → 0.08 (hover)
→ 0.12 (pressed). Variables `--state-{hover,focus,pressed,dragged}-
opacity` expose the values centrally.
Poster card (mirrors BangumiCardV)
- Aspect 0.65, 12dp image corner, title in titleSmall with
letterSpacing 0.3, padding fromLTRB(5,3,5,1) — these are the exact
Card values in lib/bean/card/bangumi_card.dart.
- Grid columns: 3 / 5 / 6 across the 600 / 840 breakpoints — matches
popular_page.dart's contentGrid.
Bits and pieces
- ICONS map exposes the SVG paths we needed (Material Symbols set),
so pageHeader back, search submit, and the play-arrow on the
bangumi detail FAB all use real Material icons instead of unicode
arrows.
- The detail page "开始观看" FAB becomes an M3 Extended FAB with
the play-arrow leading icon.
Player / danmaku / progress / collect / hls.js logic untouched —
v5b is pure visual.
Misread menu.dart in v5b. The Container(color: primaryContainer) on line 174 wraps a ClipRRect → PageView → RouterOutlet → PopularPage's own Scaffold, and that Scaffold's backgroundColor (unset, so defaults to colorScheme.surface) covers the entire visible area. primaryContainer is only ever painted in the tiny corner triangle between the rounded clip and the rectangular Container — effectively invisible. So the real visible "content background" on desktop is surface, not primaryContainer. Adjust the web .content rule to match. Drop the 16dp left corners along with it (they had no effect once the colour went away). Other layers (nav-rail, hero gradient end, on-primary-container text colour) stay as they were until each gets its own audit pass.
UI: - Detail page hero: banner overlay, title-first layout, 6-state CollectButton - Timeline: season picker (时间机器), weekday TabBar, sort/filter sheet, horizontal cards - Source picker: Plugin TabBar with concurrent queries + status dots + alias/manual search - Episodes page: 选集/评论 2 tabs, road switcher, per-episode comments - Player: prev/next, playback rate, aspect, reload, back overlay - Popular: tag dropdown, infinite scroll, return-to-top FAB - Collect: 5 tabs (在看/想看/搁置/看过/抛弃) with counts - Search: 3 sorts, 2 filters, 10-item history Backend: - /api/bangumi/<id>/episodes + /api/bangumi/<id>/episode-comments - /api/timeline?season=YYYY-Q (4x20 accumulated) - /api/popular?tag=... (delegates to getBangumiList) - HLS proxy runs media playlists through M3u8AdFilter (mirrors ffmpeg hls_ad_filter) Infra: - Persistent server port (random first run, user-editable, conflict fallback) - Remove SSE theme stream → visibilitychange lazy refresh (SSE on Windows + dart:io stalls subsequent short requests, causing ERR_EMPTY_RESPONSE) - Add access log + error handler middleware, /favicon.ico 204 shim
Flutter 3.44 stopped having package:flutter/material.dart re-export the cupertino library, so CupertinoPageTransitionsBuilder (used by pageTransitionsTheme2024) no longer resolves through material alone. Add an explicit `import 'package:flutter/cupertino.dart';` to fix the Windows build error: lib/utils/constants.dart(33,29): error G311314CC: Method not found: 'CupertinoPageTransitionsBuilder'.
Adapt LAN web slice to main's services/ migration & typed settings registry:
- services/ migration: lan/* now imports from services/{logging,storage,
video_source} instead of utils/* and providers/video/* (renames carried
by main; only paths changed, semantics preserved)
- SettingBoxKey replaced by SettingsKeys typed registry
(services/storage/settings_keys.dart); register lanServerEnable and
lanServerPort under SettingGroup.misc
- LanServerController & LanServer switch from raw GStorage.setting.get/put
to GStorage.getSetting/putSetting wrappers
- WebViewVideoSourceProvider -> WebViewVideoSourceService (LanSourceResolver)
- Utils.getRandomUA() -> top-level getRandomUA() (utils/http_headers.dart)
- _danmakuToJson: Danmaku -> DanmakuEntry (modules/danmaku rename)
- Dedupe duplicate cupertino import in constants.dart (auto-merge artifact
from both branches independently adding the same Flutter 3.44 fix)
- init_page _lanServerInit: migrate setting.get to GStorage.getSetting
main's utils -> services migration removed Utils.isDesktop but left my_page.dart referencing the deleted Utils class and utils/utils.dart. Point it at the top-level isDesktop() in utils/device.dart.
- _handlePopular / _handleTimeline: respect enableBangumiProxy setting, delegate to getBangumiMirrorPopularSubjects / getBangumiMirrorSeasonCalendar when mirror is enabled (aligns with PopularController / TimelineController) - _handleBangumiDetail: add _retryNullable (3 attempts, 400ms gap) for transient mirror timeouts; change 404 "not found" to 502 "上游暂时不可用" - _handleBangumiComments / _handleEpisodeComments: catch mirror 401 gracefully, return 502 with user-friendly message instead of leaking NetworkException - Web frontend: add retryBlock() helper for centered error + retry button; apply to detail load failure, tucao tab, and episode comments tab
| final decoded = utf8.decode(base64Url.decode(subPath)); | ||
| // 简单校验是 http(s) URL | ||
| final uri = Uri.parse(decoded); | ||
| if (!uri.hasScheme || (uri.scheme != 'http' && uri.scheme != 'https')) { |
There was a problem hiding this comment.
WARNING: SSRF / open-proxy: the proxied sub-URL is only validated to have an http/https scheme, with no restriction on the target host.
A client that holds any session token can request /proxy/<token>/<base64url(arbitrary-url)> and the server will fetch any http(s) URL — including loopback (http://127.0.0.1:port/...), link-local metadata (http://169.254.169.254/...) and other RFC1918 hosts on the machine's network. Because the server binds to InternetAddress.anyIPv4 with wildcard CORS and no authentication, any device on the LAN effectively gets a general-purpose proxy into the host's internal network.
Consider restricting the decoded sub-URL to the same host/origin as session.originalUrl (or an allowlist), and rejecting loopback/link-local/private target addresses.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| if (persistPreference) { | ||
| await GStorage.putSetting(SettingsKeys.lanServerEnable, true); | ||
| } | ||
| } catch (e, st) { |
There was a problem hiding this comment.
WARNING: Inconsistent state / resource leak on the failure path. After the HTTP server (and possibly the mDNS broadcaster) are already started at lines 62-69, any throw from GStorage.putSetting (line 65 or 71) or refreshAddresses lands here and resets isRunning = false / port = null without calling _server.stop() / _mdns.stop().
The server keeps listening and mDNS keeps broadcasting while the controller reports stopped, and a later stop() returns early because of the if (!isRunning) return; guard (line 106) — so the user can never cleanly shut it down. The catch block should tear down _server and _mdns before clearing the flags.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| if (!hadAny) { | ||
| // 对齐应用端:吐槽拉取失败只在 tab 内显示软状态 + 重试,不暴露 | ||
| // 内部异常文本。镜像模式下吐槽接口常返 401,重试按钮让用户自行再试。 | ||
| list.append(retryBlock("吐槽获取失败,请重试", () => renderTabBody())); |
There was a problem hiding this comment.
WARNING: The 吐槽 retry button calls renderTabBody() with no argument, but renderTabBody(key) (line 1602) dispatches on key. With key === undefined none of the branches match, so it only clears tabBody.innerHTML and renders nothing — clicking “重试” blanks the whole tab area instead of reloading the comments. Pass the current tab key:
| list.append(retryBlock("吐槽获取失败,请重试", () => renderTabBody())); | |
| list.append(retryBlock("吐槽获取失败,请重试", () => renderTabBody("tucao"))); |
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Notes / Assumptions
Fix these issues in Kilo Cloud Files Reviewed (key files)
Reviewed by claude-4.8-opus-20260528 · Input: 3.8K · Output: 18.1K · Cached: 1.6M |
Summary
Adds an experimental LAN HTTP server that lets iOS users watch anime via Safari on the same network, bypassing the need for self-signed iOS certificates.
The desktop Kazumi client (macOS/Windows/Linux) hosts a lightweight web service. An iPhone/iPad on the same LAN opens the URL in Safari and gets a full browsing + playback experience powered by the desktop's plugin system and video proxy.
Key features
Web client (pure HTML/CSS/JS, no Flutter Web)
/api/themeServer infrastructure
hls_ad_filter)enableBangumiProxysetting for popular/timeline endpointsSettings page
Files changed
lib/lan/— entire new directory (server, proxy, web client, mDNS, theme export)lib/pages/settings/lan/— settings UIlib/pages/init_page.dart— auto-start on launchlib/pages/my/my_page.dart— settings entry point (desktop only)lib/services/storage/settings_keys.dart—lanServerEnable,lanServerPortTest plan