diff --git a/package.json b/package.json index 4103ffb..89402c2 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,10 @@ "@heroicons/react": "^2.2.0", "@upstash/redis": "^1.25.0", "@vidstack/react": "^1.12.13", + "artplayer": "^5.2.3", "clsx": "^2.0.0", "framer-motion": "^12.18.1", - "hls.js": "^1.6.5", + "hls.js": "^1.6.6", "lucide-react": "^0.438.0", "media-icons": "^1.1.5", "next": "^14.2.23", @@ -46,8 +47,6 @@ "swiper": "^11.2.8", "tailwind-merge": "^2.6.0", "vidstack": "^0.6.15", - "xgplayer": "^3.0.22", - "xgplayer-hls.js": "^3.0.22", "zod": "^3.24.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46a6bd1..77353cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@vidstack/react': specifier: ^1.12.13 version: 1.12.13(@types/react@18.3.23)(react@18.3.1) + artplayer: + specifier: ^5.2.3 + version: 5.2.3 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -42,8 +45,8 @@ importers: specifier: ^12.18.1 version: 12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) hls.js: - specifier: ^1.6.5 - version: 1.6.5 + specifier: ^1.6.6 + version: 1.6.6 lucide-react: specifier: ^0.438.0 version: 0.438.0(react@18.3.1) @@ -86,12 +89,6 @@ importers: vidstack: specifier: ^0.6.15 version: 0.6.15 - xgplayer: - specifier: ^3.0.22 - version: 3.0.22(core-js@3.43.0) - xgplayer-hls.js: - specifier: ^3.0.22 - version: 3.0.22(core-js@3.43.0)(xgplayer@3.0.22(core-js@3.43.0)) zod: specifier: ^3.24.1 version: 3.25.67 @@ -2418,6 +2415,9 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + artplayer@5.2.3: + resolution: {integrity: sha512-WaOZQrpZn/L+GgI2f0TEsoAL3Wb+v16Mu0JmWh7qKFYuvr11WNt3dWhWeIaCfoHy3NtkCWM9jTP+xwwsxdElZQ==} + as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} @@ -2817,9 +2817,6 @@ packages: core-js-compat@3.43.0: resolution: {integrity: sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==} - core-js@3.43.0: - resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==} - cosmiconfig-typescript-loader@2.0.2: resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==} engines: {node: '>=12', npm: '>=6'} @@ -2892,16 +2889,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - d@1.0.2: - resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} - engines: {node: '>=0.12'} - damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - danmu.js@1.1.13: - resolution: {integrity: sha512-knFd0/cB2HA4FFWiA7eB2suc5vCvoHdqio33FyyCSfP7C+1A+zQcTvnvwfxaZhrxsGj4qaQI2I8XiTqedRaVmg==} - dargs@7.0.0: resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} engines: {node: '>=8'} @@ -2968,10 +2958,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge@2.0.1: - resolution: {integrity: sha512-VIPwiMJqJ13ZQfaCsIFnp5Me9tnjURiaIFxfz7EH0Ci0dTSQpZtSLrqOicXqEd/z2r+z+Klk9GzmnRsgpgbOsQ==} - engines: {node: '>=0.10.0'} - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -2995,9 +2981,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - delegate@3.2.0: - resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} - depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -3068,9 +3051,6 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} - downloadjs@1.4.7: - resolution: {integrity: sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3157,17 +3137,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - es5-ext@0.10.64: - resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} - engines: {node: '>=0.10'} - - es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} - - es6-symbol@3.1.4: - resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} - engines: {node: '>=0.12'} - esbuild-android-64@0.14.47: resolution: {integrity: sha512-R13Bd9+tqLVFndncMHssZrPWe6/0Kpv2/dt4aA69soX4PRxlzsVpCvoJeFE8sOEoeVEiBkI0myjlkDodXlHa0g==} engines: {node: '>=12'} @@ -3557,10 +3526,6 @@ packages: deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true - esniff@2.0.1: - resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} - engines: {node: '>=0.10'} - espree@9.6.1: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3600,12 +3565,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - event-emitter@0.3.5: - resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - events-intercept@2.0.0: resolution: {integrity: sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==} @@ -3636,9 +3595,6 @@ packages: exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3907,8 +3863,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hls.js@1.6.5: - resolution: {integrity: sha512-KMn5n7JBK+olC342740hDPHnGWfE8FiHtGMOdJPfUjRdARTWj9OB+8c13fnsf9sk1VtpuU2fKSgUjHvg4rNbzQ==} + hls.js@1.6.6: + resolution: {integrity: sha512-S4uTCwTHOtImW+/jxMjzG7udbHy5z682YQRbm/4f7VXuVNEoGBRjPJnD3Fxrufomdhzdtv24KnxRhPMXSvL6Fw==} hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -4797,9 +4753,6 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - next@14.2.30: resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==} engines: {node: '>=18.17.0'} @@ -4941,6 +4894,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + option-validator@2.0.6: + resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6010,9 +5966,6 @@ packages: resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} engines: {node: '>=14.16'} - type@2.7.3: - resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} - typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -6359,22 +6312,6 @@ packages: resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} engines: {node: '>= 6.0'} - xgplayer-hls.js@3.0.22: - resolution: {integrity: sha512-oNhiw1QnWNYmZsKnq7D8PUfAVHqAwv4kCwEwARMquGcUEZwNOOvazzZopTYPgwXj965LKSzgD36nhpbfYef/ZQ==} - peerDependencies: - core-js: '>=3.12.1' - xgplayer: '>=3.0.13' - - xgplayer-subtitles@3.0.22: - resolution: {integrity: sha512-2XjamtZnWS/r4QjesOC34JmuGD3QPbgeqkI4t5Gq19dN1CWNBP7nJ8pbGLuAeHswKjGg8LFRpnsic7xjc/XSyA==} - peerDependencies: - core-js: '>=3.12.1' - - xgplayer@3.0.22: - resolution: {integrity: sha512-uVKffa02NxWnWMVzgnrU0HGwZFH0ymPHsD3zGxtV6oPPplA6EBLyh9N5q3b++J7jRs2usvKR2+WslT+je1RuwA==} - peerDependencies: - core-js: '>=3.12.1' - xml-name-validator@3.0.0: resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} @@ -9033,6 +8970,10 @@ snapshots: arrify@1.0.1: {} + artplayer@5.2.3: + dependencies: + option-validator: 2.0.6 + as-table@1.0.55: dependencies: printable-characters: 1.0.42 @@ -9427,8 +9368,6 @@ snapshots: dependencies: browserslist: 4.25.0 - core-js@3.43.0: {} - cosmiconfig-typescript-loader@2.0.2(@types/node@24.0.3)(cosmiconfig@7.1.0)(typescript@4.9.5): dependencies: '@types/node': 24.0.3 @@ -9504,17 +9443,8 @@ snapshots: csstype@3.1.3: {} - d@1.0.2: - dependencies: - es5-ext: 0.10.64 - type: 2.7.3 - damerau-levenshtein@1.0.8: {} - danmu.js@1.1.13: - dependencies: - event-emitter: 0.3.5 - dargs@7.0.0: {} data-uri-to-buffer@2.0.2: {} @@ -9570,8 +9500,6 @@ snapshots: deep-is@0.1.4: {} - deepmerge@2.0.1: {} - deepmerge@4.3.1: {} define-data-property@1.1.4: @@ -9600,8 +9528,6 @@ snapshots: delayed-stream@1.0.0: {} - delegate@3.2.0: {} - depd@1.1.2: {} dequal@2.0.3: {} @@ -9663,8 +9589,6 @@ snapshots: dependencies: is-obj: 2.0.0 - downloadjs@1.4.7: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9819,24 +9743,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es5-ext@0.10.64: - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esniff: 2.0.1 - next-tick: 1.1.0 - - es6-iterator@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-symbol: 3.1.4 - - es6-symbol@3.1.4: - dependencies: - d: 1.0.2 - ext: 1.7.0 - esbuild-android-64@0.14.47: optional: true @@ -10247,13 +10153,6 @@ snapshots: transitivePeerDependencies: - supports-color - esniff@2.0.1: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-emitter: 0.3.5 - type: 2.7.3 - espree@9.6.1: dependencies: acorn: 8.15.0 @@ -10282,13 +10181,6 @@ snapshots: etag@1.8.1: {} - event-emitter@0.3.5: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - - eventemitter3@4.0.7: {} - events-intercept@2.0.0: {} events@3.3.0: {} @@ -10327,10 +10219,6 @@ snapshots: exsolve@1.0.7: {} - ext@1.7.0: - dependencies: - type: 2.7.3 - fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -10622,7 +10510,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hls.js@1.6.5: {} + hls.js@1.6.6: {} hosted-git-info@2.8.9: {} @@ -11771,8 +11659,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next-tick@1.1.0: {} - next@14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.30 @@ -11913,6 +11799,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + option-validator@2.0.6: + dependencies: + kind-of: 6.0.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -12977,8 +12867,6 @@ snapshots: type-fest@3.13.1: {} - type@2.7.3: {} - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -13490,28 +13378,6 @@ snapshots: dependencies: os-paths: 4.4.0 - xgplayer-hls.js@3.0.22(core-js@3.43.0)(xgplayer@3.0.22(core-js@3.43.0)): - dependencies: - core-js: 3.43.0 - deepmerge: 2.0.1 - event-emitter: 0.3.5 - hls.js: 1.6.5 - xgplayer: 3.0.22(core-js@3.43.0) - - xgplayer-subtitles@3.0.22(core-js@3.43.0): - dependencies: - core-js: 3.43.0 - eventemitter3: 4.0.7 - - xgplayer@3.0.22(core-js@3.43.0): - dependencies: - core-js: 3.43.0 - danmu.js: 1.1.13 - delegate: 3.2.0 - downloadjs: 1.4.7 - eventemitter3: 4.0.7 - xgplayer-subtitles: 3.0.22(core-js@3.43.0) - xml-name-validator@3.0.0: {} xmlchars@2.2.0: {} diff --git a/src/app/globals.css b/src/app/globals.css index 12a7561..d4407de 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -153,20 +153,13 @@ div[data-media-provider] video { object-fit: contain; } -/* Vidstack Menu 自定义样式:背景黑色,文字白色 */ -.vds-menu-items { - background-color: #000; - color: #fff; +.art-notice { + display: none !important; } -.vds-radio { - color: #fff !important; -} - -.vds-radio:hover { - background-color: rgba(245, 245, 245, 0.1) !important; -} - -.vds-radio .vds-icon { - color: #fff !important; +.art-poster { + background-size: contain !important; /* 使图片完整展示 */ + background-position: center center !important; /* 居中显示 */ + background-repeat: no-repeat !important; /* 防止重复 */ + background-color: #000 !important; /* 其余区域填充为黑色 */ } diff --git a/src/app/new-play/page.tsx b/src/app/new-play/page.tsx index 458b4e7..46da32d 100644 --- a/src/app/new-play/page.tsx +++ b/src/app/new-play/page.tsx @@ -2,179 +2,680 @@ 'use client'; -import { Suspense, useEffect, useRef } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import 'xgplayer/dist/index.min.css'; +import { + generateStorageKey, + getAllPlayRecords, + savePlayRecord, +} from '@/lib/db.client'; +import { + type VideoDetail, + fetchVideoDetail, +} from '@/lib/fetchVideoDetail.client'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; function PlayPageClient() { - const playerRef = useRef(null); + const searchParams = useSearchParams(); + + // 状态管理 + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + // 视频基本信息 + const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); + const videoYear = searchParams.get('year') || ''; + const [videoCover, setVideoCover] = useState(''); + + // 当前源和ID + const [currentSource, setCurrentSource] = useState( + searchParams.get('source') || '' + ); + const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); + + // 集数相关 + const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 + const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); + + // 视频播放地址 + const [videoUrl, setVideoUrl] = useState(''); + + // 总集数 + const totalEpisodes = detail?.episodes?.length || 0; + + // 用于记录是否需要在播放器 ready 后跳转到指定进度 + const resumeTimeRef = useRef(null); + + const currentSourceRef = useRef(currentSource); + const currentIdRef = useRef(currentId); + const videoTitleRef = useRef(videoTitle); + + // 同步最新值到 refs + useEffect(() => { + currentSourceRef.current = currentSource; + currentIdRef.current = currentId; + detailRef.current = detail; + currentEpisodeIndexRef.current = currentEpisodeIndex; + videoTitleRef.current = videoTitle; + }, [currentSource, currentId, detail, currentEpisodeIndex, videoTitle]); + + // 播放进度保存相关 + const saveIntervalRef = useRef(null); + const videoEventListenersRef = useRef<{ + video: HTMLVideoElement; + listeners: Array<{ event: string; handler: EventListener }>; + } | null>(null); + + // 动态导入的 Artplayer 与 Hls 实例 + const [{ Artplayer, Hls }, setPlayers] = useState<{ + Artplayer: any | null; + Hls: any | null; + }>({ Artplayer: null, Hls: null }); + const artPlayerRef = useRef(null); + const artRef = useRef(null); + + // 添加缺少的状态和 ref + const detailRef = useRef(detail); + const currentEpisodeIndexRef = useRef(currentEpisodeIndex); + + // 同步状态到 ref + useEffect(() => { + detailRef.current = detail; + currentEpisodeIndexRef.current = currentEpisodeIndex; + }, [detail, currentEpisodeIndex]); useEffect(() => { - const initPlayer = async () => { + (async () => { try { - // 动态导入 xgplayer 和 HLS 插件 - const [{ default: Player }, { default: HLS }] = await Promise.all([ - import('xgplayer'), - import('xgplayer-hls.js'), + const [ArtplayerModule, HlsModule] = await Promise.all([ + import('artplayer'), + import('hls.js'), ]); + setPlayers({ + Artplayer: ArtplayerModule.default, + Hls: HlsModule.default, + }); + } catch (err) { + console.error('Failed to load players:', err); + setError('播放器加载失败'); + setLoading(false); + } + })(); + }, []); - console.log('Player:', Player); - console.log('HLS Plugin:', HLS); + // 更新视频地址 + const updateVideoUrl = ( + detailData: VideoDetail | null, + episodeIndex: number + ) => { + if ( + !detailData || + !detailData.episodes || + episodeIndex >= detailData.episodes.length + ) { + setVideoUrl(''); + return; + } + const newUrl = detailData?.episodes[episodeIndex] || ''; + if (newUrl !== videoUrl) { + setVideoUrl(newUrl); + } + }; - // 初始化 xgplayer - const player = new Player({ - id: 'player-container', - // 示例 HLS 流地址,您可以根据需要替换 - url: 'https://vip.dytt-cinema.com/20250607/24158_8e0c4a20/index.m3u8', - width: '100%', - height: '100%', - // 播放器配置 - autoplay: true, - preload: 'auto', - // 禁用响应式设计,保持原始大小 - fluid: false, - // 保持视频原始尺寸和宽高比 - videoFillMode: 'contain', - // 禁用自动调整尺寸 - autoResize: false, - // 保持原始视频比例 - aspectRatio: 'auto', - // 控制栏配置 - controls: true, - // 海报图片 - poster: '', - // 其他配置 - playsinline: true, - lang: 'zh-cn', - volume: 0.8, - // 插件配置 - 注册 HLS 插件 - plugins: [HLS], - // HLS 特定配置 - hls: { - // 使用 hls.js 引擎 - enableWorker: true, - // 预加载配置 - maxBufferLength: 30, - maxBufferSize: 60 * 1000 * 1000, - // 错误处理 - enableSoftwareAES: true, - debug: process.env.NODE_ENV === 'development', - // 自适应码率 - enableLowInitialPlaylist: false, - // 跨域配置 - withCredentials: false, - // 错误恢复 - maxMaxBufferLength: 600, - backBufferLength: 90, - // 性能优化 - liveSyncDurationCount: 3, - liveMaxLatencyDurationCount: 10, - }, + const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => { + if (!video || !url) return; + const sources = Array.from(video.getElementsByTagName('source')); + const existed = sources.some((s) => s.src === url); + if (!existed) { + // 移除旧的 source,保持唯一 + sources.forEach((s) => s.remove()); + const sourceEl = document.createElement('source'); + sourceEl.src = url; + video.appendChild(sourceEl); + } + + // 始终允许远程播放(AirPlay / Cast) + video.disableRemotePlayback = false; + // 如果曾经有禁用属性,移除之 + if (video.hasAttribute('disableRemotePlayback')) { + video.removeAttribute('disableRemotePlayback'); + } + }; + + // 当集数索引变化时自动更新视频地址 + useEffect(() => { + updateVideoUrl(detail, currentEpisodeIndex); + }, [detail, currentEpisodeIndex]); + + // 确保初始状态与URL参数同步 + useEffect(() => { + const urlSource = searchParams.get('source'); + const urlId = searchParams.get('id'); + + if (urlSource && urlSource !== currentSource) { + setCurrentSource(urlSource); + } + if (urlId && urlId !== currentId) { + setCurrentId(urlId); + } + }, [searchParams, currentSource, currentId]); + + // 获取视频详情 + useEffect(() => { + if (!currentSource || !currentId) { + setError('缺少必要参数'); + setLoading(false); + return; + } + + const fetchDetail = async () => { + try { + const detailData = await fetchVideoDetail({ + source: currentSource, + id: currentId, + fallbackTitle: videoTitle.trim(), + fallbackYear: videoYear, }); - // 保存播放器实例 - playerRef.current = player; + // 更新状态保存详情 + setVideoTitle(detailData.title || videoTitle); + setVideoCover(detailData.poster); + setDetail(detailData); - // 添加事件监听器 - player.on('ready', () => { - console.log('播放器已准备就绪'); - }); + // 确保集数索引在有效范围内 + if (currentEpisodeIndex >= detailData.episodes.length) { + console.log('currentEpisodeIndex', currentEpisodeIndex); + setCurrentEpisodeIndex(0); + } - player.on('play', () => { - console.log('开始播放'); - }); - - player.on('pause', () => { - console.log('暂停播放'); - }); - - player.on('error', (err: any) => { - console.error('播放器错误:', err); - }); - - // HLS 特定事件 - player.on('hls_manifest_parsed', () => { - console.log('HLS 清单解析完成'); - }); - - player.on('hls_level_switched', (event: any) => { - console.log('HLS 质量切换:', event); - }); - - player.on('hls_media_attached', () => { - console.log('HLS 媒体已附加'); - }); - - player.on('hls_manifest_loaded', () => { - console.log('HLS 清单已加载'); - }); - - player.on('hls_level_loaded', () => { - console.log('HLS 级别已加载'); - }); - - player.on('hls_frag_loaded', () => { - console.log('HLS 片段已加载'); - }); - - player.on('hls_error', (err: any) => { - console.error('HLS 错误:', err); - }); - } catch (error) { - console.error('初始化播放器失败:', error); + // 清理URL参数(移除index参数) + if (searchParams.has('index')) { + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('index'); + newUrl.searchParams.delete('position'); + window.history.replaceState({}, '', newUrl.toString()); + } + } catch (err) { + setError(err instanceof Error ? err.message : '获取视频详情失败'); + } finally { + setLoading(false); } }; - // 初始化播放器 - initPlayer(); + fetchDetail(); + }, [currentSource]); - // 清理函数 - return () => { - if (playerRef.current) { - try { - playerRef.current.destroy(); - } catch (error) { - console.error('销毁播放器失败:', error); + // 播放记录处理 + useEffect(() => { + // 仅在初次挂载时检查播放记录 + const initFromHistory = async () => { + if (!currentSource || !currentId) return; + + try { + const allRecords = await getAllPlayRecords(); + const key = generateStorageKey(currentSource, currentId); + const record = allRecords[key]; + + // URL 参数 + const urlIndexParam = searchParams.get('index'); + const urlPositionParam = searchParams.get('position'); + + // 当index参数存在时的处理逻辑 + if (urlIndexParam) { + const urlIndex = parseInt(urlIndexParam, 10) - 1; + let targetTime = 0; // 默认从0开始 + + // 只有index参数和position参数都存在时才生效position + if (urlPositionParam) { + targetTime = parseInt(urlPositionParam, 10); + } else if (record && urlIndex === record.index - 1) { + // 如果有同集播放记录则跳转到播放记录处 + targetTime = record.play_time; + } + // 否则从0开始(targetTime已经是0) + + // 更新当前选集索引 + if (urlIndex !== currentEpisodeIndex) { + setCurrentEpisodeIndex(urlIndex); + } + + // 保存待恢复的播放进度,待播放器就绪后跳转 + resumeTimeRef.current = targetTime; + } else if (record) { + // 没有index参数但有播放记录时,使用原有逻辑 + const targetIndex = record.index - 1; + const targetTime = record.play_time; + + // 更新当前选集索引 + if (targetIndex !== currentEpisodeIndex) { + setCurrentEpisodeIndex(targetIndex); + } + + // 保存待恢复的播放进度,待播放器就绪后跳转 + resumeTimeRef.current = targetTime; } + } catch (err) { + console.error('读取播放记录失败:', err); + } + }; + + initFromHistory(); + }, []); + + // 处理集数切换 + const handleEpisodeChange = (episodeNumber: number) => { + if (episodeNumber >= 0 && episodeNumber < totalEpisodes) { + // 在更换集数前保存当前播放进度 + if ( + artPlayerRef.current && + artPlayerRef.current.video && + !artPlayerRef.current.video.paused + ) { + saveCurrentPlayProgress(); + } + setCurrentEpisodeIndex(episodeNumber); + } + }; + + const handleNextEpisode = () => { + const d = detailRef.current; + const idx = currentEpisodeIndexRef.current; + if (d && d.episodes && idx < d.episodes.length - 1) { + if ( + artPlayerRef.current && + artPlayerRef.current.video && + !artPlayerRef.current.video.paused + ) { + saveCurrentPlayProgress(); + } + setCurrentEpisodeIndex(idx + 1); + } + }; + + // 保存播放进度 + const saveCurrentPlayProgress = async () => { + if ( + !artPlayerRef.current || + !currentSourceRef.current || + !currentIdRef.current || + !videoTitleRef.current || + !detailRef.current?.source_name + ) { + return; + } + + const player = artPlayerRef.current; + const currentTime = player.currentTime || 0; + const duration = player.duration || 0; + + // 如果播放时间太短(少于5秒)或者视频时长无效,不保存 + if (currentTime < 1 || !duration) { + return; + } + + try { + await savePlayRecord(currentSource, currentId, { + title: videoTitleRef.current, + source_name: detailRef.current?.source_name || '', + year: videoYear || detailRef.current?.year || '', + cover: videoCover, + index: currentEpisodeIndex + 1, // 转换为1基索引 + total_episodes: totalEpisodes, + play_time: Math.floor(currentTime), + total_time: Math.floor(duration), + save_time: Date.now(), + }); + console.log('播放进度已保存:', { + title: videoTitleRef.current, + episode: currentEpisodeIndexRef.current + 1, + progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`, + }); + } catch (err) { + console.error('保存播放进度失败:', err); + } + }; + + useLayoutEffect(() => { + const container = document.getElementById( + 'artplayer-container' + ) as HTMLDivElement; + + if ( + !Artplayer || + !Hls || + !videoUrl || + loading || + currentEpisodeIndex === null || + !container + ) { + return; + } + + artRef.current = container; + + // 确保选集索引有效 + if ( + !detail || + !detail.episodes || + currentEpisodeIndex >= detail.episodes.length || + currentEpisodeIndex < 0 + ) { + setError(`选集索引无效,当前共 ${totalEpisodes} 集`); + return; + } + + if (!videoUrl) { + setError('视频地址无效'); + return; + } + console.log(videoUrl); + + // 检测是否为WebKit浏览器 + const isWebkit = + typeof window !== 'undefined' && + typeof (window as any).webkitConvertPointFromNodeToPage === 'function'; + + // 非WebKit浏览器且播放器已存在,使用switch方法切换 + if (!isWebkit && artPlayerRef.current) { + artPlayerRef.current.switch = videoUrl; + artPlayerRef.current.title = `${videoTitle} - 第${ + currentEpisodeIndex + 1 + }集`; + artPlayerRef.current.poster = videoCover; + if (artPlayerRef.current?.video) { + console.log('attachVideoEventListeners'); + attachVideoEventListeners( + artPlayerRef.current.video as HTMLVideoElement + ); + ensureVideoSource( + artPlayerRef.current.video as HTMLVideoElement, + videoUrl + ); + } + return; + } + + // WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的 + if (artPlayerRef.current) { + if (artPlayerRef.current.video && artPlayerRef.current.video.hls) { + artPlayerRef.current.video.hls.destroy(); + } + // 销毁播放器实例 + artPlayerRef.current.destroy(); + artPlayerRef.current = null; + } + + try { + // 创建新的播放器实例 + Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; + artPlayerRef.current = new Artplayer({ + container: artRef.current, + url: videoUrl, + title: `${videoTitle} - 第${currentEpisodeIndex + 1}集`, + poster: videoCover, + volume: 0.7, + isLive: false, + muted: false, + autoplay: true, + pip: false, + autoSize: false, + autoMini: false, + screenshot: false, + setting: true, + loop: false, + flip: false, + playbackRate: true, + aspectRatio: false, + fullscreen: true, + fullscreenWeb: false, + subtitleOffset: false, + miniProgressBar: false, + mutex: true, + backdrop: true, + playsInline: true, + autoPlayback: false, + airplay: true, + theme: '#22c55e', + lang: 'zh-cn', + hotkey: false, + moreVideoAttr: { + crossOrigin: 'anonymous', + }, + // HLS 支持配置 + customType: { + m3u8: function (video: HTMLVideoElement, url: string) { + if (!Hls) { + console.error('HLS.js 未加载'); + return; + } + + if (video.hls) { + video.hls.destroy(); + } + const hls = new Hls({ + debug: false, // 关闭日志 + enableWorker: true, // WebWorker 解码,降低主线程压力 + lowLatencyMode: true, // 开启低延迟 LL-HLS + + /* 缓冲/内存相关 */ + maxBufferLength: 30, // 前向缓冲最大 30s,过大容易导致高延迟 + backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用 + maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理 + }); + + hls.loadSource(url); + hls.attachMedia(video); + video.hls = hls; + + ensureVideoSource(video, url); + + hls.on(Hls.Events.ERROR, function (event: any, data: any) { + console.error('HLS Error:', event, data); + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + console.log('网络错误,尝试恢复...'); + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + console.log('媒体错误,尝试恢复...'); + hls.recoverMediaError(); + break; + default: + console.log('无法恢复的错误'); + hls.destroy(); + break; + } + } + }); + }, + }, + icons: { + loading: + '', + }, + // 控制栏配置 + controls: [ + { + position: 'left', + index: 13, + html: '', + tooltip: '播放下一集', + click: function () { + handleNextEpisode(); + }, + }, + ], + }); + + // 监听播放器事件 + artPlayerRef.current.on('ready', () => { + setError(null); + + // 若存在需要恢复的播放进度,则跳转 + if (resumeTimeRef.current && resumeTimeRef.current > 0) { + try { + artPlayerRef.current.video.currentTime = resumeTimeRef.current; + } catch (err) { + console.warn('恢复播放进度失败:', err); + } + resumeTimeRef.current = null; + } + }); + + artPlayerRef.current.on('error', (err: any) => { + console.error('播放器错误:', err); + setError('视频播放失败'); + }); + + // 监听视频播放结束事件,自动播放下一集 + artPlayerRef.current.on('video:ended', () => { + const d = detailRef.current; + const idx = currentEpisodeIndexRef.current; + if (d && d.episodes && idx < d.episodes.length - 1) { + setTimeout(() => { + setCurrentEpisodeIndex(idx + 1); + }, 1000); + } + }); + if (artPlayerRef.current?.video) { + console.log('attachVideoEventListeners'); + attachVideoEventListeners( + artPlayerRef.current.video as HTMLVideoElement + ); + ensureVideoSource( + artPlayerRef.current.video as HTMLVideoElement, + videoUrl + ); + } + } catch (err) { + console.error('创建播放器失败:', err); + setError('播放器初始化失败'); + } + }, [Artplayer, Hls, videoUrl]); + + const attachVideoEventListeners = (video: HTMLVideoElement) => { + if (!video) return; + + // 移除旧监听器(如果存在) + if (videoEventListenersRef.current) { + const { video: oldVideo, listeners } = videoEventListenersRef.current; + listeners.forEach(({ event, handler }) => { + oldVideo.removeEventListener(event, handler); + }); + videoEventListenersRef.current = null; + } + + // 暂停时立即保存 + const pauseHandler = () => { + saveCurrentPlayProgress(); + }; + + // 阻止移动端长按弹出系统菜单 + const contextMenuHandler = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }; + + // timeupdate 节流(5 秒)保存 + let lastSave = 0; + const timeUpdateHandler = () => { + const now = Date.now(); + if (now - lastSave > 5000) { + saveCurrentPlayProgress(); + lastSave = now; + } + }; + + video.addEventListener('pause', pauseHandler); + video.addEventListener('timeupdate', timeUpdateHandler); + video.addEventListener('contextmenu', contextMenuHandler); + + videoEventListenersRef.current = { + video, + listeners: [ + { event: 'pause', handler: pauseHandler }, + { event: 'timeupdate', handler: timeUpdateHandler }, + { event: 'contextmenu', handler: contextMenuHandler }, + ], + }; + }; + + // 当组件卸载时清理定时器 + useEffect(() => { + return () => { + if (saveIntervalRef.current) { + clearInterval(saveIntervalRef.current); } }; }, []); + if (loading) { + return ( + +
+
+
+

+ 加载中... +

+
+
+
+ ); + } + + if (error) { + return ( + +
+
+
⚠️
+

+ {error} +

+ +
+
+
+ ); + } + return ( -
+
{/* 第一行:影片标题 */}

- 影片标题 + {videoTitle || '影片标题'}

- {/* 第二行:播放器和选集 */}
{/* 播放器 */}
{/* 选集 */}
- -
-
- - {/* 海报(移动端在上方) */} -
-
-
- 封面图片 -
+
@@ -182,56 +683,56 @@ function PlayPageClient() {
{/* 文字区 */}
-
-

影片详情

-
-
-

剧情简介

-

- 这里是影片的详细剧情介绍。可以包含影片的背景、故事梗概、主要人物关系等信息。 - 文字内容可以根据实际的影片数据动态显示。 -

-
-
-

基本信息

-
-
- 导演: - - 待获取 - -
-
- 主演: - - 待获取 - -
-
- 类型: - - 待获取 - -
-
- 年份: - - 待获取 - -
-
-
+
+ {/* 标题 */} +

+ {videoTitle || '影片标题'} +

+ + {/* 关键信息行 */} +
+ {detail?.class && ( + + {detail.class} + + )} + {(detail?.year || videoYear) && ( + {detail?.year || videoYear} + )} + {detail?.source_name && ( + + {detail.source_name} + + )} + {detail?.type_name && {detail.type_name}}
+ {/* 剧情简介 */} + {detail?.desc && ( +
+ {detail.desc} +
+ )}
- {/* 封面展示(桌面端在右侧) */} -
-
-
- - 封面图片 - + {/* 封面展示 */} +
+
+
+ {videoCover ? ( + {videoTitle} + ) : ( + + 封面图片 + + )}
@@ -243,7 +744,7 @@ function PlayPageClient() { export default function PlayPage() { return ( - + Loading...
}> ); diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index e36ba3f..a817226 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -84,7 +84,7 @@ const EpisodeSelector: React.FC = ({ return (
{/* 分类标签 */}
@@ -141,7 +141,7 @@ const EpisodeSelector: React.FC = ({
{/* 集数网格 */} -
+
{(() => { const len = currentEnd - currentStart + 1; const episodes = Array.from({ length: len }, (_, i) =>