(kAPPModals);
36 | const { showAPPConfig } = data ?? {};
37 | const { jumpToPage } = usePages();
38 | const closeModal = () => {
39 | showAPPConfigModal(false);
40 | };
41 | return (
42 | {
47 | closeModal();
48 | jumpToPage('settings'); // 点击打开设置页面
49 | }}
50 | cancel="取消"
51 | onCancel={closeModal}
52 | >
53 |
54 |
61 | 搜索失败,请检查以下事项:
62 |
63 | 1. 网络连接是否正常
64 |
65 | 2. 视频源配置是否正确
66 |
67 | 3. 是否设置了有效的飞鱼 Proxy
68 |
69 |
70 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/packages/feiyu-desktop/scripts/io.js:
--------------------------------------------------------------------------------
1 | import fs from "fs-extra";
2 | import path from "path";
3 |
4 | export const getFiles = (dir) => {
5 | return new Promise((resolve) => {
6 | fs.readdir(dir, (err, files) => {
7 | resolve(err ? [] : files);
8 | });
9 | });
10 | };
11 |
12 | export const copyFile = (from, to) => {
13 | if (!fs.existsSync(from)) {
14 | return false;
15 | }
16 | const dirname = path.dirname(to);
17 | if (!fs.existsSync(dirname)) {
18 | fs.mkdirSync(dirname, { recursive: true });
19 | }
20 | return new Promise((resolve) => {
21 | fs.copy(from, to, (err) => {
22 | resolve(err ? false : true);
23 | });
24 | });
25 | };
26 |
27 | export const moveFile = (from, to) => {
28 | if (!fs.existsSync(from)) {
29 | return false;
30 | }
31 | const dirname = path.dirname(to);
32 | if (!fs.existsSync(dirname)) {
33 | fs.mkdirSync(dirname, { recursive: true });
34 | }
35 | return new Promise((resolve) => {
36 | fs.rename(from, to, (err) => {
37 | resolve(err ? false : true);
38 | });
39 | });
40 | };
41 |
42 | export const deleteFile = (filePath) => {
43 | try {
44 | fs.rmSync(filePath);
45 | return true;
46 | } catch {
47 | return false;
48 | }
49 | };
50 |
51 | export const readString = (filePath) => {
52 | const dirname = path.dirname(filePath);
53 | if (!fs.existsSync(dirname)) {
54 | return undefined;
55 | }
56 | return new Promise((resolve) => {
57 | fs.readFile(filePath, "utf8", (err, data) => {
58 | resolve(err ? undefined : data);
59 | });
60 | });
61 | };
62 |
63 | export const writeJSON = (filePath, json) => {
64 | const dirname = path.dirname(filePath);
65 | if (!fs.existsSync(dirname)) {
66 | fs.mkdirSync(dirname, { recursive: true });
67 | }
68 | const data = JSON.stringify(json);
69 | return new Promise((resolve) => {
70 | fs.writeFile(filePath, data, "utf8", (err) => {
71 | resolve(err ? false : true);
72 | });
73 | });
74 | };
75 |
--------------------------------------------------------------------------------
/packages/feiyu/src/app/index.tsx:
--------------------------------------------------------------------------------
1 | import './style.css';
2 |
3 | import { Layout } from '@arco-design/web-react';
4 |
5 | import { Box, BoxProps } from '@/components/Box';
6 | import { APPConfigModal, useInitAPPModals } from '@/overlays/APPConfigModal';
7 | import { colors } from '@/styles/colors';
8 |
9 | import { useDisclaimer, useInitAPP } from './initAPP';
10 | import { kHeaderHeight, MyHeader } from './MyHeader';
11 | import { RootPages } from './RootPages';
12 | import { kSideWidth, SideDrawer, SideMenu, useSideMenu } from './SideMenu';
13 |
14 | const Sider = Layout.Sider;
15 | const Content = Layout.Content;
16 |
17 | export const App = () => {
18 | // 初始化APP
19 | useInitAPP();
20 |
21 | // APP 全局弹窗
22 | useInitAPPModals();
23 |
24 | // 免责声明弹窗
25 | const $Disclaimer = useDisclaimer();
26 |
27 | const { hideSideMenu, collapsed } = useSideMenu();
28 |
29 | return (
30 | <>
31 |
32 |
33 | {$Disclaimer}
34 |
35 |
36 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | >
53 | );
54 | };
55 |
56 | export const PageBuilder = (props: BoxProps) => {
57 | return (
58 |
68 | {props.children}
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/packages/feiyu/scripts/test.ts:
--------------------------------------------------------------------------------
1 | import { printf, printJson } from '@/utils/base';
2 | import { feiyu } from '@/utils/feiyu';
3 |
4 | const test = () => {
5 | const descs = [
6 | '在这个揭示钢铁超人起源的传奇冒险故事里,过去和现在发生了碰撞。在一个被埋葬多年的中国古老王国的废墟上,百万富翁兼发明家托尼。斯塔克进行着挖掘工作,但挖掘进度远远超出了协议规定的部分。他解开了一个古老的预言,预示中国最黑暗和最暴乱朝代的君主Mandarin将会复苏。为了对 付这股极具破坏性的力量,托尼打造了一套盔甲,并装备上先进武器。为了阻止这个他亲手带到地球的邪恶力量,托尼必须要成为自己有生以来最伟大的发明 -- 钢铁超人!这个新生的战士必须到地球的四个角落同Mandarin的追随者元素战士 -- 4个操纵着土、水、风和火元素力量的战士 战斗!但像预言所说的那样,钢铁超人是否有足够的能力挑战命运,阻止这股邪恶力量意图对地球的破坏呢?
',
7 | ' 和德普前妻艾梅柏约会,被侃爷列为竞选总统顾问,被称为现实版钢铁侠; 涉足互联网、太空探索、人工智能、可持续发展能源多个领域,梦想在火星上建立一个自给自足的城市; 快来看特斯拉、SpaceX老板伊隆·马斯克(ElonMusk)的传奇人生, 志不嫌远,梦不嫌大,只要你想去做,没有什么不可能~
',
8 | ' 在这个揭示钢铁超人起源的传奇冒险故事里,过去和现在发生了碰撞。在一个被埋葬多年的中国古老王国的废墟上,百万富翁兼发明家托尼。斯塔克进行着挖掘工作,但挖掘进度远远超出了协议规定的部分。他解开了一个古老的预言,预示中国最黑暗和最暴乱朝代的君主Mandarin将会复苏。为了对 付这股极具破坏性的力量,托尼打造了一套盔甲,并装备上先进武器。为了阻止这个他亲手带到地球的邪恶力量,托尼必须要成为自己有生以来最伟大的发明 -- 钢铁超人!这个新生的战士必须到地球的四个角落同Mandarin的追随者元素战士 -- 4个操纵着土、水、风和火元素力量的战士 战斗!但像预言所说的那样,钢铁超人是否有足够的能力挑战命运,阻止这股邪恶力量意图对地球的破坏呢?
',
9 | '斯塔克军火公司是美军在全球范围内第一大军火供应商,其新任掌门人托尼•斯塔克(小罗伯特·唐尼 Robert Downey Jr. 饰)风流倜傥,天资聪颖。他与公司元老俄巴迪亚•斯坦(杰夫·布里吉斯 Jeff Bridges 饰)合作无间,共同将斯塔克公司的业务推向顶峰。现 实生活中的托尼热衷收集名贵跑车,搞点儿发明创造,当然露水姻缘更不可少。所幸他身边有维吉尼亚•波茨(格温妮斯·帕特洛 Gwyneth Paltrow 饰)这样的好助手细心打理一切,才让他能自由自在过着贵公子的生活。 在前往中东为军方展示新型武器的途中,托尼一众遭到恐怖分子袭击。他被弹片击中险些丧命,在英森博士的帮助下,托尼体内移植了一颗核动力的人工心脏。恐怖分子要求托尼制造强大的杀伤性武器,他和英森虚与委蛇,暗中制造了一套由聚变能源驱动的钢铁盔甲。穿上盔甲托尼大闹恐怖分子的基地,回到美国后又对其进行了改进。却不知,接下来有更为黑暗的阴谋等着他……
',
10 | ];
11 | for (let desc of descs) {
12 | desc = desc.replace(/[\\n]/g, '').replace(/<.*?>/g, '').trim();
13 | printf(desc);
14 | }
15 | };
16 |
17 | const main = async () => {
18 | printf(`❌❌❌`);
19 | const results = await feiyu.search('钢铁侠', {
20 | callback: (e) => {
21 | printf(`${e.site} >>> ${e.name}`);
22 | },
23 | });
24 | printJson(results);
25 | test();
26 | printf(`✅✅✅`);
27 | };
28 |
29 | main();
30 |
--------------------------------------------------------------------------------
/packages/feiyu/src/components/Tab/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | import { useInit } from '@/hooks/useInit';
4 | import { useRebuild, useRebuildRef } from '@/hooks/useRebuild';
5 | import { colors } from '@/styles/colors';
6 |
7 | import { Box } from '../Box';
8 | import { Center } from '../Flex';
9 | import { Loading } from '../Loading';
10 | import { Stack } from '../Stack';
11 | import { Position } from '../Stack/position';
12 | import { TabPageController } from './state';
13 |
14 | export const TabPages = (props: {
15 | controller: TabPageController;
16 | currentPage?: string;
17 | }) => {
18 | const { controller, currentPage } = props;
19 | const rebuild = useRebuild();
20 | controller.rebuild = rebuild;
21 | const _currentPage = currentPage ?? controller.currentPage.key;
22 | const pages = controller.pages;
23 | return (
24 |
25 |
26 |
27 |
28 | {pages.map((e) => {
29 | const isActive = _currentPage === e.key;
30 | return (
31 |
37 |
38 |
39 | );
40 | })}
41 |
42 | );
43 | };
44 |
45 | export const AsyncBuilder = (props: {
46 | builder: () => any;
47 | inited?: boolean;
48 | }) => {
49 | const { builder, inited } = props;
50 | const pageRef = useRef();
51 | const rebuildRef = useRebuildRef();
52 | useInit(async () => {
53 | const page = await builder();
54 | pageRef.current = page;
55 | rebuildRef.current.rebuild();
56 | }, [builder]);
57 | return inited ? (
58 | pageRef.current ??
59 | ) : (
60 |
61 |
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/packages/feiyu/src/data/config/storage.ts:
--------------------------------------------------------------------------------
1 | import { storage } from '@/services/storage/storage';
2 |
3 | import { Subscribe } from './types';
4 |
5 | let localSubscribeStore;
6 | const _currentSubscribeKey = 'currentSubscribe';
7 | export const subscribeStorage = {
8 | current(): string | undefined {
9 | return storage.get(_currentSubscribeKey);
10 | },
11 | setCurrent(key: string) {
12 | return storage.set(_currentSubscribeKey, key);
13 | },
14 | async init() {
15 | if (!localSubscribeStore) {
16 | const localforage = (await import('localforage')).default;
17 | localSubscribeStore = localforage.createInstance({
18 | name: 'feiyu',
19 | storeName: 'subscribe',
20 | driver: localforage.INDEXEDDB,
21 | });
22 | }
23 | },
24 | async get(key: string): Promise {
25 | await subscribeStorage.init();
26 | return await localSubscribeStore.getItem(key).catch(() => undefined);
27 | },
28 | async set(key: string, subscribe: Subscribe): Promise {
29 | await subscribeStorage.init();
30 | const result = await localSubscribeStore
31 | .setItem(key, subscribe)
32 | .catch(() => 'failed');
33 | return result !== 'failed';
34 | },
35 | async getAll(): Promise {
36 | await subscribeStorage.init();
37 | const keys = await localSubscribeStore.keys().catch(() => []);
38 | const results = await Promise.all(
39 | keys.map((key) => subscribeStorage.get(key)),
40 | );
41 | // 筛选非空的订阅
42 | return results.filter((e) => e);
43 | },
44 | async remove(key: string): Promise {
45 | await subscribeStorage.init();
46 | const result = await localSubscribeStore
47 | .removeItem(key)
48 | .catch(() => 'failed');
49 | return result !== 'failed';
50 | },
51 | async clear(): Promise {
52 | await subscribeStorage.init();
53 | const result = await localSubscribeStore.clear().catch(() => 'failed');
54 | return result !== 'failed';
55 | },
56 | };
57 |
--------------------------------------------------------------------------------
/packages/feiyu/src/utils/is.ts:
--------------------------------------------------------------------------------
1 | export function isNaN(e: unknown): boolean {
2 | return Number.isNaN(e);
3 | }
4 |
5 | export function isNull(e: unknown): boolean {
6 | return e === null;
7 | }
8 |
9 | export function isUndefined(e: unknown): boolean {
10 | return e === undefined;
11 | }
12 |
13 | export function isNullish(e: unknown): boolean {
14 | return e === null || e === undefined;
15 | }
16 |
17 | export function isNotNullish(e: unknown): boolean {
18 | return !isNullish(e);
19 | }
20 |
21 | export function isEmpty(e: any): boolean {
22 | if (e?.size ?? 0 > 0) return false;
23 | return (
24 | isNaN(e) ||
25 | isNullish(e) ||
26 | (isString(e) && (e.length < 1 || !/\S/.test(e))) ||
27 | (isArray(e) && e.length < 1) ||
28 | (isObject(e) && Object.keys(e).length < 1)
29 | );
30 | }
31 |
32 | export function isNotEmpty(e: unknown): boolean {
33 | return !isEmpty(e);
34 | }
35 |
36 | export function isNumber(e: unknown): boolean {
37 | return typeof e === 'number' && !isNaN(e);
38 | }
39 |
40 | export function isString(e: unknown): boolean {
41 | return typeof e === 'string';
42 | }
43 |
44 | export function isStringNumber(e: any): boolean {
45 | return isString(e) && isNotEmpty(e) && !isNaN(Number(e));
46 | }
47 |
48 | export function isArray(e: unknown): boolean {
49 | return Array.isArray(e);
50 | }
51 |
52 | export function isObject(e: unknown): boolean {
53 | return typeof e === 'object' && isNotNullish(e) && !isArray(e);
54 | }
55 |
56 | export function isFunction(e: unknown): boolean {
57 | return typeof e === 'function';
58 | }
59 |
60 | export function isClass(e: any): boolean {
61 | return isFunction(e) && e.toString().startsWith('class ');
62 | }
63 |
64 | export function isValidUrl(str: string) {
65 | const pattern = new RegExp(
66 | '^(https?:\\/\\/)' + // 协议
67 | '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // 域名
68 | '((\\d{1,3}\\.){3}\\d{1,3}))' + // 或IP(v4)地址
69 | '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // 端口和路径
70 | '(\\?[;&a-z\\d%_.~+=-]*)?' + // 查询字符串
71 | '(\\#[-a-z\\d_]*)?$', // fragment locator
72 | 'i',
73 | );
74 | return !!pattern.test(str);
75 | }
76 |
--------------------------------------------------------------------------------
/packages/feiyu-desktop/src/index.desktop.js:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 | import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
3 | import { updater } from "./updater";
4 | import { getPlatform } from "./platform";
5 |
6 | class _FeiyuDesktop {
7 | isDesktop = true;
8 | isMac = false;
9 | isWindows = false;
10 | isLinux = false;
11 |
12 | invoke = null;
13 | window = null;
14 | updater = updater;
15 |
16 | _initialized = false;
17 | async init() {
18 | if (this._initialized) {
19 | return;
20 | }
21 | this.invoke = invoke;
22 | this.window = getCurrentWebviewWindow();
23 | const platform = await getPlatform();
24 | this.isMac = platform.isMac;
25 | this.isWindows = platform.isWindows;
26 | this.isLinux = platform.isLinux;
27 | this._initFullScreen();
28 | this._initWindowsBorder();
29 | this._initialized = true;
30 | }
31 |
32 | _initWindowsBorder() {
33 | const htmlElement = document.documentElement;
34 | const bodyElement = document.body;
35 | const appBorderElement = document.createElement("div");
36 | bodyElement.appendChild(appBorderElement);
37 | [htmlElement, bodyElement].forEach((e) => {
38 | e.style.background = "transparent";
39 | e.style.clipPath = "inset(1px round 12px)";
40 | });
41 | appBorderElement.style.position = "fixed";
42 | appBorderElement.style.top = "1px";
43 | appBorderElement.style.left = "1px";
44 | appBorderElement.style.zIndex = "10000";
45 | appBorderElement.style.pointerEvents = "none";
46 | appBorderElement.style.border = "1px solid rgba(0, 0, 0, 10%)";
47 | appBorderElement.style.borderRadius = "12px";
48 | appBorderElement.style.width = "calc(100vw - 2 * 1px)";
49 | appBorderElement.style.height = "calc(100vh - 2 * 1px)";
50 | }
51 |
52 | _initFullScreen() {
53 | if (this.isWindows) {
54 | document.addEventListener("fullscreenchange", async () => {
55 | const fullscreen = document.fullscreenElement != null;
56 | await this.window?.setFullscreen(fullscreen);
57 | });
58 | }
59 | }
60 | }
61 |
62 | export const FeiyuDesktop = new _FeiyuDesktop();
63 |
--------------------------------------------------------------------------------
/packages/feiyu-proxy/src/index.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | import { jsonDecode } from "./utils/base";
4 | import { isEmpty } from "./utils/is";
5 | import { clearHeaders, scfContext } from "./utils/scf/context";
6 | import { scfResponse } from "./utils/scf/response";
7 | import { APIContext, SCFEvents } from "./utils/scf/types";
8 |
9 | // Proxy 地址请求头
10 | export const kProxyHeader = "x-proxy-target";
11 | export const kProxyHeaders = "x-proxy-headers";
12 |
13 | // 默认响应
14 | const defaultResponse = scfResponse("Hello world!", { type: "text" });
15 |
16 | // header: x-proxy-target
17 | export const main = async (event: SCFEvents) => {
18 | const ctx = scfContext(event) as APIContext;
19 | const { path, method, headers, query, data } = ctx;
20 |
21 | // 无效的请求
22 | if (path !== "/proxy" || isEmpty(headers[kProxyHeader])) {
23 | return defaultResponse;
24 | }
25 |
26 | const target = headers[kProxyHeader];
27 | const targetHeaders = jsonDecode(headers[kProxyHeaders]) ?? {};
28 | const cleanHeaders = clearHeaders(headers, [
29 | "host",
30 | "origin",
31 | "referer",
32 | "x-api-scheme",
33 | "x-api-requestid",
34 | "x-forwarded-host",
35 | "x-forwarded-proto",
36 | "x-forwarded-port",
37 | "x-forwarded-for",
38 | "x-real-ip",
39 | "x-b3-traceid",
40 | "x-api-scheme",
41 | "x-qualifier",
42 | "requestsource",
43 | "endpoint-timeout",
44 | "accept-encoding",
45 | kProxyHeader,
46 | kProxyHeaders,
47 | ...Object.keys(targetHeaders),
48 | ]);
49 |
50 | const response: any = await axios({
51 | method: method,
52 | url: target,
53 | params: query,
54 | data: data,
55 | headers: {
56 | ...cleanHeaders,
57 | ...targetHeaders,
58 | referer: target,
59 | origin: new URL(target).origin,
60 | },
61 | }).catch((e) => {
62 | console.error(e.toString());
63 | return e.response;
64 | });
65 |
66 | return scfResponse(response?.data, {
67 | code: response?.status,
68 | headers: clearHeaders(response?.headers, [
69 | "content-length",
70 | "access-control-allow-origin",
71 | "access-control-allow-headers",
72 | ]),
73 | });
74 | };
75 |
--------------------------------------------------------------------------------
/packages/feiyu-proxy/src/utils/scf/types.ts:
--------------------------------------------------------------------------------
1 | // {
2 | // Type: 'Timer',
3 | // TriggerName: 'EveryDay',
4 | // Time: '2019-03-21T11:49:00Z',
5 | // Message: 'user define msg body',
6 | // }
7 | export interface TimerEvent {
8 | Type: "Timer";
9 | TriggerName: string;
10 | Time: string;
11 | Message: string;
12 | }
13 |
14 | // {
15 | // httpMethod: 'POST',
16 | // isBase64Encoded: false,
17 | // path: '/xxx',
18 | // body: '{"a":123}',
19 | // queryString: {
20 | // aaa: '1234',
21 | // },
22 | // headers: {
23 | // 'user-agent': 'xxx',
24 | // 'x-api-requestid': 'xxx',
25 | // 'x-b3-traceid': 'xxx',
26 | // 'x-api-scheme': 'https',
27 | // },
28 | // requestContext: {
29 | // httpMethod: 'ANY',
30 | // identity: {},
31 | // path: '/xxx',
32 | // serviceId: 'service-xxx',
33 | // sourceIp: '50.xxx.xxx.xxx',
34 | // stage: 'release',
35 | // },
36 | // headerParameters: {},
37 | // pathParameters: {},
38 | // queryStringParameters: {},
39 | // }
40 | export interface APIEvent {
41 | httpMethod: "POST" | "GET";
42 | isBase64Encoded: boolean;
43 | // Full relative path
44 | path: string;
45 | // POST row json string
46 | body: string;
47 | // GET query map
48 | queryString: Record;
49 | headers: Record;
50 | requestContext: {
51 | // Path category
52 | path: string;
53 | serviceId: string;
54 | sourceIp: string;
55 | stage: string;
56 | };
57 | }
58 |
59 | export type SCFEvents = APIEvent | TimerEvent;
60 |
61 | export type HttpQuery = Record;
62 |
63 | interface ContextTypes {
64 | isTimer?: boolean;
65 | isAPI?: boolean;
66 | }
67 |
68 | export interface TimerContext extends ContextTypes {
69 | isTimer: true;
70 | name: string;
71 | time: string;
72 | message: string;
73 | }
74 |
75 | export interface APIContext extends ContextTypes {
76 | isAPI: true;
77 | stage: string;
78 | method: "POST" | "GET";
79 | path: string;
80 | query: HttpQuery;
81 | data: any;
82 | headers: Record;
83 | userAgent: string;
84 | sourceIp: string;
85 | traceid: string;
86 | }
87 |
88 | export type SCFContext = TimerContext | APIContext;
89 |
--------------------------------------------------------------------------------
/packages/feiyu/src/utils/base.ts:
--------------------------------------------------------------------------------
1 | export const timestamp = () => new Date().getTime();
2 |
3 | export const delay = async (time: number) =>
4 | new Promise((resolve) => setTimeout(resolve, time));
5 |
6 | export const printf = (...v: any[]) => console.log(...v);
7 |
8 | export const printJson = (obj: any) =>
9 | console.log(JSON.stringify(obj, undefined, 4));
10 |
11 | export const firstOf = (datas?: T[]) =>
12 | datas ? (datas.length < 1 ? undefined : datas[0]) : undefined;
13 |
14 | export const lastOf = (datas?: T[]) =>
15 | datas ? (datas.length < 1 ? undefined : datas[datas.length - 1]) : undefined;
16 |
17 | export const randomInt = (min: number, max?: number) => {
18 | if (!max) {
19 | max = min;
20 | min = 0;
21 | }
22 | return Math.floor(Math.random() * (max - min + 1) + min);
23 | };
24 |
25 | export const pickOne = (datas: T[]) =>
26 | datas.length < 1 ? undefined : datas[randomInt(datas.length - 1)];
27 |
28 | export const range = (start: number, end?: number) => {
29 | if (!end) {
30 | end = start;
31 | start = 0;
32 | }
33 | return Array.from({ length: end - start }, (_, index) => start + index);
34 | };
35 |
36 | /**
37 | * clamp(-1,0,1)=0
38 | */
39 | export function clamp(num: number, min: number, max: number): number {
40 | return num < max ? (num > min ? num : min) : max;
41 | }
42 |
43 | export const toSet = (datas: T[], byKey?: (e: T) => any) => {
44 | if (byKey) {
45 | const keys = {};
46 | const newDatas: T[] = [];
47 | datas.forEach((e) => {
48 | const key = jsonEncode({ key: byKey(e) }) as any;
49 | if (!keys[key]) {
50 | newDatas.push(e);
51 | keys[key] = true;
52 | }
53 | });
54 | return newDatas;
55 | }
56 | return Array.from(new Set(datas));
57 | };
58 |
59 | export function jsonEncode(obj: any, prettier = false) {
60 | try {
61 | return prettier ? JSON.stringify(obj, undefined, 4) : JSON.stringify(obj);
62 | } catch (error) {
63 | return undefined;
64 | }
65 | }
66 |
67 | export function jsonDecode(json: string | undefined) {
68 | if (json == undefined) return undefined;
69 | try {
70 | return JSON.parse(json!);
71 | } catch (error) {
72 | return undefined;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 | .stylelintcache
56 |
57 | # Microbundle cache
58 | .rpt2_cache/
59 | .rts2_cache_cjs/
60 | .rts2_cache_es/
61 | .rts2_cache_umd/
62 |
63 | # Optional REPL history
64 | .node_repl_history
65 |
66 | # Output of 'npm pack'
67 | *.tgz
68 |
69 | # Yarn Integrity file
70 | .yarn-integrity
71 |
72 | # dotenv environment variables file
73 | .env
74 | .env.test
75 | .config.ts
76 |
77 | # misc
78 | .DS_Store
79 |
80 | # parcel-bundler cache (https://parceljs.org/)
81 | .cache
82 |
83 | # Next.js build output
84 | .next
85 |
86 | # Nuxt.js build / generate output
87 | .nuxt
88 | dist
89 | dev-dist
90 |
91 | # Gatsby files
92 | .cache/
93 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
94 | # https://nextjs.org/blog/next-9-1#public-directory-support
95 | # public
96 |
97 | # vuepress build output
98 | .vuepress/dist
99 |
100 | # Serverless directories
101 | .serverless/
102 |
103 | # FuseBox cache
104 | .fusebox/
105 |
106 | # DynamoDB Local files
107 | .dynamodb/
108 |
109 | # TernJS port file
110 | .tern-port
111 | .vercel
112 |
--------------------------------------------------------------------------------
/packages/feiyu-website/src/index.css:
--------------------------------------------------------------------------------
1 | *,
2 | *::after,
3 | *::before {
4 | box-sizing: border-box;
5 | }
6 |
7 | #root {
8 | max-width: 1024px;
9 | margin: 0 auto;
10 | padding: 2rem;
11 | text-align: center;
12 | }
13 |
14 | html,
15 | body,
16 | :root {
17 | margin: 0;
18 | font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
19 | Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
20 | font-size: 14px;
21 | font-weight: 400;
22 | text-rendering: optimizelegibility;
23 | -webkit-font-smoothing: antialiased;
24 | -moz-osx-font-smoothing: grayscale;
25 | text-size-adjust: 100%;
26 | -webkit-tap-highlight-color: rgb(0 0 0 / 0%);
27 | }
28 |
29 | @media only screen and (max-width: 768px) {
30 | html {
31 | font-size: 12px !important;
32 | }
33 | }
34 |
35 | a:focus,
36 | input:focus,
37 | p:focus,
38 | div:focus {
39 | -webkit-tap-highlight-color: rgb(0 0 0 / 0%);
40 | }
41 |
42 | p,
43 | h1,
44 | h2,
45 | h3 {
46 | margin: 0;
47 | }
48 |
49 | a {
50 | color: inherit;
51 | text-decoration: inherit;
52 | }
53 |
54 | .row {
55 | display: flex;
56 | flex-direction: row;
57 | align-items: center;
58 | }
59 |
60 | .column {
61 | display: flex;
62 | flex-direction: column;
63 | align-items: center;
64 | }
65 |
66 | .expand {
67 | display: flex;
68 | flex: 1;
69 | }
70 |
71 | .relative {
72 | position: relative;
73 | }
74 |
75 | .absolute {
76 | position: absolute;
77 | }
78 |
79 | .center-x {
80 | position: absolute;
81 | left: 50%;
82 | transform: translateX(-50%);
83 | }
84 |
85 | .center-y {
86 | position: absolute;
87 | top: 50%;
88 | transform: translateY(-50%);
89 | }
90 |
91 | @keyframes heartbeat {
92 | 0% {
93 | transform: scale(0.75);
94 | }
95 | 20% {
96 | transform: scale(1);
97 | }
98 | 40% {
99 | transform: scale(0.75);
100 | }
101 | 60% {
102 | transform: scale(1);
103 | }
104 | 80% {
105 | transform: scale(0.75);
106 | }
107 | 100% {
108 | transform: scale(0.75);
109 | }
110 | }
111 |
112 | .heart {
113 | display: inline-block;
114 | animation: heartbeat 1.5s ease-in-out both infinite;
115 | }
116 |
--------------------------------------------------------------------------------
/packages/feiyu/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 | .stylelintcache
56 |
57 | # Microbundle cache
58 | .rpt2_cache/
59 | .rts2_cache_cjs/
60 | .rts2_cache_es/
61 | .rts2_cache_umd/
62 |
63 | # Optional REPL history
64 | .node_repl_history
65 |
66 | # Output of 'npm pack'
67 | *.tgz
68 |
69 | # Yarn Integrity file
70 | .yarn-integrity
71 |
72 | # dotenv environment variables file
73 | .env
74 | .env.test
75 | .config.ts
76 |
77 | # misc
78 | .DS_Store
79 |
80 | # parcel-bundler cache (https://parceljs.org/)
81 | .cache
82 |
83 | # Next.js build output
84 | .next
85 |
86 | # Nuxt.js build / generate output
87 | .nuxt
88 | dist
89 | dev-dist
90 |
91 | # Gatsby files
92 | .cache/
93 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
94 | # https://nextjs.org/blog/next-9-1#public-directory-support
95 | # public
96 |
97 | # vuepress build output
98 | .vuepress/dist
99 |
100 | # Serverless directories
101 | .serverless/
102 |
103 | # FuseBox cache
104 | .fusebox/
105 |
106 | # DynamoDB Local files
107 | .dynamodb/
108 |
109 | # TernJS port file
110 | .tern-port
111 | .vercel
112 |
--------------------------------------------------------------------------------
/packages/feiyu-proxy/src/utils/scf/context.ts:
--------------------------------------------------------------------------------
1 | import { jsonDecode } from '../base';
2 | import { isNotEmpty } from '../is';
3 | import {
4 | APIEvent,
5 | HttpQuery,
6 | SCFContext,
7 | SCFEvents,
8 | TimerEvent,
9 | } from './types';
10 |
11 | const _isTimerEvent = (e: SCFEvents) => (e as any).Type === 'Timer';
12 | const _isAPIEvent = (e: SCFEvents) => (e as any).requestContext != null;
13 | const _parseQuery = (datas: Record) => {
14 | const newDatas: HttpQuery = {};
15 | for (const [key, value] of Object.entries(datas)) {
16 | if (value === '') {
17 | newDatas[key] = undefined;
18 | }
19 | if (value === 'true') newDatas[key] = true;
20 | if (value === 'false') newDatas[key] = false;
21 | if (/^(0|-?[1-9]\d*)$/.test(value)) {
22 | newDatas[key] = parseInt(value, 10);
23 | }
24 | newDatas[key] = value;
25 | }
26 | return newDatas;
27 | };
28 |
29 | const _clearKeys = [
30 | 'x-api-requestid',
31 | 'x-b3-traceid',
32 | 'x-api-scheme',
33 | 'requestsource',
34 | 'referer',
35 | 'host',
36 | 'endpoint-timeout',
37 | 'x-qualifier',
38 | 'accept-encoding',
39 | ];
40 |
41 | export const clearHeaders = (headers: any, clearKeys = _clearKeys) => {
42 | const _headers: any = {};
43 | for (const [key, value] of Object.entries(headers ?? {})) {
44 | if (!clearKeys.includes(key.toLowerCase())) {
45 | _headers[key] = value;
46 | }
47 | }
48 | return _headers;
49 | };
50 |
51 | export const scfContext = (e: SCFEvents): SCFContext | undefined => {
52 | if (_isTimerEvent(e)) {
53 | e = e as TimerEvent;
54 | return {
55 | isTimer: true,
56 | name: e.TriggerName,
57 | time: e.Time,
58 | message: e.Message,
59 | };
60 | }
61 | if (_isAPIEvent(e)) {
62 | e = e as APIEvent;
63 | const userAgent = e.headers['user-agent'];
64 | const traceid = e.headers['x-b3-traceid'];
65 | return {
66 | isAPI: true,
67 | stage: e.requestContext.stage,
68 | method: e.httpMethod,
69 | path: e.path,
70 | headers: e.headers,
71 | query: _parseQuery(e.queryString),
72 | data: jsonDecode(e.body) ?? (isNotEmpty(e.body) ? e.body : undefined),
73 | userAgent,
74 | traceid,
75 | sourceIp: e.requestContext.sourceIp,
76 | };
77 | }
78 | };
79 |
--------------------------------------------------------------------------------
/packages/feiyu/src/app/MyHeader.tsx:
--------------------------------------------------------------------------------
1 | import { Message } from '@arco-design/web-react';
2 | import { FeiyuDesktop } from 'feiyu-desktop';
3 |
4 | import { Expand, Row } from '@/components/Flex';
5 | import { SwitchDark } from '@/components/SwitchDark';
6 | import { colors } from '@/styles/colors';
7 |
8 | import { useHomePages } from '../pages/home/useHomePages';
9 | import { SearchButton } from './SearchButton';
10 | import { useSideMenu } from './SideMenu';
11 | import { TitleBar } from './TitleBar';
12 |
13 | export const kHeaderHeight = '60px';
14 |
15 | export const MyHeader = () => {
16 | const { jumpToIndex, isIndexPage } = useHomePages();
17 | const { hideSideMenu, openSideDrawer } = useSideMenu();
18 |
19 | return (
20 |
28 | {FeiyuDesktop.isDesktop &&
}
29 |
41 |
{
45 | if (hideSideMenu) {
46 | // 移动端点击打开菜单栏
47 | openSideDrawer();
48 | } else {
49 | if (!isIndexPage) {
50 | jumpToIndex(); // 回到首页
51 | } else {
52 | Message.info("喵呜 ฅ'ω'ฅ");
53 | }
54 | }
55 | }}
56 | >
57 |
58 |
66 | 飞鱼
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/packages/feiyu-desktop/scripts/updater.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import path from "path";
4 | import {
5 | copyFile,
6 | deleteFile,
7 | getFiles,
8 | moveFile,
9 | readString,
10 | writeJSON,
11 | } from "./io.js";
12 |
13 | async function main() {
14 | const args = process.argv.slice(2);
15 | const [root, version, notes] = args;
16 | const targets = {
17 | "darwin-x86_64": "macos_x86_64.app.tar.gz",
18 | "darwin-aarch64": "macos_aarch64.app.tar.gz",
19 | "darwin-universal": "macos_universal.app.tar.gz",
20 | "windows-x86_64": "windows_x86_64.nsis.zip",
21 | "windows-aarch64": "windows_aarch64.nsis.zip",
22 | "windows-i686": "windows_i686.nsis.zip",
23 | "linux-x86_64": "linux_x86_64.AppImage.tar.gz",
24 | "linux-i686": "linux_i686.AppImage.tar.gz",
25 | };
26 | const files = await getFiles(path.join(root, "dist"));
27 | const platforms = {};
28 | for (const target in targets) {
29 | const suffix = targets[target];
30 | const updater = files.find((e) => e.endsWith(suffix));
31 | if (updater) {
32 | const sig = path.join(root, "dist", updater + ".sig");
33 | const signature = await readString(sig);
34 | if (signature) {
35 | platforms[target] = {
36 | signature,
37 | url: `https://github.com/idootop/feiyu-player/releases/download/updater/${updater}`,
38 | };
39 | await deleteFile(sig);
40 | await moveFile(
41 | path.join(root, "dist", updater),
42 | path.join(root, "updater", updater)
43 | );
44 | console.log(`✅ ${target}`);
45 | }
46 | }
47 | }
48 | await writeJSON(path.join(root, "updater", "latest.json"), {
49 | version,
50 | notes,
51 | platforms,
52 | pub_date: new Date().toISOString(),
53 | });
54 | const installers = [
55 | "macos_universal.dmg",
56 | "windows_x86_64.exe",
57 | "linux_x86_64.deb",
58 | ];
59 | for (const file of files) {
60 | const match = installers.find((e) => file.endsWith(e));
61 | if (match) {
62 | await copyFile(
63 | path.join(root, "dist", file),
64 | path.join(root, "installer", file)
65 | );
66 | }
67 | // 移除版本号
68 | await moveFile(
69 | path.join(root, "dist", file),
70 | path.join(root, "dist", file.replace("_" + version, ""))
71 | );
72 | }
73 | }
74 |
75 | main();
76 |
--------------------------------------------------------------------------------
/packages/feiyu-website/src/layouts/Intro.jsx:
--------------------------------------------------------------------------------
1 | import { Download } from "../components/Button/Download";
2 | import { Button } from "../components/Button";
3 | import { useHover } from "../hooks/useHover";
4 | import { useShadow } from "../hooks/useShadow";
5 |
6 | export function Intro() {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 |
14 |
20 |
32 |
33 | >
34 | );
35 | }
36 |
37 | function Slogan() {
38 | const { hoverRef, isHovered } = useHover();
39 | const { shadow, shadowRef } = useShadow();
40 |
41 | return (
42 | <>
43 |
44 | 追
45 |
51 | 光影
52 |
53 | ,
54 |
62 | {!isHovered ? "看世界" : "👀 🌍"}
63 |
64 |
65 |
73 | The Light and Shadow, A Brighter World to See
74 |
75 | >
76 | );
77 | }
78 |
79 | function DownloadArea() {
80 | return (
81 |
82 |
83 |
84 | 网页版
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/packages/feiyu/src/components/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Modal } from '@arco-design/web-react';
2 | import { ReactNode } from 'react';
3 |
4 | import { isNotEmpty } from '@/utils/is';
5 |
6 | import { Box } from './Box';
7 | import { Column, Expand, Row } from './Flex';
8 | import { Text } from './Text';
9 |
10 | interface DialogProps {
11 | ok?: string;
12 | cancel?: string;
13 | lead?: string;
14 | onOk?: () => void;
15 | onCancel?: () => void;
16 | onClose?: () => void;
17 | onLead?: () => void;
18 | title: string;
19 | children: ReactNode;
20 | visible?: boolean;
21 | okWaiting?: boolean;
22 | cancelWaiting?: boolean;
23 | leadWaiting?: boolean;
24 | }
25 |
26 | export const Dialog = (props: DialogProps) => {
27 | const {
28 | ok = '确定',
29 | cancel = '取消',
30 | onOk,
31 | onCancel,
32 | title,
33 | children,
34 | visible,
35 | okWaiting,
36 | cancelWaiting,
37 | lead,
38 | onLead,
39 | onClose = onCancel,
40 | leadWaiting,
41 | } = props;
42 | return (
43 |
55 |
56 |
59 | {title}
60 |
61 | {children}
62 |
63 | {lead ? (
64 |
65 | {lead}
66 |
67 | ) : (
68 |
69 | )}
70 |
71 | {isNotEmpty(cancel) ? (
72 |
78 | {cancel}
79 |
80 | ) : (
81 |
82 | )}
83 |
84 | {ok}
85 |
86 |
87 |
88 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/packages/feiyu/src/components/Stack/index.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 |
3 | import { flattenChildren } from '@/utils/flatten';
4 |
5 | import { Box, BoxProps } from '../Box';
6 | import { PositionProps } from './position';
7 |
8 | export const Stack = forwardRef((props: BoxProps, ref: any) => {
9 | const children = props.children as any[];
10 | if (!Array.isArray(props.children) || props.children.length < 1)
11 | return props.children;
12 | // 最底部的元素,撑起 Stack 的宽高
13 | const target = children![0];
14 | // 合并子元素中的嵌套列表
15 | let items = children.slice(1);
16 | items = flattenChildren(items);
17 | // 构造 Stack 内容
18 | const contents = items.map((item: any, index: number) => {
19 | const p = item.props ?? {};
20 | const position = _getStackPosition(p);
21 | const zIndex = p?.zIndex ?? 1;
22 | const props = { ...p, ...position, zIndex, position: 'absolute' };
23 | return (
24 |
25 | {item}
26 |
27 | );
28 | });
29 | return (
30 |
31 | {contents}
32 | {target}
33 |
34 | ) as any;
35 | });
36 |
37 | const _getStackPosition = (p?: Partial) => {
38 | switch (p?.align) {
39 | case 'topLeft':
40 | return {
41 | top: 0,
42 | left: 0,
43 | };
44 | case 'topRight':
45 | return {
46 | top: 0,
47 | right: 0,
48 | };
49 | case 'bottomLeft':
50 | return {
51 | bottom: 0,
52 | left: 0,
53 | };
54 | case 'bottomRight':
55 | return {
56 | bottom: 0,
57 | right: 0,
58 | };
59 | case 'center':
60 | return {
61 | top: '50%',
62 | left: '50%',
63 | transform: 'translate(-50%, -50%)',
64 | };
65 | case 'topCenter':
66 | return {
67 | top: 0,
68 | left: '50%',
69 | transform: 'translateX(-50%)',
70 | };
71 | case 'bottomCenter':
72 | return {
73 | bottom: 0,
74 | left: '50%',
75 | transform: 'translateX(-50%)',
76 | };
77 | case 'centerLeft':
78 | return {
79 | left: 0,
80 | top: '50%',
81 | transform: 'translateY(-50%)',
82 | };
83 | case 'centerRight':
84 | return {
85 | right: 0,
86 | top: '50%',
87 | transform: 'translateY(-50%)',
88 | };
89 | default:
90 | return {};
91 | }
92 | };
93 |
--------------------------------------------------------------------------------
/packages/feiyu/src/services/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { Router } from 'wouter';
3 | import { useXProvider, XSta } from 'xsta';
4 |
5 | import { BoxProps } from '@/components/Box';
6 | import { AsyncBuilder, TabPages } from '@/components/Tab';
7 | import { TabPageController } from '@/components/Tab/state';
8 | import { useInit } from '@/hooks/useInit';
9 | import { flattenChildren } from '@/utils/flatten';
10 |
11 | import { useRouterInit } from './listener';
12 | import { multiPathMatcher, useLLocation } from './location';
13 | import { router } from './router';
14 |
15 | const _lRoutesKey = (key: string, id = '0') => `LRoutes-${key}-${id}`;
16 | /**
17 | * key 为当前 LRoutes 的 parent
18 | */
19 | export const getLRoutesController = (key: string, id = '0') =>
20 | XSta.get(_lRoutesKey(key, id));
21 |
22 | export const LRouter = (
23 | props: BoxProps & { base?: string; hash?: boolean },
24 | ) => {
25 | const { base = '/', hash = true } = props;
26 | router.base = base;
27 | router.hash = hash;
28 | // 初始化 router
29 | useRouterInit();
30 | return (
31 |
32 | {props.children}
33 |
34 | );
35 | };
36 |
37 | export const LRoutes = (props: {
38 | children: any[];
39 | /**
40 | * /path/of/parent/
41 | */
42 | parent?: string;
43 | id?: string;
44 | keepalive?: boolean;
45 | }) => {
46 | const { parent: _parent = '/', id, keepalive = true, children } = props;
47 | let parent = _parent;
48 | if (!_parent.endsWith('/')) {
49 | parent = _parent + '/';
50 | }
51 | // 合并子元素中的嵌套列表
52 | const items = flattenChildren(children);
53 | const pages: { key: string; pageBuilder: () => any }[] = items.map((e) => ({
54 | key: e.props.path,
55 | pageBuilder: e.props.builder,
56 | }));
57 | // 解析当前 match 的页面(默认 fallback 到第一个页面)
58 | const [location] = useLLocation();
59 | const current =
60 | pages.find((e) => location.startsWith(parent + e.key)) ?? pages[0];
61 | if (!keepalive) {
62 | return ;
63 | }
64 | // 创建 Tab Controller
65 | const key = _lRoutesKey(parent, id);
66 | const controller = useInit(
67 | () =>
68 | new TabPageController({
69 | key,
70 | pages,
71 | }),
72 | );
73 | useXProvider(key, controller);
74 | useEffect(() => {
75 | controller.jumpTo(current.key);
76 | }, [current.key]);
77 | return ;
78 | };
79 |
80 | export const LRoute = (_p: { path: string; builder: () => any }) => {
81 | return
;
82 | };
83 |
--------------------------------------------------------------------------------
/packages/feiyu/src/utils/dom.ts:
--------------------------------------------------------------------------------
1 | export const createDom = (props: {
2 | tagName?: string;
3 | innerHTML?: string;
4 | attrs?: Record;
5 | className?: string;
6 | }) => {
7 | const { tagName = 'div', innerHTML = '', attrs = {}, className = '' } = props;
8 | const dom = document.createElement(tagName);
9 | dom.className = className;
10 | dom.innerHTML = innerHTML;
11 | Object.keys(attrs).forEach((item) => {
12 | const key = item;
13 | const value = attrs[item];
14 | if (tagName === 'video' || tagName === 'audio') {
15 | if (value) {
16 | dom.setAttribute(key, value);
17 | }
18 | } else {
19 | dom.setAttribute(key, value);
20 | }
21 | });
22 | return dom;
23 | };
24 |
25 | export function hasClass(el: HTMLElement, className: string) {
26 | if (!el) {
27 | return false;
28 | }
29 |
30 | if (el.classList) {
31 | return Array.prototype.some.call(
32 | el.classList,
33 | (item) => item === className,
34 | );
35 | } else if (el.className) {
36 | return !!el.className.match(new RegExp('(\\s|^)' + className + '(\\s|$)'));
37 | } else {
38 | return false;
39 | }
40 | }
41 |
42 | export function addClass(el: HTMLElement, className: string) {
43 | if (!el) {
44 | return;
45 | }
46 |
47 | if (el.classList) {
48 | className
49 | .replace(/(^\s+|\s+$)/g, '')
50 | .split(/\s+/g)
51 | .forEach((item) => {
52 | item && el.classList.add(item);
53 | });
54 | } else if (!hasClass(el, className)) {
55 | el.className += ' ' + className;
56 | }
57 | }
58 |
59 | export function removeClass(el: HTMLElement, className: string) {
60 | if (!el) {
61 | return;
62 | }
63 |
64 | if (el.classList) {
65 | className.split(/\s+/g).forEach((item) => {
66 | el.classList.remove(item);
67 | });
68 | } else if (hasClass(el, className)) {
69 | className.split(/\s+/g).forEach((item) => {
70 | const reg = new RegExp('(\\s|^)' + item + '(\\s|$)');
71 | el.className = el.className.replace(reg, ' ');
72 | });
73 | }
74 | }
75 |
76 | export function toggleClass(el: HTMLElement, className: string) {
77 | if (!el) {
78 | return;
79 | }
80 |
81 | className.split(/\s+/g).forEach((item) => {
82 | if (hasClass(el, item)) {
83 | removeClass(el, item);
84 | } else {
85 | addClass(el, item);
86 | }
87 | });
88 | }
89 |
90 | export function findDom(el = document, sel: string) {
91 | let dom: any;
92 | try {
93 | dom = el.querySelector(sel);
94 | } catch (e) {
95 | if (sel.indexOf('#') === 0) {
96 | dom = el.getElementById(sel.slice(1));
97 | }
98 | }
99 | return dom;
100 | }
101 |
--------------------------------------------------------------------------------
/packages/feiyu/src/services/routes/listener.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import { useInit } from '@/hooks/useInit';
4 | import { ArrayElement } from '@/utils/types';
5 |
6 | import { getLocation, useInitLocationListener } from './location';
7 | import { router } from './router';
8 |
9 | type RouteEvent = ArrayElement<
10 | ['popstate', 'pushState', 'replaceState', 'hashchange']
11 | >;
12 |
13 | export const kRoutEvents: RouteEvent[] = [
14 | 'popstate',
15 | 'pushState',
16 | 'replaceState',
17 | 'hashchange',
18 | ];
19 |
20 | export const useListenLocationUpdates = (callback: (event: any) => void) => {
21 | useEffect(() => {
22 | for (const event of kRoutEvents) {
23 | addEventListener(event, callback);
24 | }
25 | return () => {
26 | for (const event of kRoutEvents) {
27 | removeEventListener(event, callback);
28 | }
29 | };
30 | }, []);
31 | };
32 |
33 | const getPath = (url: URL) => {
34 | return getLocation(url.pathname, url.hash);
35 | };
36 | export const useRouterInit = () => {
37 | useInit(() => {
38 | router.init();
39 | });
40 | // 路由变化监听(轮询)
41 | useInitLocationListener();
42 | // 路由变化监听(响应式)
43 | useListenLocationUpdates((event: any) => {
44 | let to: string | undefined = '/';
45 | switch (event.type) {
46 | case 'pushState':
47 | router.push(getLocation(event.arguments[2]), {
48 | query: router.currentQuery,
49 | action: false,
50 | });
51 | break;
52 | case 'replaceState':
53 | router.pop(undefined, { action: false });
54 | router.push(getLocation(event.arguments[2]), {
55 | query: router.currentQuery,
56 | action: false,
57 | replace: true,
58 | });
59 | break;
60 | case 'hashchange':
61 | to = getPath(new URL(event.newURL));
62 | if (to === router.prePage) {
63 | // 后退
64 | router.pop(undefined, { action: false });
65 | }
66 | if (to !== router.prePage) {
67 | // 前进
68 | router.push(to, {
69 | query: router.currentQuery,
70 | action: false,
71 | });
72 | }
73 | break;
74 | case 'popstate':
75 | if (router.hash) {
76 | // popstate 可能是页面前进或后退,都会造成 hashchange
77 | break;
78 | }
79 | to = getPath(new URL(event.target.location.href));
80 | if (to === router.prePage) {
81 | // 后退
82 | router.pop(undefined, { action: false });
83 | }
84 | if (to !== router.prePage) {
85 | // 前进
86 | router.push(to, {
87 | query: router.currentQuery,
88 | action: false,
89 | });
90 | }
91 | break;
92 | default:
93 | break;
94 | }
95 | });
96 | };
97 |
--------------------------------------------------------------------------------
/packages/feiyu/src/utils/diff.ts:
--------------------------------------------------------------------------------
1 | // 来源:https://github.com/AsyncBanana/microdiff
2 |
3 | interface Difference {
4 | type: 'CREATE' | 'REMOVE' | 'CHANGE';
5 | path: (string | number)[];
6 | value?: any;
7 | }
8 | interface Options {
9 | cyclesFix: boolean;
10 | }
11 |
12 | const t = true;
13 | const richTypes = { Date: t, RegExp: t, String: t, Number: t };
14 |
15 | export function isEqual(oldObj: any, newObj: any): boolean {
16 | return (
17 | diff(
18 | {
19 | obj: oldObj,
20 | },
21 | { obj: newObj },
22 | ).length < 1
23 | );
24 | }
25 |
26 | export const isNotEqual = (oldObj: any, newObj: any) =>
27 | !isEqual(oldObj, newObj);
28 |
29 | function diff(
30 | obj: Record | any[],
31 | newObj: Record | any[],
32 | options: Partial = { cyclesFix: true },
33 | _stack: Record[] = [],
34 | ): Difference[] {
35 | const diffs: Difference[] = [];
36 | const isObjArray = Array.isArray(obj);
37 |
38 | for (const key in obj) {
39 | const objKey = obj[key];
40 | const path = isObjArray ? Number(key) : key;
41 | if (!(key in newObj)) {
42 | diffs.push({
43 | type: 'REMOVE',
44 | path: [path],
45 | });
46 | continue;
47 | }
48 | const newObjKey = newObj[key];
49 | const areObjects =
50 | typeof objKey === 'object' && typeof newObjKey === 'object';
51 | if (
52 | objKey &&
53 | newObjKey &&
54 | areObjects &&
55 | !richTypes[Object.getPrototypeOf(objKey).constructor.name] &&
56 | (options.cyclesFix ? !_stack.includes(objKey) : true)
57 | ) {
58 | const nestedDiffs = diff(
59 | objKey,
60 | newObjKey,
61 | options,
62 | options.cyclesFix ? _stack.concat([objKey]) : [],
63 | );
64 | // eslint-disable-next-line prefer-spread
65 | diffs.push.apply(
66 | diffs,
67 | nestedDiffs.map((difference) => {
68 | difference.path.unshift(path);
69 | return difference;
70 | }),
71 | );
72 | } else if (
73 | objKey !== newObjKey &&
74 | !(
75 | areObjects &&
76 | (Number.isNaN(objKey)
77 | ? String(objKey) === String(newObjKey)
78 | : Number(objKey) === Number(newObjKey))
79 | )
80 | ) {
81 | diffs.push({
82 | path: [path],
83 | type: 'CHANGE',
84 | value: newObjKey,
85 | });
86 | }
87 | }
88 |
89 | const isNewObjArray = Array.isArray(newObj);
90 | for (const key in newObj) {
91 | if (!(key in obj)) {
92 | diffs.push({
93 | type: 'CREATE',
94 | path: [isNewObjArray ? Number(key) : key],
95 | value: newObj[key],
96 | });
97 | }
98 | }
99 | return diffs;
100 | }
101 |
--------------------------------------------------------------------------------
/packages/feiyu/src/pages/home/play/xg-preset.ts:
--------------------------------------------------------------------------------
1 | import { I18N } from 'xgplayer';
2 | import ZH from 'xgplayer/es/lang/zh-cn';
3 | import Thumbnail from 'xgplayer/es/plugins/common/thumbnail';
4 | import Enter from 'xgplayer/es/plugins/enter';
5 | import Error from 'xgplayer/es/plugins/error';
6 | import Fullscreen from 'xgplayer/es/plugins/fullscreen';
7 | import GapJump from 'xgplayer/es/plugins/gapJump';
8 | import Keyboard from 'xgplayer/es/plugins/keyboard';
9 | import Mobile from 'xgplayer/es/plugins/mobile';
10 | import PC from 'xgplayer/es/plugins/pc';
11 | import PIPIcon from 'xgplayer/es/plugins/pip';
12 | import PlayIcon from 'xgplayer/es/plugins/play';
13 | import PlaybackRate from 'xgplayer/es/plugins/playbackRate';
14 | import Poster from 'xgplayer/es/plugins/poster';
15 | import Progress from 'xgplayer/es/plugins/progress';
16 | import MiniProgress from 'xgplayer/es/plugins/progress/miniProgress';
17 | import ProgressPreview from 'xgplayer/es/plugins/progressPreview';
18 | import Prompt from 'xgplayer/es/plugins/prompt';
19 | import Start from 'xgplayer/es/plugins/start';
20 | import TimeIcon from 'xgplayer/es/plugins/time';
21 | import TimeSegments from 'xgplayer/es/plugins/time/timesegments';
22 | import Volume from 'xgplayer/es/plugins/volume';
23 | import WaitingTimeoutJump from 'xgplayer/es/plugins/waitingTimeoutJump';
24 | import sniffer from 'xgplayer/es/utils/sniffer';
25 |
26 | import { HlsPlugin, Loading, PlayNext, Replay } from './xg-plugins';
27 |
28 | // @ts-ignore
29 | ZH.TEXT.FULLSCREEN_TIPS = '全屏';
30 |
31 | I18N.use(ZH);
32 |
33 | export class XgPreset {
34 | plugins: any[];
35 | ignores: any[];
36 | i18n: any[];
37 |
38 | constructor(options, playerConfig) {
39 | const simulateMode =
40 | playerConfig && playerConfig.isMobileSimulateMode === 'mobile';
41 |
42 | const vodPlugins = [
43 | TimeSegments,
44 | Progress,
45 | MiniProgress,
46 | ProgressPreview,
47 | TimeIcon,
48 | ];
49 |
50 | const contolsIcons = [
51 | ...vodPlugins,
52 | PlayIcon,
53 | Fullscreen,
54 | PlayNext,
55 | Volume,
56 | PIPIcon,
57 | PlaybackRate,
58 | ];
59 |
60 | const layers = [
61 | Replay,
62 | Poster,
63 | Start,
64 | Loading,
65 | Enter,
66 | Error,
67 | Prompt,
68 | Thumbnail,
69 | ];
70 |
71 | this.plugins = [
72 | HlsPlugin,
73 | ...contolsIcons,
74 | ...layers,
75 | GapJump,
76 | WaitingTimeoutJump,
77 | ];
78 | const mode = simulateMode ? 'mobile' : sniffer.device;
79 | switch (mode) {
80 | case 'pc':
81 | this.plugins.push(...[Keyboard, PC]);
82 | break;
83 | case 'mobile':
84 | this.plugins.push(...[Mobile]);
85 | break;
86 | default:
87 | this.plugins.push(...[Keyboard, PC]);
88 | }
89 | if (sniffer.os.isIpad) {
90 | this.plugins.push(PC);
91 | }
92 | this.ignores = [];
93 | this.i18n = [];
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/packages/feiyu/src/app/style.css:
--------------------------------------------------------------------------------
1 | .app-header {
2 | -webkit-user-select: none;
3 | -moz-user-select: none;
4 | -ms-user-select: none;
5 | user-select: none;
6 | }
7 |
8 | .glass-effect {
9 | -webkit-backdrop-filter: blur(10px);
10 | backdrop-filter: blur(10px);
11 | background: rgba(255, 255, 255, 0.8);
12 | }
13 |
14 | .arco-btn-loading::before {
15 | background: transparent !important;
16 | }
17 |
18 | body[arco-theme='dark'] .glass-effect {
19 | background: rgba(0, 0, 0, 0.8);
20 | }
21 |
22 | div[data-type='offscreen-wrapper'] {
23 | width: 100% !important;
24 | }
25 |
26 | .arco-layout-sider {
27 | z-index: 3 !important;
28 | }
29 |
30 | .arco-menu-selected {
31 | background: rgb(var(--primary-6)) !important;
32 | color: var(--color-bg-white) !important;
33 | }
34 |
35 | .arco-menu-selected .arco-icon {
36 | color: var(--color-bg-white) !important;
37 | }
38 |
39 | /* 通知横幅 */
40 | .arco-notification {
41 | margin-bottom: 0 !important;
42 | }
43 |
44 | /* 移动端样式 */
45 | @media only screen and (max-width: 768px) {
46 | .arco-modal-search {
47 | width: 100% !important;
48 | top: 0 !important;
49 | }
50 |
51 | .zoomModal-appear,
52 | .zoomModal-enter-active,
53 | .zoomModal-exit-active,
54 | .fadeModal-appear,
55 | .fadeModal-enter-active,
56 | .fadeModal-exit-active {
57 | transition: none !important;
58 | }
59 |
60 | /* 通知横幅 */
61 | .arco-notification {
62 | width: 100% !important;
63 | }
64 |
65 | .arco-notification-wrapper-bottomRight {
66 | width: 100%;
67 | bottom: 0 !important;
68 | right: 0 !important;
69 | padding: 20px;
70 | }
71 | }
72 |
73 | .ac-navbar-search-input {
74 | width: 140px;
75 | height: 32px;
76 | display: flex;
77 | align-items: center;
78 | justify-content: space-between;
79 | background-color: var(--color-fill-2);
80 | border-radius: 2px;
81 | padding: 0 8px;
82 | margin: 0 24px;
83 | transition: all 0.2s;
84 | cursor: pointer;
85 | }
86 |
87 | .ac-navbar-search-input-placeholder {
88 | color: var(--color-text-3);
89 | margin: 0 6px;
90 | vertical-align: 1px;
91 | user-select: none;
92 | }
93 |
94 | .ac-navbar-search-input:hover {
95 | background-color: var(--color-fill-3);
96 | }
97 |
98 | .arco-modal {
99 | border-radius: 10px !important;
100 | }
101 |
102 | .arco-modal-content {
103 | padding: 0 !important;
104 | }
105 |
106 | .arco-drawer-content {
107 | padding: 0 !important;
108 | }
109 |
110 | div.arco-typography,
111 | p.arco-typography {
112 | margin-bottom: 0 !important;
113 | }
114 |
115 | .arco-rate-character:not(:last-child) {
116 | margin-right: 2px !important;
117 | }
118 |
119 | .arco-rate-character .arco-icon {
120 | width: 16px !important;
121 | height: 16px !important;
122 | }
123 |
124 | .arco-rate-inner {
125 | min-height: auto !important;
126 | }
127 |
128 | .arco-list-item:hover {
129 | background: var(--color-secondary-hover);
130 | }
131 |
132 | .arco-list-item:hover .arco-rate-character {
133 | color: var(--color-secondary-active);
134 | }
--------------------------------------------------------------------------------
/packages/feiyu/src/services/routes/location.ts:
--------------------------------------------------------------------------------
1 | import makeMatcher from 'wouter/matcher';
2 | import { navigate, useLocationProperty } from 'wouter/use-location';
3 | import { useXConsumer, XSta } from 'xsta';
4 |
5 | import { useInit } from '@/hooks/useInit';
6 | import { useInterval } from '@/hooks/useInterval';
7 | import { isEmpty } from '@/utils/is';
8 |
9 | import { router } from './router';
10 |
11 | const defaultMatcher = makeMatcher();
12 |
13 | /*
14 | * A custom routing matcher function that supports multipath routes
15 | */
16 | export const multiPathMatcher = (patterns, path) => {
17 | for (const pattern of [patterns].flat()) {
18 | const [match, params] = defaultMatcher(pattern, path);
19 | if (match) return [match, params];
20 | }
21 |
22 | return [false, null];
23 | };
24 |
25 | const _getLocation = (hash: string | undefined) =>
26 | isEmpty(hash) ? '/' : hash!.replace(/^#/, '').split('?')[0];
27 |
28 | /**
29 | * /path/to/page
30 | */
31 | export const getLocation = (path?: string, hash?: string) => {
32 | let _path = router.hash
33 | ? _getLocation(isEmpty(hash) ? window.location.hash : hash)
34 | : path ?? window.location.pathname;
35 | if (_path?.startsWith(router.base)) {
36 | // 替换 base path
37 | const base = router.base.replace(/\/$/, '');
38 | _path = _path!.replace(base, '');
39 | }
40 | return isEmpty(_path) ? '/' : _path;
41 | };
42 |
43 | /**
44 | * to: /path/to/page
45 | */
46 | export const lNavigate = (
47 | to: string,
48 | options?: {
49 | replace?: boolean;
50 | },
51 | ) => {
52 | // 去掉 basepath 最后的 /
53 | const base = router.base.replace(/\/$/, '');
54 | const path = base + (router.hash ? '/#' : '') + to;
55 | navigate(path, options);
56 | };
57 |
58 | export const addSearchParams = (query: Record) => {
59 | const url = new URL(window.location.href.replace('#/', ''));
60 | const currentQuery = {};
61 | url.searchParams.forEach((value, key) => {
62 | currentQuery[key] = value;
63 | });
64 | const currentPage = router.currentPage!;
65 | router.replace(currentPage.path, {
66 | query: { ...currentQuery, ...query },
67 | data: currentPage.data,
68 | });
69 | };
70 |
71 | let oldPath;
72 | export const useInitLocationListener = () => {
73 | useInterval(() => {
74 | const currentPath = window.location.href;
75 | if (oldPath !== currentPath) {
76 | // 路径变更,刷新路由
77 | refreshLocation();
78 | }
79 | oldPath = currentPath;
80 | }, 100);
81 | };
82 |
83 | const kLocationRefresh = 'kLocationRefresh';
84 | export const refreshLocation = () => {
85 | XSta.set(kLocationRefresh, !XSta.get(kLocationRefresh));
86 | };
87 | export const useLLocation = (): [
88 | string,
89 | (
90 | to: string,
91 | options?: {
92 | replace?: boolean | undefined;
93 | },
94 | ) => void,
95 | ] => {
96 | useInit(() => {
97 | if (isEmpty(XSta.get(kLocationRefresh))) {
98 | XSta.set(kLocationRefresh, false);
99 | }
100 | });
101 | useXConsumer(kLocationRefresh);
102 | const location = useLocationProperty(getLocation);
103 | return [location, lNavigate];
104 | };
105 |
--------------------------------------------------------------------------------
/packages/feiyu/vite.config.ts:
--------------------------------------------------------------------------------
1 | import legacy from '@vitejs/plugin-legacy';
2 | import react from '@vitejs/plugin-react-swc';
3 | import * as path from 'path';
4 | import { defineConfig, splitVendorChunkPlugin } from 'vite';
5 | import { createHtmlPlugin } from 'vite-plugin-html';
6 | import vitePluginImp from 'vite-plugin-imp';
7 | import { VitePWA } from 'vite-plugin-pwa';
8 |
9 | const title = '飞鱼';
10 | const description = '一个漂亮得不像实力派的在线视频播放器✨';
11 |
12 | export default defineConfig({
13 | plugins: [
14 | react(),
15 | // 分包加载
16 | splitVendorChunkPlugin(),
17 | // @arco-design 组件样式按需加载
18 | vitePluginImp({
19 | libList: [
20 | {
21 | libName: '@arco-design/web-react',
22 | camel2DashComponentName: false,
23 | style: (name) => {
24 | return [
25 | '@arco-design/web-react/lib/style/index.less',
26 | `@arco-design/web-react/lib/${name}/style/index.less`,
27 | ];
28 | },
29 | },
30 | ],
31 | }),
32 | // html 压缩 + 元数据替换
33 | createHtmlPlugin({
34 | minify: true,
35 | inject: {
36 | data: {
37 | title,
38 | description,
39 | },
40 | },
41 | }),
42 | // PWA
43 | VitePWA({
44 | outDir: 'dist/pwa',
45 | registerType: 'prompt',
46 | workbox: {
47 | globPatterns: ['../**/*.{js,css,html,jpg,png,svg,gif}'],
48 | globIgnores: ['**/node_modules/**/*', 'pwa/sw.js', 'pwa/workbox-*.js'],
49 | },
50 | manifest: {
51 | lang: 'zh-CN',
52 | name: title,
53 | short_name: title,
54 | description: description,
55 | theme_color: '#ffffff',
56 | display: 'standalone',
57 | orientation: 'portrait',
58 | icons: [
59 | {
60 | src: 'pwa/logo-192.png',
61 | sizes: '192x192',
62 | type: 'image/png',
63 | },
64 | {
65 | src: 'pwa/logo-512.png',
66 | sizes: '512x512',
67 | type: 'image/png',
68 | },
69 | {
70 | src: 'pwa/logo-512.png',
71 | sizes: '512x512',
72 | type: 'image/png',
73 | purpose: 'maskable',
74 | },
75 | ],
76 | },
77 | }),
78 | // 兼容旧版浏览器
79 | legacy(),
80 | ],
81 | envPrefix: 'k',
82 | resolve: {
83 | alias: [{ find: '@', replacement: path.resolve(__dirname, 'src/') }],
84 | },
85 | server: {
86 | host: '0.0.0.0',
87 | port: 3000,
88 | strictPort: true,
89 | open: false,
90 | },
91 | build: {
92 | minify: true,
93 | chunkSizeWarningLimit: 1024, // 1MB
94 | rollupOptions: {
95 | output: {
96 | manualChunks(id) {
97 | if (id.includes('node_modules')) {
98 | const lib = id.split('node_modules/')[1].split('/')[0];
99 | if (
100 | ['@arco-design', 'localforage', 'lodash', 'dayjs'].includes(lib)
101 | ) {
102 | return lib;
103 | }
104 | }
105 | },
106 | },
107 | },
108 | },
109 | });
110 |
--------------------------------------------------------------------------------
/packages/feiyu-desktop/src-tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json",
3 | "productName": "飞鱼",
4 | "version": "1.0.0",
5 | "identifier": "xbox.work.feiyu",
6 | "build": {
7 | "beforeDevCommand": "cd ../feiyu && pnpm dev:desktop",
8 | "devUrl": "http://localhost:3000",
9 | "frontendDist": "../../feiyu/dist"
10 | },
11 | "app": {
12 | "withGlobalTauri": true,
13 | "macOSPrivateApi": true,
14 | "windows": [
15 | {
16 | "title": "飞鱼",
17 | "width": 950,
18 | "height": 650,
19 | "minWidth": 310,
20 | "minHeight": 600,
21 | "shadow": false,
22 | "center": true,
23 | "decorations": false,
24 | "transparent": true,
25 | "hiddenTitle": true,
26 | "titleBarStyle": "Overlay"
27 | }
28 | ],
29 | "security": {
30 | "csp": null
31 | }
32 | },
33 | "plugins": {
34 | "updater": {
35 | "active": true,
36 | "dialog": false,
37 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ3MDIyQ0IxQTBDRjZBNUEKUldSYWFzK2dzU3dDMTY0MjJtVFA2ZnVLK0NZWUl6MlV0ejdJSCtzMHQvV3RtcG1LZDJYcGFjdUoK",
38 | "endpoints": [
39 | "https://github.com/idootop/feiyu-player/releases/download/updater/latest.json"
40 | ]
41 | }
42 | },
43 | "bundle": {
44 | "active": true,
45 | "targets": "all",
46 | "copyright": "Copyright © 2023 Del.Wang",
47 | "shortDescription": "一个漂亮得不像实力派的在线视频播放器 ✨",
48 | "longDescription": "The light and shadow, a brighter world to see.",
49 | "icon": [
50 | "icons/32x32.png",
51 | "icons/128x128.png",
52 | "icons/128x128@2x.png",
53 | "icons/icon.icns",
54 | "icons/icon.ico"
55 | ],
56 | "macOS": {
57 | "entitlements": null,
58 | "frameworks": [],
59 | "providerShortName": null,
60 | "signingIdentity": null,
61 | "dmg": {
62 | "background": "images/dmg-background.jpg",
63 | "appPosition": {
64 | "x": 180,
65 | "y": 170
66 | },
67 | "applicationFolderPosition": {
68 | "x": 480,
69 | "y": 170
70 | },
71 | "windowSize": {
72 | "height": 400,
73 | "width": 660
74 | }
75 | }
76 | },
77 | "windows": {
78 | "digestAlgorithm": "sha256",
79 | "timestampUrl": "",
80 | "webviewInstallMode": {
81 | "type": "embedBootstrapper"
82 | },
83 | "wix": {
84 | "language": ["zh-CN"],
85 | "bannerPath": "images/wix_banner.png",
86 | "dialogImagePath": "images/wix_dialog.png"
87 | },
88 | "nsis": {
89 | "headerImage": "images/nsis_header.bmp",
90 | "sidebarImage": "images/nsis_sidebar.bmp",
91 | "installerIcon": "icons/icon.ico",
92 | "languages": ["SimpChinese"]
93 | }
94 | },
95 | "linux": {
96 | "deb": {
97 | "depends": ["libwebkit2gtk-4.1-dev"]
98 | },
99 | "rpm": {
100 | "depends": ["webkit2gtk4.1-devel"]
101 | },
102 | "appimage": {
103 | "bundleMediaFramework": true
104 | }
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/packages/feiyu/src/services/cache/index.ts:
--------------------------------------------------------------------------------
1 | let localStore;
2 |
3 | let _caches: Record = {};
4 |
5 | export const cache = {
6 | cacheDuration: 24 * 60 * 60 * 1000,
7 | async init() {
8 | if (!localStore) {
9 | const localforage = (await import('localforage')).default;
10 | localStore = localforage.createInstance({
11 | name: 'feiyu',
12 | storeName: 'cache',
13 | driver: localforage.INDEXEDDB,
14 | });
15 | }
16 | },
17 | async get(key: string): Promise {
18 | await cache.init();
19 | const _cache =
20 | _caches[key] ?? (await localStore.getItem(key).catch(() => undefined));
21 | if (!_cache) return undefined;
22 | const expired = Date.now() > _cache.expiredTime;
23 | if (!expired) return _cache.data;
24 | },
25 | /**
26 | * 默认 data 为空时不更新值,可使用 force 强制更新空值
27 | */
28 | async set(
29 | key: string,
30 | data: any,
31 | config?: { force?: boolean; cacheDuration?: number },
32 | ) {
33 | await cache.init();
34 | const { force, cacheDuration = cache.cacheDuration } = config ?? {};
35 | if (force || data) {
36 | _caches[key] = {
37 | data: data,
38 | expiredTime: Date.now() + cacheDuration,
39 | };
40 | await localStore.setItem(key, _caches[key]).catch(() => undefined);
41 | }
42 | },
43 | /**
44 | * 清除已过期的缓存
45 | */
46 | async clearExpired() {
47 | await cache.init();
48 | let keys = await localStore.keys().catch(() => []);
49 | const expiredKeys: string[] = [];
50 | // 找到内存中已过期的 key
51 | const currentKeys = Object.keys(_caches);
52 | currentKeys.forEach((key) => {
53 | const _cache = _caches[key];
54 | if (Date.now() > (_cache?.expiredTime ?? 0)) {
55 | expiredKeys.push(key);
56 | }
57 | });
58 | // 找到本地存储中已过期的 key
59 | keys = keys.filter((e) => !currentKeys.includes(e));
60 | await Promise.all(
61 | keys.map((key) => {
62 | (async () => {
63 | const _cache: any = await localStore
64 | .getItem(key)
65 | .catch(() => undefined);
66 | if (Date.now() > (_cache?.expiredTime ?? 0)) {
67 | expiredKeys.push(key);
68 | }
69 | })();
70 | }),
71 | );
72 | // 清空本地和内存中过期的 key
73 | await Promise.all(
74 | expiredKeys.map((key) =>
75 | (async () => {
76 | delete _caches[key];
77 | await localStore.removeItem(key).catch(() => undefined);
78 | })(),
79 | ),
80 | );
81 | },
82 | /**
83 | * 清除缓存
84 | */
85 | async reset() {
86 | await cache.init();
87 | _caches = {};
88 | await localStore.clear().catch(() => undefined);
89 | },
90 |
91 | async readOrWrite(
92 | cacheKey: string,
93 | onWrite: () => Promise,
94 | cacheEmpty?: boolean,
95 | ): Promise {
96 | const cacheData = await cache.get(cacheKey);
97 | if (cacheEmpty ? cacheData : cacheData?.data) {
98 | return cacheData.data;
99 | }
100 | const data = await onWrite();
101 | cache.set(cacheKey, { data });
102 | return data;
103 | },
104 | };
105 |
--------------------------------------------------------------------------------
/packages/feiyu/src/pages/home/hot/index.tsx:
--------------------------------------------------------------------------------
1 | import './style.css';
2 |
3 | import { Rate } from '@arco-design/web-react';
4 |
5 | import { PageBuilder } from '@/app';
6 | import { Box } from '@/components/Box';
7 | import { Column, Row } from '@/components/Flex';
8 | import { LazyImage } from '@/components/LazyImage/LazyImage';
9 | import { Loading } from '@/components/Loading';
10 | import { SearchEmpty } from '@/components/SearchEmpty';
11 | import { Text } from '@/components/Text';
12 | import { useInit } from '@/hooks/useInit';
13 | import { colors } from '@/styles/colors';
14 | import { DoubanHotMovie } from '@/utils/douban';
15 | import { useSearchHotMovies } from '@/utils/douban/useSearchHotMovies';
16 |
17 | import { useHomePages } from '../useHomePages';
18 |
19 | const Hot = () => {
20 | const {
21 | refresh,
22 | initial,
23 | searching,
24 | datas: hotMovies,
25 | noData,
26 | } = useSearchHotMovies();
27 | const { jumpToPage } = useHomePages();
28 | useInit(refresh);
29 | return initial ? (
30 |
31 | ) : searching && noData ? (
32 |
33 |
34 |
35 | ) : noData ? (
36 |
43 |
44 |
45 | ) : (
46 | <>
47 |
48 | {[...hotMovies].map((movie) => (
49 |
50 | ))}
51 |
52 |
59 | {searching ? : }
60 |
61 | {searching ? '加载中...' : "没有更多了,喵呜 ฅ'ω'ฅ"}
62 |
63 |
64 | >
65 | );
66 | };
67 |
68 | export default (
69 |
70 |
71 |
72 | );
73 |
74 | const MovieItem = (props: { movie: DoubanHotMovie; jumpToPage: any }) => {
75 | const { movie, jumpToPage } = props;
76 |
77 | const onClick = async () => {
78 | jumpToPage('search', {
79 | query: {
80 | movie: movie.title,
81 | },
82 | });
83 | };
84 |
85 | return (
86 |
87 |
88 |
95 |
103 | {movie.rate === '0' ? '未知' : movie.rate}
104 |
105 |
114 | {movie.title}
115 |
116 |
117 | );
118 | };
119 |
--------------------------------------------------------------------------------
/packages/feiyu/src/hooks/useSearchDatas.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'react';
2 |
3 | import { useAbort } from '@/hooks/useAbort';
4 |
5 | import { useRebuildRef } from './useRebuild';
6 |
7 | const _isEqual = (a: any, b: any) => a === b;
8 |
9 | export const useSearchDatas = (config: {
10 | onSearch: (props: {
11 | query: Q;
12 | signal: AbortSignal;
13 | callback: (data: R) => void;
14 | }) => Promise;
15 | initialDatas?: R[];
16 | isEqual?: (a: Q | undefined, b: Q) => boolean;
17 | onlyCallback?: boolean;
18 | }): {
19 | search: (query: Q) => void;
20 | refresh: () => void;
21 | initial: boolean;
22 | searching: boolean;
23 | reset: () => void;
24 | datas: R[];
25 | query: Q | undefined;
26 | noData: boolean;
27 | sort: (compareFn: (a: R, b: R) => number) => void;
28 | } => {
29 | const {
30 | onSearch,
31 | initialDatas,
32 | isEqual = _isEqual,
33 | onlyCallback = false,
34 | } = config;
35 |
36 | // Search status
37 | const abortRef = useAbort();
38 | const ref = useRef({
39 | query: undefined as any,
40 | searching: false,
41 | datas: [] as any[],
42 | initial: true,
43 | });
44 | const abortSearch = () => {
45 | abortRef.current!.abortPre();
46 | };
47 |
48 | // Datas
49 | let datas = ref.current.datas;
50 | const initial = ref.current.initial;
51 | datas = initial ? initialDatas ?? [] : datas;
52 |
53 | // Add new data
54 | const rebuildRef = useRebuildRef();
55 | const addData = useCallback((data: R) => {
56 | ref.current.datas.push(data);
57 | rebuildRef.current.rebuild();
58 | }, []);
59 |
60 | const _search = useCallback(async (newQuery: Q) => {
61 | abortSearch(); // 终止之前的搜索 promise 和 callback
62 | ref.current.initial = false;
63 | ref.current.query = newQuery;
64 | ref.current.datas = []; // 清除之前的搜索结果
65 | ref.current.searching = true; // 开始搜索
66 | rebuildRef.current.rebuild();
67 | // 搜索
68 | const currentSignal = abortRef.current!.signal;
69 | const datas = await onSearch({
70 | query: newQuery,
71 | signal: currentSignal,
72 | callback: (data) => {
73 | if (!currentSignal?.aborted) {
74 | addData(data);
75 | }
76 | },
77 | }).catch(() => undefined);
78 | if (!currentSignal?.aborted) {
79 | ref.current.searching = false; // 搜索结束
80 | if (!onlyCallback && datas) {
81 | ref.current.datas = datas;
82 | }
83 | }
84 | rebuildRef.current.rebuild();
85 | }, []);
86 |
87 | const reset = useCallback(() => {
88 | abortSearch(); // 终止之前的搜索 promise 和 callback
89 | ref.current.initial = true;
90 | ref.current.query = undefined;
91 | ref.current.datas = []; // 清除之前的搜索结果
92 | ref.current.searching = false; // 搜索结束
93 | rebuildRef.current.rebuild();
94 | }, []);
95 |
96 | return {
97 | initial,
98 | searching: ref.current.searching,
99 | search: (newQuery: Q) => {
100 | if (isEqual(ref.current.query, newQuery)) {
101 | return;
102 | }
103 | return _search(newQuery);
104 | },
105 | refresh: () => {
106 | return _search(ref.current.query);
107 | },
108 | reset,
109 | datas,
110 | query: ref.current.query,
111 | noData: datas.length < 1,
112 | sort: (compareFn) => {
113 | ref.current.datas = ref.current.datas.sort(compareFn);
114 | rebuildRef.current.rebuild();
115 | },
116 | };
117 | };
118 |
--------------------------------------------------------------------------------
/packages/feiyu/src/app/SideMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Drawer, Menu, Message } from '@arco-design/web-react';
2 | import { useXState, XSta } from 'xsta';
3 |
4 | import { Box } from '@/components/Box';
5 | import { Row } from '@/components/Flex';
6 | import { useScreen } from '@/hooks/useScreen';
7 | import { kRoutePages } from '@/pages';
8 | import { usePages } from '@/services/routes/page';
9 | import { colors } from '@/styles/colors';
10 |
11 | import { kHeaderHeight } from './MyHeader';
12 |
13 | const MenuItem = Menu.Item;
14 |
15 | export const kSideWidth = 200;
16 | const kShowSideDrawer = 'showSideDrawer';
17 | const closeSideDrawer = () => XSta.set(kShowSideDrawer, false);
18 | export const useSideMenu = () => {
19 | const [showSideDrawer, setShowSideDrawer] = useXState(kShowSideDrawer);
20 | const { width } = useScreen();
21 | const { location } = usePages();
22 | const hideSideMenu =
23 | width < 650 || // 移动端
24 | (location === '/home/play' && width < 1250); // 播放页
25 | return {
26 | collapsed: width < 910,
27 | hideSideMenu,
28 | showSideDrawer,
29 | openSideDrawer() {
30 | setShowSideDrawer(true);
31 | },
32 | closeSideDrawer() {
33 | setShowSideDrawer(false);
34 | },
35 | };
36 | };
37 |
38 | const MyMenu = (props?: { isDrawer?: boolean }) => {
39 | const { isDrawer = false } = props ?? {};
40 | const showLogo = isDrawer;
41 | const { currentPage, jumpToPage, isIndexPage } = usePages();
42 | const { jumpToIndex } = usePages({ parent: '/home', index: 'hot' });
43 | return (
44 |
56 |
64 | {showLogo ? (
65 | {
68 | // 收起侧边栏
69 | closeSideDrawer();
70 | if (!isIndexPage) {
71 | jumpToIndex(); // 回到首页
72 | } else {
73 | Message.info("喵呜 ฅ'ω'ฅ");
74 | }
75 | }}
76 | >
77 |
78 |
86 | 飞鱼
87 |
88 |
89 | ) : (
90 |
91 | )}
92 |
93 | {kRoutePages.map((page) => {
94 | return (
95 | page.title}
98 | onClick={() => {
99 | // 收起侧边栏
100 | closeSideDrawer();
101 | // 跳转页面
102 | const available = ['home', 'settings'].includes(page.key);
103 | if (available) {
104 | if (page.key === 'home') {
105 | jumpToIndex();
106 | } else {
107 | jumpToPage(page.key);
108 | }
109 | } else {
110 | Message.info('Coming soon');
111 | }
112 | }}
113 | >
114 | {page.icon}
115 | {page.title}
116 |
117 | );
118 | })}
119 |
120 | );
121 | };
122 |
123 | export const SideMenu = () => ;
124 |
125 | export const SideDrawer = () => {
126 | const { showSideDrawer, closeSideDrawer } = useSideMenu();
127 | return (
128 |
140 |
141 |
142 | );
143 | };
144 |
--------------------------------------------------------------------------------
/packages/feiyu/src/services/routes/router.ts:
--------------------------------------------------------------------------------
1 | import { delay, firstOf, lastOf } from '@/utils/base';
2 |
3 | import { getLocation, lNavigate } from './location';
4 |
5 | export interface NavigateOption {
6 | data?: any;
7 | query?: Record;
8 | action?: boolean;
9 | replace?: boolean;
10 | }
11 |
12 | class Router {
13 | /**
14 | * 网页 /base/
15 | */
16 | base = '/';
17 | /**
18 | * 是否使用 hashRoute
19 | */
20 | hash = false;
21 | /**
22 | * 获取当前页面路径
23 | *
24 | * /pages/page
25 | */
26 | get current(): string {
27 | return getLocation();
28 | }
29 |
30 | /**
31 | * 获取上个页面传递给当前页面的参数
32 | */
33 | get currentData(): any {
34 | return lastOf(this._pages)?.data;
35 | }
36 |
37 | /**
38 | * 当前页面的 query 参数
39 | */
40 | get currentQuery(): Record {
41 | const url = new URL(window.location.href.replace('#/', ''));
42 | const query = {};
43 | url.searchParams.forEach((value, key) => {
44 | query[key] = value;
45 | });
46 | return query;
47 | }
48 |
49 | /**
50 | * 根路由
51 | */
52 | get root(): string {
53 | return firstOf(this._pages)?.path ?? this.current;
54 | }
55 |
56 | /**
57 | * 当前是否为根路由
58 | */
59 | get isRoot(): boolean {
60 | return this._pages.length < 1 || this.root === this.current;
61 | }
62 |
63 | /**
64 | * 当前路由堆栈
65 | */
66 | private _pages: {
67 | path: string;
68 | query?: Record;
69 | data: any;
70 | resolve: any;
71 | }[] = [];
72 |
73 | get pages() {
74 | return this._pages;
75 | }
76 |
77 | get history(): string[] {
78 | return this._pages.map((e) => e.path);
79 | }
80 |
81 | get currentPage() {
82 | return lastOf(this._pages);
83 | }
84 |
85 | get prePage(): string | undefined {
86 | return this._pages.length > 1
87 | ? this._pages[this._pages.length - 2].path
88 | : undefined;
89 | }
90 |
91 | init(): void {
92 | this._pages = [];
93 | this.push(this.current, {
94 | query: this.currentQuery,
95 | action: false,
96 | init: true,
97 | });
98 | }
99 |
100 | /**
101 | * 打开新页面
102 | */
103 | push(
104 | to: string,
105 | options?: NavigateOption & { init?: boolean },
106 | ): Promise {
107 | const { query = {}, action = true, replace, init, data } = options ?? {};
108 | if (!action && !replace && !init && to === lastOf(this.history)) {
109 | // 非手动触发的路由变更
110 | return delay(0) as any;
111 | }
112 | // 拼接带 query 的 path
113 | const origin = window.location.origin;
114 | const toURL = new URL(origin + to);
115 | Object.entries(query).forEach(([key, value]) => {
116 | toURL.searchParams.append(key, value);
117 | });
118 | // 解析 query
119 | const _query = {};
120 | toURL.searchParams.forEach((value, key) => {
121 | _query[key] = value;
122 | });
123 | const newPathWithQuery = toURL.href.replace(origin, '');
124 | // 等待下个页面返回数据
125 | return new Promise((resolve) => {
126 | this._pages.push({
127 | path: to,
128 | query: _query,
129 | data,
130 | resolve,
131 | });
132 | if (action) {
133 | // 跳转到新页面
134 | lNavigate(newPathWithQuery, { replace });
135 | }
136 | });
137 | }
138 |
139 | /**
140 | * 返回上一页
141 | */
142 | pop(data?: T, options?: { action?: boolean }): void {
143 | const { action = true } = options ?? {};
144 | if (action) {
145 | // 返回上一页
146 | window.history.back();
147 | }
148 | // 找到最后一页
149 | const prePage = lastOf(this._pages);
150 | // 将 data 返回给上一页
151 | prePage?.resolve(data);
152 | // 删除最后一页
153 | this._pages.splice(this._pages.length - 1, 1);
154 | }
155 |
156 | /**
157 | * 替换页面
158 | */
159 | replace(
160 | to: string,
161 | options?: Pick,
162 | ): Promise {
163 | this.pop(undefined, { action: false });
164 | return this.push(to, {
165 | ...options,
166 | replace: true,
167 | action: true,
168 | });
169 | }
170 |
171 | /**
172 | * 连续返回多层页面
173 | */
174 | pops(times = 1): void {
175 | for (let i = 0; i < times; i++) {
176 | this.pop();
177 | }
178 | }
179 | }
180 |
181 | export const router = new Router();
182 |
--------------------------------------------------------------------------------
/packages/feiyu/src/pages/home/play/player.tsx:
--------------------------------------------------------------------------------
1 | import 'xgplayer/dist/index.min.css';
2 | import './style.css';
3 |
4 | import { forwardRef, useEffect, useRef } from 'react';
5 | import XgPlayer, { Events } from 'xgplayer/es/player';
6 | import { XSta } from 'xsta';
7 |
8 | import { Box } from '@/components/Box';
9 | import { lastOf } from '@/utils/base';
10 | import { addClass, removeClass } from '@/utils/dom';
11 |
12 | import { isPlayPage } from '.';
13 | import { XgPreset } from './xg-preset';
14 |
15 | const kPlayerKey = 'kPlayerKey';
16 |
17 | interface PlayerConfig {
18 | current: string;
19 | playList: string[];
20 | onPlayNext: (next: string, idx: number) => void;
21 | pause?: boolean;
22 | }
23 |
24 | // todo 记忆并恢复上次播放历史进度
25 | export const Player = forwardRef(
26 | (props: PlayerConfig & { current?: string }, ref: any) => {
27 | const player = useRef();
28 |
29 | // 离开当前页面时,暂停播放。重新激活时,恢复播放。
30 | useEffect(() => {
31 | if (props.pause && player.current?.play) {
32 | player.current?.pause();
33 | }
34 | if (!props.pause && player.current?.pause) {
35 | player.current?.play();
36 | }
37 | }, [props.pause]);
38 |
39 | // 播放地址变更
40 | useEffect(() => {
41 | if (!isPlayPage()) return;
42 | if (!props.current && player.current) {
43 | // 没有选中的视频
44 | player.current.src = '';
45 | return;
46 | }
47 | XSta.set(kPlayerKey, props);
48 | setTimeout(() => {
49 | const { current, playList } = XSta.get(kPlayerKey) ?? {};
50 | if (!player.current) {
51 | // 初始化视频播放器
52 | player.current = new XgPlayer({
53 | id: 'player',
54 | url: current,
55 | pip: true,
56 | autoplay: true,
57 | crossOrigin: true,
58 | videoInit: true,
59 | fluid: true,
60 | lang: 'zh-cn',
61 | presets: [XgPreset],
62 | playNext: { urlList: playList },
63 | playbackRate: {
64 | list: [
65 | {
66 | text: 'x2',
67 | rate: 2,
68 | },
69 | {
70 | text: 'x1.5',
71 | rate: 1.5,
72 | },
73 | {
74 | text: 'x1',
75 | iconText: '倍速',
76 | rate: 1,
77 | },
78 | {
79 | text: 'x0.5',
80 | rate: 0.5,
81 | },
82 | ],
83 | },
84 | });
85 | player.current.on(Events.PLAYNEXT, () => {
86 | const { current, playList, onPlayNext } =
87 | XSta.get(kPlayerKey)!;
88 | const hasNext = lastOf(playList) !== current;
89 | if (hasNext) {
90 | // 选中并播放下一集
91 | const currentIdx = playList.indexOf(current);
92 | const nextVideo = playList[currentIdx + 1];
93 | playerSwitchURL(player.current, nextVideo);
94 | onPlayNext?.(nextVideo, currentIdx + 1);
95 | }
96 | });
97 | }
98 | // 播放当前视频
99 | playerSwitchURL(player.current, current);
100 | // 是否有下一集
101 | const hasNext = lastOf(playList) !== current;
102 | // 控制下一集按钮是否显示
103 | const playNextDoms = [
104 | document.getElementsByClassName(
105 | 'xgplayer-playnext',
106 | )[0] as HTMLElement,
107 | document.getElementsByClassName('playnext-button')[0] as HTMLElement,
108 | ];
109 | playNextDoms.forEach((playNextDom) => {
110 | if (hasNext) {
111 | removeClass(playNextDom, 'no-next');
112 | } else {
113 | addClass(playNextDom, 'no-next');
114 | }
115 | });
116 | }, 100);
117 | }, [props.current]);
118 |
119 | return ;
120 | },
121 | );
122 |
123 | const playerSwitchURL = (player, url) => {
124 | if (!player || !url) {
125 | return;
126 | }
127 | player.pause();
128 | player.currentTime = 0;
129 | if (player.switchURL) {
130 | player.switchURL(url);
131 | } else {
132 | player.src = url;
133 | }
134 | player.config.url = url;
135 | player.play();
136 | };
137 |
--------------------------------------------------------------------------------
/packages/feiyu/src/app/initAPP.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Message, Notification } from '@arco-design/web-react';
2 | import { FeiyuDesktop } from 'feiyu-desktop';
3 | import { useEffect, useState } from 'react';
4 | import { registerSW } from 'virtual:pwa-register';
5 |
6 | import { Box } from '@/components/Box';
7 | import { Dialog } from '@/components/Dialog';
8 | import { Text } from '@/components/Text';
9 | import { appConfig } from '@/data/config';
10 | import { useDesktopUpdater } from '@/hooks/useDesktopUpdater';
11 | import { useInit } from '@/hooks/useInit';
12 | import { usePWA } from '@/hooks/usePWA';
13 | import { useRebuildRef } from '@/hooks/useRebuild';
14 | import { cache } from '@/services/cache';
15 | import { useFallbackToIndex } from '@/services/routes/page';
16 | import { storage } from '@/services/storage/storage';
17 |
18 | import { kRoutePages } from '../pages';
19 |
20 | const _initAPP = async (rebuildRef: any) => {
21 | // 初始化桌面环境
22 | await FeiyuDesktop.init();
23 | // 初始化APP配置信息
24 | await appConfig.init();
25 | // 注册 service worker(自动更新)
26 | registerSW({ immediate: true });
27 | // 清除已过期的本地缓存
28 | cache.clearExpired();
29 | // 刷新 APP 页面
30 | rebuildRef.current.rebuild();
31 | };
32 |
33 | export const useInitAPP = () => {
34 | // 当没有在其他子页面时,默认转到首页
35 | useFallbackToIndex(kRoutePages, { parent: '/' });
36 |
37 | // APP 初始化
38 | const rebuildRef = useRebuildRef();
39 | useInit(() => {
40 | _initAPP(rebuildRef).catch((e) => {
41 | console.trace('❌ initAPP error', e);
42 | });
43 | }, []);
44 |
45 | // APP 升级弹窗
46 | useUpdateAPP();
47 | };
48 |
49 | const useUpdateAPP = () => {
50 | let { needUpdate, update } = usePWA();
51 | const { needUpdate: needUpdateDesktop, update: updateDesktop } =
52 | useDesktopUpdater();
53 | if (needUpdateDesktop) {
54 | needUpdate = needUpdateDesktop;
55 | update = updateDesktop;
56 | }
57 | useEffect(() => {
58 | if (needUpdate) {
59 | const id = 'needUpdate';
60 | Notification.info({
61 | id,
62 | showIcon: false,
63 | title: '✨ 发现新版本',
64 | content: "是否立即升级?喵呜 ฅ'ω'ฅ",
65 | position: 'bottomRight',
66 | duration: 0,
67 | btn: (
68 |
69 | Notification.remove(id)}
73 | style={{ margin: '0 12px' }}
74 | >
75 | 取消
76 |
77 | {
81 | if (needUpdateDesktop && FeiyuDesktop.isWindows) {
82 | Message.info('开始下载更新,请稍等...');
83 | }
84 | update();
85 | Notification.remove(id);
86 | }}
87 | >
88 | 确定
89 |
90 |
91 | ),
92 | });
93 | }
94 | }, [needUpdate]);
95 | };
96 |
97 | export const useDisclaimer = () => {
98 | const kDisclaimerAgreed = 'kDisclaimerAgreed';
99 | const [show, setShow] = useState(false);
100 | useEffect(() => {
101 | setTimeout(async () => {
102 | const agreed = storage.get(kDisclaimerAgreed);
103 | if (!agreed) {
104 | setShow(true);
105 | }
106 | }, 0);
107 | }, []);
108 | return (
109 | {
114 | setShow(false);
115 | storage.set(kDisclaimerAgreed, true);
116 | }}
117 | cancel="退出"
118 | onCancel={() => {
119 | if (FeiyuDesktop.isDesktop) {
120 | FeiyuDesktop.window?.close();
121 | } else {
122 | window.location.href = 'about:blank';
123 | }
124 | }}
125 | >
126 |
127 |
134 | 最新版本: V2.0 生效日期: 2024年4月1日
135 |
136 | 1.
137 | 本项目(飞鱼)是一个开源的视频播放器软件,仅供个人合法地点播、学习和研究使用,严禁将其用于任何商业、违法或不当用途,否则由此产生的一切后果由用户自行承担。
138 |
139 | 2.
140 | 本软件仅作为一个通用播放器使用,不针对任何特定内容提供源,用户应自行判断所播放内容的合法性并承担相应责任,开发者对用户播放的任何内容不承担任何责任。
141 |
142 | 3.
143 | 用户在使用本软件时,必须完全遵守所在地区的法律法规,严禁将本软件用于任何非法用途,如传播违禁信息、侵犯他人知识版权、破坏网络安全等,否则由此产生的一切后果由用户自行承担。
144 |
145 | 4.
146 | 用户使用本软件所产生的任何风险或损失(包括但不限于:系统故障、隐私泄露等),开发者概不负责。用户应明确认知上述风险并自行防范。
147 |
148 | 5.
149 | 未尽事宜,均依照用户所在地区相关法律法规的规定执行。如本声明与当地法律法规存在冲突,应以法律法规为准。
150 |
151 | 6.
152 | 用户使用本软件即视为已阅读并同意本声明全部内容。开发者保留随时修订本声明的权利。本声明的最终解释权归本项目开发者所有。
153 |
154 |
155 |
156 | );
157 | };
158 |
--------------------------------------------------------------------------------