From 05c3eaeed537270af7d3008b3dbc85d7e621bd32 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Wed, 12 Aug 2026 11:50:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=9C=A8=E7=BA=BF=E8=A7=82?= =?UTF-8?q?=E7=9C=8B=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 2 + .../online_search/movie_detail_page.dart | 800 +++++++++++++----- .../online_search/online_search_page.dart | 68 ++ lib/utils/user_prefs.dart | 31 + linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 98 ++- pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 11 files changed, 833 insertions(+), 193 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index f1b2524..211f117 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:window_manager/window_manager.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:media_kit/media_kit.dart'; import 'pages/home/home_page.dart'; import 'utils/theme/app_theme.dart'; import 'utils/app_router.dart'; @@ -29,6 +30,7 @@ WebViewEnvironment? windowsWebViewEnvironment; void main() async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); // Windows 桌面:使用 FFI 初始化 sqflite if (Platform.isWindows) { sqfliteFfiInit(); diff --git a/lib/pages/online_search/movie_detail_page.dart b/lib/pages/online_search/movie_detail_page.dart index 451ed7e..bfb96fe 100644 --- a/lib/pages/online_search/movie_detail_page.dart +++ b/lib/pages/online_search/movie_detail_page.dart @@ -1,7 +1,10 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; @@ -21,7 +24,8 @@ class MovieDetailPage extends StatefulWidget { State createState() => _MovieDetailPageState(); } -class _MovieDetailPageState extends State { +class _MovieDetailPageState extends State + with WidgetsBindingObserver { Map? _data; List> _staffList = []; bool _loading = true; @@ -32,12 +36,45 @@ class _MovieDetailPageState extends State { int _currentTab = 0; int _detailStyle = 0; // 0: 紧凑, 1: 沉浸式 + // 播放 Tab 数据 + bool _playLoading = false; + String? _playError; + List _playSources = []; + List> _playEpisodes = []; + Set _playedEpisodes = {}; + int _currentSource = 0; + int? _currentEpisode; + + // 播放器状态 + bool _isPlaying = false; + Player? _player; + VideoController? _videoController; + bool _playerCreating = false; + bool _isFullscreen = false; + @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _load(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _player?.dispose(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _player?.pause(); + } + } + Future _load() async { final token = UserPrefs().movieSearchToken; try { @@ -105,6 +142,127 @@ class _MovieDetailPageState extends State { }); } + Future _loadPlayInfo() async { + if (_playSources.isNotEmpty || _playLoading) return; + final token = UserPrefs().movieSearchToken; + setState(() { + _playLoading = true; + _playError = null; + }); + try { + final url = + '${ServerConfig.vipBaseUrl}/api/movie/detail/plus?vodId=${widget.vodId}&token=$token'; + final resp = + await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); + if (!mounted) return; + if (resp.statusCode == 200) { + final json_ = json.decode(resp.body); + if (json_['code'] == 0 && json_['data'] != null) { + final data = json_['data'] as Map; + final sources = (data['vod_play_from'] ?? '') + .toString() + .split(r'$$$') + .where((s) => s.trim().isNotEmpty) + .toList(); + final urlGroups = (data['vod_play_url'] ?? '') + .toString() + .split(r'$$$'); + final episodes = >[]; + for (var i = 0; i < sources.length; i++) { + final group = + i < urlGroups.length ? urlGroups[i] : ''; + final eps = <({String name, String url})>[]; + for (final raw in group.split('#')) { + final parts = raw.split('\$'); + if (parts.length >= 2 && parts[1].trim().isNotEmpty) { + eps.add((name: parts[0].trim(), url: parts[1].trim())); + } + } + episodes.add(eps); + } + if (mounted) { + setState(() { + _playSources = sources; + _playEpisodes = episodes; + _playedEpisodes = UserPrefs().getPlayedEpisodes(widget.vodId).toSet(); + _playLoading = false; + }); + } + return; + } + } + if (mounted) { + setState(() { + _playError = '加载失败'; + _playLoading = false; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _playError = '网络错误'; + _playLoading = false; + }); + } + } + } + + Future _startPlay(String url, int episodeIndex) async { + if (_playerCreating) return; + setState(() { + _playerCreating = true; + }); + try { + if (_player == null) { + _player = Player(); + _videoController = VideoController(_player!); + } + await _player!.open(Media(url)); + if (!mounted) return; + final epName = _playEpisodes[_currentSource][episodeIndex].name; + await UserPrefs().addPlayedEpisode(widget.vodId, epName); + setState(() { + _isPlaying = true; + _currentEpisode = episodeIndex; + _playedEpisodes.add(epName); + _playerCreating = false; + }); + } catch (_) { + if (mounted) { + ToastUtil.show(context, '播放失败'); + setState(() { + _playerCreating = false; + }); + } + } + } + + void _closePlayer() { + _player?.pause(); + setState(() { + _isPlaying = false; + }); + } + + Future _enterFullscreen() async { + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + setState(() { + _isFullscreen = true; + }); + } + + Future _exitFullscreen() async { + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + setState(() { + _isFullscreen = false; + }); + } + void _checkLocal() { final name = _data?['vod_name'] ?? ''; if (name.toString().isEmpty) return; @@ -278,11 +436,89 @@ class _MovieDetailPageState extends State { ); } + // ── 播放器区域 ────────────────────────────────────────── + + Widget _buildPlayerArea(ColorScheme colors) { + return Column(children: [ + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row(children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, shape: BoxShape.circle), + child: Icon(Icons.arrow_back, + size: 20, color: colors.onSurface), + ), + ), + const Spacer(), + GestureDetector( + onTap: _closePlayer, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18)), + child: Text('关闭播放器', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + ), + ), + ]), + ), + ), + AspectRatio( + aspectRatio: 16 / 9, + child: _videoController != null + ? MaterialVideoControlsTheme( + normal: const MaterialVideoControlsThemeData( + seekBarThumbColor: Color(0xFFFFFFFF), + seekBarPositionColor: Color(0xFFFFFFFF), + ), + fullscreen: const MaterialVideoControlsThemeData(), + child: Video( + controller: _videoController!, + controls: MaterialVideoControls, + onEnterFullscreen: _enterFullscreen, + onExitFullscreen: _exitFullscreen, + ), + ) + : Container(color: Colors.black), + ), + ]); + } + + Widget _buildFullscreenPlayer(ColorScheme colors) { + return Scaffold( + backgroundColor: Colors.black, + body: _videoController != null + ? MaterialVideoControlsTheme( + normal: const MaterialVideoControlsThemeData(), + fullscreen: const MaterialVideoControlsThemeData(), + child: Video( + controller: _videoController!, + controls: MaterialVideoControls, + onEnterFullscreen: _enterFullscreen, + onExitFullscreen: _exitFullscreen, + ), + ) + : const SizedBox.shrink(), + ); + } + // ── Build ────────────────────────────────────────────── @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + if (_isFullscreen && _isPlaying) return _buildFullscreenPlayer(colors); return Scaffold( backgroundColor: colors.surface, floatingActionButton: @@ -347,111 +583,113 @@ class _MovieDetailPageState extends State { return Scaffold( backgroundColor: colors.surface, body: Column(children: [ - // 头部:返回按钮 + 海报信息 - SafeArea( - bottom: false, - child: Column(children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row(children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - shape: BoxShape.circle), - child: Icon(Icons.arrow_back, - size: 20, color: colors.onSurface)), - ), - const Spacer(), - GestureDetector( - onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - shape: BoxShape.circle), - child: Icon( - _detailStyle == 0 - ? Icons.crop_landscape_rounded - : Icons.grid_view_rounded, - size: 18, - color: colors.onSurface)), - ), - ]), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - width: 120, - height: 170, - child: pic.toString().isNotEmpty - ? Image.network(pic, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - _posterPlaceholder(colors)) - : _posterPlaceholder(colors), + // 头部:返回按钮 + 海报信息(播放时替换为播放器区域) + _isPlaying + ? _buildPlayerArea(colors) + : SafeArea( + bottom: false, + child: Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row(children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + shape: BoxShape.circle), + child: Icon(Icons.arrow_back, + size: 20, color: colors.onSurface)), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: colors.onSurface)), - const SizedBox(height: 8), - if (score.toString().isNotEmpty && score != '0.0') ...[ - Row(children: [ - Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)), - const SizedBox(width: 3), - Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), - ]), - const SizedBox(height: 2), - Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))), - const SizedBox(height: 8), - ], - _endTag(isEnd), - if (metaParts.isNotEmpty) ...[ - const SizedBox(height: 8), - Text(metaParts, - style: TextStyle( - fontSize: 12, - color: colors.onSurface - .withValues(alpha: 0.5))), - ], - if (typeParts.isNotEmpty) ...[ - const SizedBox(height: 3), - Text(typeParts, - style: TextStyle( - fontSize: 11, - color: colors.onSurface - .withValues(alpha: 0.4)), - maxLines: 1, - overflow: TextOverflow.ellipsis), - ], - if (_localMovie != null) ...[ - const SizedBox(height: 10), - _buildLocalStatus(colors), - ], - ]), - ), - ]), - ), - ]), - ), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + shape: BoxShape.circle), + child: Icon( + _detailStyle == 0 + ? Icons.crop_landscape_rounded + : Icons.grid_view_rounded, + size: 18, + color: colors.onSurface)), + ), + ]), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 120, + height: 170, + child: pic.toString().isNotEmpty + ? Image.network(pic, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + _posterPlaceholder(colors)) + : _posterPlaceholder(colors), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: colors.onSurface)), + const SizedBox(height: 8), + if (score.toString().isNotEmpty && score != '0.0') ...[ + Row(children: [ + Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ]), + const SizedBox(height: 2), + Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 8), + ], + _endTag(isEnd), + if (metaParts.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(metaParts, + style: TextStyle( + fontSize: 12, + color: colors.onSurface + .withValues(alpha: 0.5))), + ], + if (typeParts.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(typeParts, + style: TextStyle( + fontSize: 11, + color: colors.onSurface + .withValues(alpha: 0.4)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + if (_localMovie != null) ...[ + const SizedBox(height: 10), + _buildLocalStatus(colors), + ], + ]), + ), + ]), + ), + ]), + ), // Tab 栏 Container( decoration: BoxDecoration( @@ -461,11 +699,16 @@ class _MovieDetailPageState extends State { child: Row(children: [ _buildTabButton('概要', 0), _buildTabButton('演职人员', 1), + if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2), ]), ), // Tab 内容 Expanded( - child: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors), + child: _currentTab == 0 + ? _buildOverview(colors) + : _currentTab == 1 + ? _buildStaffTab(colors) + : _buildPlayTab(colors), ), ]), ); @@ -489,95 +732,97 @@ class _MovieDetailPageState extends State { return Scaffold( backgroundColor: colors.surface, body: Column(children: [ - // 沉浸式头部 - Stack(children: [ - SizedBox( - width: double.infinity, - height: 320, - child: pic.toString().isNotEmpty - ? Image.network(pic, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest)) - : Container(color: colors.surfaceContainerHighest, - child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))), - ), - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)], - stops: const [0.35, 1.0], + // 沉浸式头部(播放时替换为播放器区域) + _isPlaying + ? _buildPlayerArea(colors) + : Stack(children: [ + SizedBox( + width: double.infinity, + height: 320, + child: pic.toString().isNotEmpty + ? Image.network(pic, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest)) + : Container(color: colors.surfaceContainerHighest, + child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))), ), - ), - ), - ), - SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row(children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 36, height: 36, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), - child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)), - ), - const Spacer(), - GestureDetector( - onTap: () => setState(() => _detailStyle = 0), - child: Container( - width: 36, height: 36, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), - child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)), - ), - ]), - ), - ), - Positioned( - left: 16, right: 16, bottom: 18, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(name, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 8), - Row(children: [ - if (score.toString().isNotEmpty && score != '0.0') ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + Positioned.fill( + child: DecoratedBox( decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400), - const SizedBox(width: 3), - Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)), - ], + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)], + stops: const [0.35, 1.0], + ), ), ), - const SizedBox(width: 10), - ], - _endTag(isEnd), - if (metaParts.isNotEmpty) ...[ - const SizedBox(width: 8), - Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))), - ], + ), + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row(children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 36, height: 36, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), + child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)), + ), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _detailStyle = 0), + child: Container( + width: 36, height: 36, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), + child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)), + ), + ]), + ), + ), + Positioned( + left: 16, right: 16, bottom: 18, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(name, maxLines: 2, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)), + const SizedBox(height: 8), + Row(children: [ + if (score.toString().isNotEmpty && score != '0.0') ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400), + const SizedBox(width: 3), + Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)), + ], + ), + ), + const SizedBox(width: 10), + ], + _endTag(isEnd), + if (metaParts.isNotEmpty) ...[ + const SizedBox(width: 8), + Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))), + ], + ]), + if (typeParts.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))), + ], + if (_localMovie != null) ...[ + const SizedBox(height: 8), + _buildLocalStatus(colors), + ], + ]), + ), ]), - if (typeParts.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))), - ], - if (_localMovie != null) ...[ - const SizedBox(height: 8), - _buildLocalStatus(colors), - ], - ]), - ), - ]), // Tab 栏 Container( decoration: BoxDecoration( @@ -586,11 +831,16 @@ class _MovieDetailPageState extends State { child: Row(children: [ _buildTabButton('概要', 0), _buildTabButton('演职人员', 1), + if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2), ]), ), // Tab 内容 Expanded( - child: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors), + child: _currentTab == 0 + ? _buildOverview(colors) + : _currentTab == 1 + ? _buildStaffTab(colors) + : _buildPlayTab(colors), ), ]), ); @@ -657,7 +907,15 @@ class _MovieDetailPageState extends State { final colors = Theme.of(context).colorScheme; final selected = _currentTab == index; return GestureDetector( - onTap: () => setState(() => _currentTab = index), + onTap: () { + setState(() => _currentTab = index); + if (index == 2 && + UserPrefs().playbackUnlocked && + _playSources.isEmpty && + !_playLoading) { + _loadPlayInfo(); + } + }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), decoration: BoxDecoration( @@ -832,6 +1090,164 @@ class _MovieDetailPageState extends State { ]); } + // ── 播放 Tab ────────────────────────────────────────── + + Widget _buildPlayTab(ColorScheme colors) { + if (!UserPrefs().playbackUnlocked) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.lock_outline, + size: 40, color: colors.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text('播放功能未解锁', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 6), + Text('请在增强搜索中输入验证码以解锁(7天有效)', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.35))), + ], + ), + ), + ); + } + if (_playLoading) { + return Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.primary, + ), + ), + ); + } + if (_playError != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + size: 40, color: colors.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text(_playError!, + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 12), + TextButton( + onPressed: _loadPlayInfo, + child: Text('重试', style: TextStyle(color: colors.primary)), + ), + ], + ), + ); + } + if (_playSources.isEmpty || _playEpisodes.isEmpty) { + return Center( + child: Text('暂无播放资源', + style: TextStyle( + fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))), + ); + } + + final episodes = _playEpisodes[_currentSource]; + return Column(children: [ + // 播放源选择器 + SizedBox( + height: 44, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + itemCount: _playSources.length, + itemBuilder: (_, i) { + final selected = _currentSource == i; + return GestureDetector( + onTap: () { + setState(() { + _currentSource = i; + _currentEpisode = null; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: selected + ? colors.primaryContainer + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text(_playSources[i], + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected + ? colors.onPrimaryContainer + : colors.onSurface.withValues(alpha: 0.6), + )), + ), + ); + }, + ), + ), + const SizedBox(height: 8), + // 剧集网格 + Expanded( + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 6, + mainAxisSpacing: 6, + childAspectRatio: 1.4, + ), + itemCount: episodes.length, + itemBuilder: (_, i) { + final ep = episodes[i]; + final isCurrent = _currentEpisode == i && _isPlaying; + final isPlayed = _playedEpisodes.contains(ep.name); + return GestureDetector( + onTap: () => _startPlay(ep.url, i), + child: Container( + decoration: BoxDecoration( + color: isCurrent + ? colors.primary + : isPlayed + ? colors.surfaceContainerHigh + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text(ep.name, + style: TextStyle( + fontSize: 12, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, + color: isCurrent + ? colors.onPrimary + : isPlayed + ? colors.onSurface.withValues(alpha: 0.5) + : colors.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + ); + }, + ), + ), + ]); + } + // ── 演职人员 Tab ────────────────────────────────────────── Widget _buildStaffTab(ColorScheme colors) { diff --git a/lib/pages/online_search/online_search_page.dart b/lib/pages/online_search/online_search_page.dart index a10ea6e..ffefc7a 100644 --- a/lib/pages/online_search/online_search_page.dart +++ b/lib/pages/online_search/online_search_page.dart @@ -99,6 +99,7 @@ class _OnlineSearchPageBodyState extends State { final q = _searchController.text.trim(); if (q.isEmpty) return; _focusNode.unfocus(); + _tryUnlockPlayback(q); setState(() { _query = q; _hasSearched = true; @@ -123,6 +124,73 @@ class _OnlineSearchPageBodyState extends State { } } + Future _tryUnlockPlayback(String query) async { + try { + final url = '${ServerConfig.baseUrl}/api/enhance-video'; + final resp = + await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); + if (!mounted) return; + if (resp.statusCode == 200) { + final json_ = json.decode(resp.body); + if (json_['code'] == 0 && json_['data'] == query) { + await UserPrefs().setPlaybackUnlockTime(DateTime.now().toIso8601String()); + if (!mounted) return; + _showUnlockDialog(); + } + } + } catch (_) {} + } + + void _showUnlockDialog() { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_rounded, + size: 52, + color: const Color(0xFF16A34A), + ), + const SizedBox(height: 14), + Text( + '播放功能已解锁', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + '有效期 7 天,可前往影视详情页观看', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.5), + ), + textAlign: TextAlign.center, + ), + ], + ), + actionsAlignment: MainAxisAlignment.center, + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('知道了', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.primary)), + ), + ], + ), + ); + } + Future _searchMovies(String keyword, int page) async { final token = UserPrefs().movieSearchToken; if (token.isEmpty) { diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 10cbcb5..dbc1d6a 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -312,6 +312,37 @@ class UserPrefs { int get lastSearchTab => prefs.getInt('lastSearchTab') ?? 0; Future setLastSearchTab(int value) => prefs.setInt('lastSearchTab', value); + // ========== 播放解锁 ========== + + /// 播放解锁时间(ISO 字符串),解锁后 7 天有效 + String? get playbackUnlockTime => prefs.getString('playbackUnlockTime'); + Future setPlaybackUnlockTime(String value) => prefs.setString('playbackUnlockTime', value); + + /// 当前是否已解锁播放(7 天内有效) + bool get playbackUnlocked { + final t = playbackUnlockTime; + if (t == null) return false; + final dt = DateTime.tryParse(t); + if (dt == null) return false; + return DateTime.now().difference(dt).inDays < 7; + } + + /// 影视已播放剧集记录:key 为 "playback_played_",value 为已播放 episode 名称 JSON 数组 + List getPlayedEpisodes(int vodId) { + final key = 'playback_played_$vodId'; + return prefs.getStringList(key) ?? []; + } + + Future addPlayedEpisode(int vodId, String episodeName) async { + final key = 'playback_played_$vodId'; + final list = prefs.getStringList(key) ?? []; + if (!list.contains(episodeName)) { + list.add(episodeName); + return prefs.setStringList(key, list); + } + return false; + } + // ========== 版本更新 ========== /// 已忽略的版本号(不再提示更新) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c8f1f9e..f32317e 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,8 +8,10 @@ #include #include +#include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -19,12 +21,18 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) volume_controller_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin"); + volume_controller_plugin_register_with_registrar(volume_controller_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index eb72b7e..be4b6d2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,8 +5,10 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color file_selector_linux + media_kit_video screen_retriever_linux url_launcher_linux + volume_controller window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index edc1b34..b3d0b51 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,12 +10,15 @@ import dynamic_color import file_picker import file_selector_macos import flutter_inappwebview_macos +import media_kit_video import package_info_plus import screen_retriever_macos import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos +import volume_controller +import wakelock_plus import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -24,11 +27,14 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 53c29bb..d9ba70e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" device_info_plus: dependency: "direct main" description: @@ -565,6 +573,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: "direct main" + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a" + url: "https://pub.dev" + source: hosted + version: "1.3.1" meta: dependency: transitive description: @@ -789,6 +821,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + screen_brightness_android: + dependency: transitive + description: + name: screen_brightness_android + sha256: "2008ad8e9527cc968f7a4de1ec58b476d495b3c612a149dbd6550c4f046da147" + url: "https://pub.dev" + source: hosted + version: "2.1.6" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + sha256: "2de60c0ba569b898950029cc1f7e9dd72bda44a22beb5054aac331cb6fce2ff2" + url: "https://pub.dev" + source: hosted + version: "2.1.2" screen_retriever: dependency: transitive description: @@ -1034,6 +1090,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" url_launcher: dependency: "direct main" description: @@ -1146,6 +1218,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + volume_controller: + dependency: transitive + description: + name: volume_controller + sha256: bed7c57b1da19d60a58e2ed598b6824754cd9af1b728b26fb966a8745c72629c + url: "https://pub.dev" + source: hosted + version: "3.5.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" web: dependency: transitive description: @@ -1204,4 +1300,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2615f9b..2b88c84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,9 @@ dependencies: flutter_svg: ^2.3.0 lpinyin: ^2.0.3 scrollable_positioned_list: ^0.3.8 + media_kit: ^1.1.11 + media_kit_video: ^1.2.5 + media_kit_libs_android_video: ^1.3.6 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f2e40be..b4cb40a 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,10 +9,12 @@ #include #include #include +#include #include #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -22,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( @@ -30,6 +34,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + VolumeControllerPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 594ae95..0f40620 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color file_selector_windows flutter_inappwebview_windows + media_kit_video permission_handler_windows screen_retriever_windows share_plus url_launcher_windows + volume_controller window_manager )