generated from dellevin/template
加入在线观看的功能
This commit is contained in:
@@ -12,6 +12,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'pages/home/home_page.dart';
|
import 'pages/home/home_page.dart';
|
||||||
import 'utils/theme/app_theme.dart';
|
import 'utils/theme/app_theme.dart';
|
||||||
import 'utils/app_router.dart';
|
import 'utils/app_router.dart';
|
||||||
@@ -29,6 +30,7 @@ WebViewEnvironment? windowsWebViewEnvironment;
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
// Windows 桌面:使用 FFI 初始化 sqflite
|
// Windows 桌面:使用 FFI 初始化 sqflite
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
sqfliteFfiInit();
|
sqfliteFfiInit();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:http/http.dart' as http;
|
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:path/path.dart' as p;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -21,7 +24,8 @@ class MovieDetailPage extends StatefulWidget {
|
|||||||
State<MovieDetailPage> createState() => _MovieDetailPageState();
|
State<MovieDetailPage> createState() => _MovieDetailPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MovieDetailPageState extends State<MovieDetailPage> {
|
class _MovieDetailPageState extends State<MovieDetailPage>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
Map<String, dynamic>? _data;
|
Map<String, dynamic>? _data;
|
||||||
List<Map<String, dynamic>> _staffList = [];
|
List<Map<String, dynamic>> _staffList = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
@@ -32,12 +36,45 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
int _currentTab = 0;
|
int _currentTab = 0;
|
||||||
int _detailStyle = 0; // 0: 紧凑, 1: 沉浸式
|
int _detailStyle = 0; // 0: 紧凑, 1: 沉浸式
|
||||||
|
|
||||||
|
// 播放 Tab 数据
|
||||||
|
bool _playLoading = false;
|
||||||
|
String? _playError;
|
||||||
|
List<String> _playSources = [];
|
||||||
|
List<List<({String name, String url})>> _playEpisodes = [];
|
||||||
|
Set<String> _playedEpisodes = {};
|
||||||
|
int _currentSource = 0;
|
||||||
|
int? _currentEpisode;
|
||||||
|
|
||||||
|
// 播放器状态
|
||||||
|
bool _isPlaying = false;
|
||||||
|
Player? _player;
|
||||||
|
VideoController? _videoController;
|
||||||
|
bool _playerCreating = false;
|
||||||
|
bool _isFullscreen = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_load();
|
_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<void> _load() async {
|
Future<void> _load() async {
|
||||||
final token = UserPrefs().movieSearchToken;
|
final token = UserPrefs().movieSearchToken;
|
||||||
try {
|
try {
|
||||||
@@ -105,6 +142,127 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<String, dynamic>;
|
||||||
|
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 = <List<({String name, String url})>>[];
|
||||||
|
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<void> _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<void> _enterFullscreen() async {
|
||||||
|
await SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight,
|
||||||
|
]);
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
|
setState(() {
|
||||||
|
_isFullscreen = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exitFullscreen() async {
|
||||||
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
setState(() {
|
||||||
|
_isFullscreen = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _checkLocal() {
|
void _checkLocal() {
|
||||||
final name = _data?['vod_name'] ?? '';
|
final name = _data?['vod_name'] ?? '';
|
||||||
if (name.toString().isEmpty) return;
|
if (name.toString().isEmpty) return;
|
||||||
@@ -278,11 +436,89 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 播放器区域 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────
|
// ── Build ──────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
if (_isFullscreen && _isPlaying) return _buildFullscreenPlayer(colors);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
floatingActionButton:
|
floatingActionButton:
|
||||||
@@ -347,111 +583,113 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
body: Column(children: [
|
body: Column(children: [
|
||||||
// 头部:返回按钮 + 海报信息
|
// 头部:返回按钮 + 海报信息(播放时替换为播放器区域)
|
||||||
SafeArea(
|
_isPlaying
|
||||||
bottom: false,
|
? _buildPlayerArea(colors)
|
||||||
child: Column(children: [
|
: SafeArea(
|
||||||
Padding(
|
bottom: false,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
child: Column(children: [
|
||||||
child: Row(children: [
|
Padding(
|
||||||
GestureDetector(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
onTap: () => Navigator.pop(context),
|
child: Row(children: [
|
||||||
child: Container(
|
GestureDetector(
|
||||||
width: 36,
|
onTap: () => Navigator.pop(context),
|
||||||
height: 36,
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
width: 36,
|
||||||
color: colors.surfaceContainerHigh,
|
height: 36,
|
||||||
shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
child: Icon(Icons.arrow_back,
|
color: colors.surfaceContainerHigh,
|
||||||
size: 20, color: colors.onSurface)),
|
shape: BoxShape.circle),
|
||||||
),
|
child: Icon(Icons.arrow_back,
|
||||||
const Spacer(),
|
size: 20, color: colors.onSurface)),
|
||||||
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 Spacer(),
|
||||||
const SizedBox(width: 16),
|
GestureDetector(
|
||||||
Expanded(
|
onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0),
|
||||||
child: Column(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
width: 36,
|
||||||
children: [
|
height: 36,
|
||||||
Text(name,
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: colors.surfaceContainerHigh,
|
||||||
fontSize: 18,
|
shape: BoxShape.circle),
|
||||||
fontWeight: FontWeight.w700,
|
child: Icon(
|
||||||
color: colors.onSurface)),
|
_detailStyle == 0
|
||||||
const SizedBox(height: 8),
|
? Icons.crop_landscape_rounded
|
||||||
if (score.toString().isNotEmpty && score != '0.0') ...[
|
: Icons.grid_view_rounded,
|
||||||
Row(children: [
|
size: 18,
|
||||||
Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)),
|
color: colors.onSurface)),
|
||||||
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))),
|
),
|
||||||
]),
|
Padding(
|
||||||
const SizedBox(height: 2),
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||||
Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
|
child: Row(
|
||||||
const SizedBox(height: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
],
|
children: [
|
||||||
_endTag(isEnd),
|
ClipRRect(
|
||||||
if (metaParts.isNotEmpty) ...[
|
borderRadius: BorderRadius.circular(10),
|
||||||
const SizedBox(height: 8),
|
child: SizedBox(
|
||||||
Text(metaParts,
|
width: 120,
|
||||||
style: TextStyle(
|
height: 170,
|
||||||
fontSize: 12,
|
child: pic.toString().isNotEmpty
|
||||||
color: colors.onSurface
|
? Image.network(pic,
|
||||||
.withValues(alpha: 0.5))),
|
fit: BoxFit.cover,
|
||||||
],
|
errorBuilder: (_, __, ___) =>
|
||||||
if (typeParts.isNotEmpty) ...[
|
_posterPlaceholder(colors))
|
||||||
const SizedBox(height: 3),
|
: _posterPlaceholder(colors),
|
||||||
Text(typeParts,
|
),
|
||||||
style: TextStyle(
|
),
|
||||||
fontSize: 11,
|
const SizedBox(width: 16),
|
||||||
color: colors.onSurface
|
Expanded(
|
||||||
.withValues(alpha: 0.4)),
|
child: Column(
|
||||||
maxLines: 1,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
overflow: TextOverflow.ellipsis),
|
children: [
|
||||||
],
|
Text(name,
|
||||||
if (_localMovie != null) ...[
|
style: TextStyle(
|
||||||
const SizedBox(height: 10),
|
fontSize: 18,
|
||||||
_buildLocalStatus(colors),
|
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 栏
|
// Tab 栏
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -461,11 +699,16 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
_buildTabButton('概要', 0),
|
_buildTabButton('概要', 0),
|
||||||
_buildTabButton('演职人员', 1),
|
_buildTabButton('演职人员', 1),
|
||||||
|
if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
// Tab 内容
|
// Tab 内容
|
||||||
Expanded(
|
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<MovieDetailPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
body: Column(children: [
|
body: Column(children: [
|
||||||
// 沉浸式头部
|
// 沉浸式头部(播放时替换为播放器区域)
|
||||||
Stack(children: [
|
_isPlaying
|
||||||
SizedBox(
|
? _buildPlayerArea(colors)
|
||||||
width: double.infinity,
|
: Stack(children: [
|
||||||
height: 320,
|
SizedBox(
|
||||||
child: pic.toString().isNotEmpty
|
width: double.infinity,
|
||||||
? Image.network(pic, fit: BoxFit.cover,
|
height: 320,
|
||||||
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest))
|
child: pic.toString().isNotEmpty
|
||||||
: Container(color: colors.surfaceContainerHighest,
|
? Image.network(pic, fit: BoxFit.cover,
|
||||||
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
|
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest))
|
||||||
),
|
: Container(color: colors.surfaceContainerHighest,
|
||||||
Positioned.fill(
|
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
|
||||||
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],
|
|
||||||
),
|
),
|
||||||
),
|
Positioned.fill(
|
||||||
),
|
child: DecoratedBox(
|
||||||
),
|
|
||||||
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(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
gradient: LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(6),
|
begin: Alignment.topCenter,
|
||||||
),
|
end: Alignment.bottomCenter,
|
||||||
child: Row(
|
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)],
|
||||||
mainAxisSize: MainAxisSize.min,
|
stops: const [0.35, 1.0],
|
||||||
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),
|
),
|
||||||
],
|
SafeArea(
|
||||||
_endTag(isEnd),
|
bottom: false,
|
||||||
if (metaParts.isNotEmpty) ...[
|
child: Padding(
|
||||||
const SizedBox(width: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
|
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 栏
|
// Tab 栏
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -586,11 +831,16 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
_buildTabButton('概要', 0),
|
_buildTabButton('概要', 0),
|
||||||
_buildTabButton('演职人员', 1),
|
_buildTabButton('演职人员', 1),
|
||||||
|
if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
// Tab 内容
|
// Tab 内容
|
||||||
Expanded(
|
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<MovieDetailPage> {
|
|||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
final selected = _currentTab == index;
|
final selected = _currentTab == index;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() => _currentTab = index),
|
onTap: () {
|
||||||
|
setState(() => _currentTab = index);
|
||||||
|
if (index == 2 &&
|
||||||
|
UserPrefs().playbackUnlocked &&
|
||||||
|
_playSources.isEmpty &&
|
||||||
|
!_playLoading) {
|
||||||
|
_loadPlayInfo();
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -832,6 +1090,164 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 播放 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 ──────────────────────────────────────────
|
// ── 演职人员 Tab ──────────────────────────────────────────
|
||||||
|
|
||||||
Widget _buildStaffTab(ColorScheme colors) {
|
Widget _buildStaffTab(ColorScheme colors) {
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class _OnlineSearchPageBodyState extends State<OnlineSearchPageBody> {
|
|||||||
final q = _searchController.text.trim();
|
final q = _searchController.text.trim();
|
||||||
if (q.isEmpty) return;
|
if (q.isEmpty) return;
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
|
_tryUnlockPlayback(q);
|
||||||
setState(() {
|
setState(() {
|
||||||
_query = q;
|
_query = q;
|
||||||
_hasSearched = true;
|
_hasSearched = true;
|
||||||
@@ -123,6 +124,73 @@ class _OnlineSearchPageBodyState extends State<OnlineSearchPageBody> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _searchMovies(String keyword, int page) async {
|
Future<void> _searchMovies(String keyword, int page) async {
|
||||||
final token = UserPrefs().movieSearchToken;
|
final token = UserPrefs().movieSearchToken;
|
||||||
if (token.isEmpty) {
|
if (token.isEmpty) {
|
||||||
|
|||||||
@@ -312,6 +312,37 @@ class UserPrefs {
|
|||||||
int get lastSearchTab => prefs.getInt('lastSearchTab') ?? 0;
|
int get lastSearchTab => prefs.getInt('lastSearchTab') ?? 0;
|
||||||
Future<bool> setLastSearchTab(int value) => prefs.setInt('lastSearchTab', value);
|
Future<bool> setLastSearchTab(int value) => prefs.setInt('lastSearchTab', value);
|
||||||
|
|
||||||
|
// ========== 播放解锁 ==========
|
||||||
|
|
||||||
|
/// 播放解锁时间(ISO 字符串),解锁后 7 天有效
|
||||||
|
String? get playbackUnlockTime => prefs.getString('playbackUnlockTime');
|
||||||
|
Future<bool> 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_<vodId>",value 为已播放 episode 名称 JSON 数组
|
||||||
|
List<String> getPlayedEpisodes(int vodId) {
|
||||||
|
final key = 'playback_played_$vodId';
|
||||||
|
return prefs.getStringList(key) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> 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;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 版本更新 ==========
|
// ========== 版本更新 ==========
|
||||||
|
|
||||||
/// 已忽略的版本号(不再提示更新)
|
/// 已忽略的版本号(不再提示更新)
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
|
|
||||||
#include <dynamic_color/dynamic_color_plugin.h>
|
#include <dynamic_color/dynamic_color_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
|
#include <media_kit_video/media_kit_video_plugin.h>
|
||||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
#include <volume_controller/volume_controller_plugin.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
@@ -19,12 +21,18 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
dynamic_color
|
dynamic_color
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
|
media_kit_video
|
||||||
screen_retriever_linux
|
screen_retriever_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
|
volume_controller
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import dynamic_color
|
|||||||
import file_picker
|
import file_picker
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import flutter_inappwebview_macos
|
import flutter_inappwebview_macos
|
||||||
|
import media_kit_video
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import screen_retriever_macos
|
import screen_retriever_macos
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
import volume_controller
|
||||||
|
import wakelock_plus
|
||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
@@ -24,11 +27,14 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||||
|
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
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"))
|
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
98
pubspec.lock
98
pubspec.lock
@@ -97,6 +97,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
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:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -565,6 +573,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
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:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -789,6 +821,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
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:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1034,6 +1090,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1146,6 +1218,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
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:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1204,4 +1300,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.0 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.41.0"
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ dependencies:
|
|||||||
flutter_svg: ^2.3.0
|
flutter_svg: ^2.3.0
|
||||||
lpinyin: ^2.0.3
|
lpinyin: ^2.0.3
|
||||||
scrollable_positioned_list: ^0.3.8
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||||
|
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -22,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||||
|
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||||
@@ -30,6 +34,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
|
VolumeControllerPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
||||||
WindowManagerPluginRegisterWithRegistrar(
|
WindowManagerPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
dynamic_color
|
dynamic_color
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
flutter_inappwebview_windows
|
flutter_inappwebview_windows
|
||||||
|
media_kit_video
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
screen_retriever_windows
|
screen_retriever_windows
|
||||||
share_plus
|
share_plus
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
|
volume_controller
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user