diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..f416b741 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,156 @@ +name: Selene 全平台构建与发布 + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-linux: + name: Linux 构建 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 安装构建依赖 + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config \ + libgtk-3-dev liblzma-dev libstdc++-12-dev libasound2-dev libmpv-dev + + - name: 配置 Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: 获取依赖 + run: flutter pub get + + - name: 构建并打包 + run: bash scripts/ci-build.sh linux + + - name: 上传构建产物 + uses: actions/upload-artifact@v4 + with: + name: selene-linux + path: build/**/selene-linux.tar.gz + + build-windows: + name: Windows 构建 + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: 配置 Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: 获取依赖 + run: flutter pub get + + - name: 构建 + run: flutter build windows --release + + - name: 打包 + run: | + $dir = (Get-ChildItem -Path build -Recurse -Filter "runner" -Directory | Select-Object -First 1).FullName + Compress-Archive -Path "$dir\Release\*" -DestinationPath selene-windows.zip + + - name: 上传构建产物 + uses: actions/upload-artifact@v4 + with: + name: selene-windows + path: selene-windows.zip + + build-apple: + name: Apple 构建 (iOS & macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: 配置 Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: 获取依赖 + run: flutter pub get + + - name: 构建并打包 iOS + run: bash scripts/ci-build.sh ios + + - name: 构建并打包 macOS + run: bash scripts/ci-build.sh macos + + - name: 上传构建产物 + uses: actions/upload-artifact@v4 + with: + name: selene-apple + path: | + selene-ios.ipa + selene-macos.dmg + + build-android: + name: Android 构建 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 配置 Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: 配置 Android SDK + uses: android-actions/setup-android@v3 + + - name: 配置 Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: 获取依赖 + run: flutter pub get + + - name: 构建并打包 + run: bash scripts/ci-build.sh android + + - name: 上传构建产物 + uses: actions/upload-artifact@v4 + with: + name: selene-android + path: build/app/outputs/flutter-apk/*.apk + + release: + name: 发布到 GitHub Release + needs: [build-linux, build-windows, build-apple, build-android] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 下载全部构建产物 + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: 读取版本号 + id: version + run: | + VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: *//' | cut -d'+' -f1) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: 创建 Release 并上传产物 + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: Selene v${{ steps.version.outputs.version }} + generate_release_notes: true + files: | + artifacts/selene-android/*.apk + artifacts/selene-linux/**/*.tar.gz + artifacts/selene-windows/*.zip + artifacts/selene-apple/*.ipa + artifacts/selene-apple/*.dmg diff --git a/lib/models/search_result.dart b/lib/models/search_result.dart index aeeebf23..9a820a47 100644 --- a/lib/models/search_result.dart +++ b/lib/models/search_result.dart @@ -150,10 +150,9 @@ class SearchStartEvent extends SearchEvent { SearchStartEvent({ required this.query, required this.totalSources, - required int timestamp, + required super.timestamp, }) : super( type: SearchEventType.start, - timestamp: timestamp, ); factory SearchStartEvent.fromJson(Map json) { @@ -175,10 +174,9 @@ class SearchSourceResultEvent extends SearchEvent { required this.source, required this.sourceName, required this.results, - required int timestamp, + required super.timestamp, }) : super( type: SearchEventType.sourceResult, - timestamp: timestamp, ); factory SearchSourceResultEvent.fromJson(Map json) { @@ -206,10 +204,9 @@ class SearchSourceErrorEvent extends SearchEvent { required this.source, required this.sourceName, required this.error, - required int timestamp, + required super.timestamp, }) : super( type: SearchEventType.sourceError, - timestamp: timestamp, ); factory SearchSourceErrorEvent.fromJson(Map json) { @@ -230,10 +227,9 @@ class SearchCompleteEvent extends SearchEvent { SearchCompleteEvent({ required this.totalResults, required this.completedSources, - required int timestamp, + required super.timestamp, }) : super( type: SearchEventType.complete, - timestamp: timestamp, ); factory SearchCompleteEvent.fromJson(Map json) { diff --git a/lib/screens/anime_screen.dart b/lib/screens/anime_screen.dart index 0a9569de..3836c797 100644 --- a/lib/screens/anime_screen.dart +++ b/lib/screens/anime_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import '../services/theme_service.dart'; import '../widgets/capsule_tab_switcher.dart'; diff --git a/lib/screens/live_player_screen.dart b/lib/screens/live_player_screen.dart index 58fb5b51..d174eb42 100644 --- a/lib/screens/live_player_screen.dart +++ b/lib/screens/live_player_screen.dart @@ -444,10 +444,12 @@ class _LivePlayerScreenState extends State children: [ Column( children: [ - // Windows 自定义标题栏 + // Windows 自定义标题栏(跟随主题) if (Platform.isWindows) - const WindowsTitleBar( - customBackgroundColor: Color(0xFF000000), + WindowsTitleBar( + customBackgroundColor: isDarkMode + ? const Color(0xFF121212) + : const Color(0xFFf5f5f5), ), // 主要内容 Expanded( @@ -1809,8 +1811,8 @@ class _LivePlayerScreenState extends State Container( width: 4, height: 4, - decoration: BoxDecoration( - color: const Color(0xFF27ae60), + decoration: const BoxDecoration( + color: Color(0xFF27ae60), shape: BoxShape.circle, ), ), diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 381cadaa..ba122e30 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -28,6 +28,11 @@ class _LoginScreenState extends State { bool _isLoading = false; bool _isFormValid = false; bool _isLocalMode = false; + bool _showAdvancedSettings = false; + bool _enableBrowserHeaders = false; + final _userAgentController = TextEditingController(); + final _customHeaderNameController = TextEditingController(); + final _customHeaderValueController = TextEditingController(); // 点击计数器相关 int _logoTapCount = 0; @@ -41,6 +46,23 @@ class _LoginScreenState extends State { _passwordController.addListener(_validateForm); _subscriptionUrlController.addListener(_validateForm); _loadSavedUserData(); + _loadAdvancedSettings(); + } + + void _loadAdvancedSettings() async { + final ua = await UserDataService.getCustomUserAgent(); + final enabled = await UserDataService.getEnableBrowserHeaders(); + final header = await UserDataService.getCustomHeader(); + if (mounted) { + setState(() { + _userAgentController.text = ua; + _enableBrowserHeaders = enabled; + if (header.isNotEmpty) { + _customHeaderNameController.text = header.keys.first; + _customHeaderValueController.text = header.values.first; + } + }); + } } void _loadSavedUserData() async { @@ -83,6 +105,9 @@ class _LoginScreenState extends State { _usernameController.dispose(); _passwordController.dispose(); _subscriptionUrlController.dispose(); + _userAgentController.dispose(); + _customHeaderNameController.dispose(); + _customHeaderValueController.dispose(); _tapTimer?.cancel(); super.dispose(); } @@ -261,16 +286,23 @@ class _LoginScreenState extends State { } String _parseCookies(http.Response response) { - // 解析 Set-Cookie 头部 - List cookies = []; - - // 获取所有 Set-Cookie 头部 - final setCookieHeaders = response.headers['set-cookie']; - if (setCookieHeaders != null) { - // HTTP 头部通常是 String 类型 - final cookieParts = setCookieHeaders.split(';'); - if (cookieParts.isNotEmpty) { - cookies.add(cookieParts[0].trim()); + final allCookies = response.headers['set-cookie']; + if (allCookies == null || allCookies.isEmpty) return ''; + + final lines = allCookies + .split('\n') + .expand((line) => line.split(',')) + .where((s) => s.trim().isNotEmpty) + .toList(); + + final cookies = []; + for (final line in lines) { + final semicolonIdx = line.indexOf(';'); + final nameValue = semicolonIdx > 0 + ? line.substring(0, semicolonIdx).trim() + : line.trim(); + if (nameValue.contains('=')) { + cookies.add(nameValue); } } @@ -307,16 +339,37 @@ class _LoginScreenState extends State { }); try { + // 保存高级设置 + await UserDataService.saveCustomUserAgent(_userAgentController.text); + await UserDataService.saveEnableBrowserHeaders(_enableBrowserHeaders); + await UserDataService.saveCustomHeader( + _customHeaderNameController.text, + _customHeaderValueController.text); + // 处理 URL String baseUrl = _processUrl(_urlController.text); String loginUrl = '$baseUrl/api/login'; + final loginHeaders = { + 'Content-Type': 'application/json', + }; + loginHeaders['User-Agent'] = _userAgentController.text; + if (_enableBrowserHeaders) { + loginHeaders['Accept-Language'] = 'zh-CN,zh;q=0.9,en;q=0.8'; + loginHeaders['Sec-Fetch-Site'] = 'same-origin'; + loginHeaders['Sec-Fetch-Mode'] = 'cors'; + loginHeaders['Sec-Fetch-Dest'] = 'empty'; + } + if (_customHeaderNameController.text.isNotEmpty && + _customHeaderValueController.text.isNotEmpty) { + loginHeaders[_customHeaderNameController.text.trim()] = + _customHeaderValueController.text.trim(); + } + // 发送登录请求 final response = await http.post( Uri.parse(loginUrl), - headers: { - 'Content-Type': 'application/json', - }, + headers: loginHeaders, body: json.encode({ 'username': _usernameController.text, 'password': _passwordController.text, @@ -522,6 +575,168 @@ class _LoginScreenState extends State { } } + Widget _buildAdvancedSettingsToggle() { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: GestureDetector( + onTap: () { + setState(() { + _showAdvancedSettings = !_showAdvancedSettings; + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _showAdvancedSettings + ? Icons.expand_less + : Icons.expand_more, + color: const Color(0xFF7f8c8d), + size: 18, + ), + const SizedBox(width: 4), + Text( + '高级设置', + style: FontUtils.poppins( + fontSize: 13, + color: const Color(0xFF7f8c8d), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAdvancedSettingsPanel() { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'User-Agent', + style: FontUtils.poppins( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFF2c3e50), + ), + ), + const Spacer(), + SizedBox( + height: 20, + child: Switch.adaptive( + value: _enableBrowserHeaders, + onChanged: (v) { + setState(() { + _enableBrowserHeaders = v; + }); + }, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + Text( + '浏览器头', + style: FontUtils.poppins( + fontSize: 12, + color: const Color(0xFF7f8c8d), + ), + ), + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: _userAgentController, + maxLines: 2, + minLines: 1, + style: FontUtils.poppins( + fontSize: 12, + color: const Color(0xFF2c3e50), + ), + decoration: InputDecoration( + hintText: '自定义 User-Agent', + hintStyle: FontUtils.poppins( + fontSize: 12, + color: const Color(0xFFbdc3c7), + ), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _customHeaderNameController, + style: FontUtils.poppins( + fontSize: 12, + color: const Color(0xFF2c3e50), + ), + decoration: InputDecoration( + hintText: '自定义头名称', + hintStyle: FontUtils.poppins( + fontSize: 12, + color: const Color(0xFFbdc3c7), + ), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _customHeaderValueController, + style: FontUtils.poppins( + fontSize: 12, + color: const Color(0xFF2c3e50), + ), + decoration: InputDecoration( + hintText: '自定义头值', + hintStyle: FontUtils.poppins( + fontSize: 12, + color: const Color(0xFFbdc3c7), + ), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final isTablet = DeviceUtils.isTablet(context); @@ -795,7 +1010,7 @@ class _LoginScreenState extends State { ? Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox( + const SizedBox( height: 18, width: 18, child: CircularProgressIndicator( @@ -825,6 +1040,9 @@ class _LoginScreenState extends State { ), ), ), + // 高级设置折叠面板 + _buildAdvancedSettingsToggle(), + if (_showAdvancedSettings) _buildAdvancedSettingsPanel(), ], ), ), @@ -1092,6 +1310,9 @@ class _LoginScreenState extends State { ), ), ), + // 高级设置折叠面板 + _buildAdvancedSettingsToggle(), + if (_showAdvancedSettings) _buildAdvancedSettingsPanel(), ], ), ), diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index b3bd6137..e0fda420 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -563,7 +563,7 @@ class _PlayerScreenState extends State // 异步保存播放记录(不等待结果) PageCacheService().savePlayRecord(playRecord, context).then((_) { debugPrint( - '保存播放进度 [场景: $scene]: source: $currentSourceSnapshot, id: $currentIDSnapshot, 第${currentEpisodeIndexSnapshot + 1}集, 时间: ${playTime}秒'); + '保存播放进度 [场景: $scene]: source: $currentSourceSnapshot, id: $currentIDSnapshot, 第${currentEpisodeIndexSnapshot + 1}集, 时间: $playTime秒'); }).catchError((e) { debugPrint('保存播放进度失败 [场景: $scene]: $e'); }); @@ -1201,7 +1201,7 @@ class _PlayerScreenState extends State // 等待下一帧,确保 MobileVideoPlayerWidget 已经重新创建 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && currentDetail != null) { - debugPrint('恢复播放: 第${resumeEpisodeIndex + 1}集, ${resumeSeconds}秒'); + debugPrint('恢复播放: 第${resumeEpisodeIndex + 1}集, $resumeSeconds秒'); // 调用 startPlay 重新初始化播放器 startPlay(resumeEpisodeIndex, resumeSeconds); } @@ -1472,12 +1472,12 @@ class _PlayerScreenState extends State return LayoutBuilder( builder: (context, constraints) { final double screenWidth = constraints.maxWidth; - final double padding = 16.0; - final double spacing = 12.0; + const double padding = 16.0; + const double spacing = 12.0; final crossAxisCount = _isTablet ? 6 : 3; final double availableWidth = screenWidth - (padding * 2) - (spacing * (crossAxisCount - 1)); - final double minItemWidth = 80.0; + const double minItemWidth = 80.0; final double calculatedItemWidth = availableWidth / crossAxisCount; final double itemWidth = math.max(calculatedItemWidth, minItemWidth); final double itemHeight = itemWidth * 2.0; @@ -1676,7 +1676,7 @@ class _PlayerScreenState extends State builder: (context, constraints) { // 计算按钮宽度:根据设备类型调整 final screenWidth = constraints.maxWidth; - final horizontalPadding = 32.0; // 左右各16 + const horizontalPadding = 32.0; // 左右各16 final availableWidth = screenWidth - horizontalPadding; final cardsPerView = _isTablet ? 6.2 : 3.2; final buttonWidth = (availableWidth / cardsPerView) - 6; // 减去右边距6 @@ -1837,7 +1837,7 @@ class _PlayerScreenState extends State builder: (context) { return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { - return Container( + return SizedBox( height: panelHeight, width: double.infinity, child: PlayerEpisodesPanel( @@ -1938,7 +1938,7 @@ class _PlayerScreenState extends State builder: (context) { return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { - return Container( + return SizedBox( height: panelHeight, width: double.infinity, child: PlayerDetailsPanel( @@ -2075,7 +2075,7 @@ class _PlayerScreenState extends State builder: (context, constraints) { // 计算卡片宽度:根据设备类型调整 final screenWidth = constraints.maxWidth; - final horizontalPadding = 32.0; // 左右各16 + const horizontalPadding = 32.0; // 左右各16 final availableWidth = screenWidth - horizontalPadding; final cardsPerView = _isTablet ? 6.2 : 3.2; final cardWidth = (availableWidth / cardsPerView) - 6; // 减去右边距6 @@ -2207,7 +2207,7 @@ class _PlayerScreenState extends State builder: (context) { return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { - return Container( + return SizedBox( height: panelHeight, width: double.infinity, child: PlayerSourcesPanel( @@ -2660,10 +2660,12 @@ class _PlayerScreenState extends State ), child: Column( children: [ - // Windows 自定义标题栏(播放页使用纯黑背景) + // Windows 自定义标题栏(播放页跟随主题) if (Platform.isWindows) - const WindowsTitleBar( - customBackgroundColor: Color(0xFF000000), + WindowsTitleBar( + customBackgroundColor: isDarkMode + ? const Color(0xFF121212) + : const Color(0xFFf5f5f5), ), // 主要内容 Expanded( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 80cd3e08..d6ed0fde 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -38,7 +38,7 @@ class _SearchScreenState extends State final ScrollController _scrollController = ScrollController(); String _searchQuery = ''; List _searchHistory = []; - List _searchResults = []; + final List _searchResults = []; bool _hasSearched = false; bool _hasReceivedStart = false; // 是否已收到start消息 String? _searchError; diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 90445b8c..a3d7df1c 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -73,6 +73,30 @@ class ApiService { return '$cleanBaseUrl$cleanEndpoint'; } + /// 添加浏览器特征头 + static Future _addBrowserHeaders(Map headers) async { + final ua = await UserDataService.getCustomUserAgent(); + headers['User-Agent'] = ua; + + final enabled = await UserDataService.getEnableBrowserHeaders(); + if (enabled) { + headers['Accept-Language'] = 'zh-CN,zh;q=0.9,en;q=0.8'; + headers['Accept-Encoding'] = 'gzip, deflate, br'; + headers['Sec-Fetch-Site'] = 'same-origin'; + headers['Sec-Fetch-Mode'] = 'cors'; + headers['Sec-Fetch-Dest'] = 'empty'; + headers['Sec-Ch-Ua'] = + '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"'; + headers['Sec-Ch-Ua-Mobile'] = '?0'; + headers['Sec-Ch-Ua-Platform'] = '"Windows"'; + } + + final customHeader = await UserDataService.getCustomHeader(); + if (customHeader.isNotEmpty) { + headers.addAll(customHeader); + } + } + /// 构建请求头 static Future> _buildHeaders({ Map? additionalHeaders, @@ -83,6 +107,9 @@ class ApiService { 'Accept': 'application/json', }; + // 添加浏览器特征头 + await _addBrowserHeaders(headers); + // 添加认证cookies if (includeAuth) { final cookies = await _getCookies(); @@ -574,13 +601,16 @@ class ApiService { } String loginUrl = '$baseUrl/api/login'; + final loginHeaders = { + 'Content-Type': 'application/json', + }; + await _addBrowserHeaders(loginHeaders); + // 发送登录请求 final response = await http .post( Uri.parse(loginUrl), - headers: { - 'Content-Type': 'application/json', - }, + headers: loginHeaders, body: json.encode({ 'username': username, 'password': password, @@ -802,17 +832,26 @@ class ApiService { } } - /// 解析 Set-Cookie 头部 + /// 解析 Set-Cookie 头部,提取所有 Cookie 的 name=value static String _parseCookies(http.Response response) { - List cookies = []; - - // 获取所有 Set-Cookie 头部 - final setCookieHeaders = response.headers['set-cookie']; - if (setCookieHeaders != null) { - // HTTP 头部通常是 String 类型 - final cookieParts = setCookieHeaders.split(';'); - if (cookieParts.isNotEmpty) { - cookies.add(cookieParts[0].trim()); + final allCookies = response.headers['set-cookie']; + if (allCookies == null || allCookies.isEmpty) return ''; + + // 按 \n(Dart http 包多值拼接方式)或 ","(HTTP 标准)切分多个 cookie + final lines = allCookies + .split('\n') + .expand((line) => line.split(',')) + .where((s) => s.trim().isNotEmpty) + .toList(); + + final cookies = []; + for (final line in lines) { + final semicolonIdx = line.indexOf(';'); + final nameValue = semicolonIdx > 0 + ? line.substring(0, semicolonIdx).trim() + : line.trim(); + if (nameValue.contains('=')) { + cookies.add(nameValue); } } diff --git a/lib/services/douban_service.dart b/lib/services/douban_service.dart index 6b6c6fdc..5494d592 100644 --- a/lib/services/douban_service.dart +++ b/lib/services/douban_service.dart @@ -95,6 +95,244 @@ class DoubanService { return _uniqueOrigin!; } + /// 解析豆瓣HTML详情页面 + static DoubanMovieDetails _parseDoubanHtmlDetails(String html, String id) { + try { + // 提取基本信息 - 标题 + final titleRegex = RegExp(r']*>[\s\S]*?]*property="v:itemreviewed"[^>]*>([^<]+)'); + final titleMatch = titleRegex.firstMatch(html); + final title = titleMatch?.group(1)?.trim() ?? ''; + + // 提取海报 + final posterRegex = RegExp(r']*class="nbgnbg"[^>]*>[\s\S]*?]*src="([^"]+)"'); + final posterMatch = posterRegex.firstMatch(html); + final poster = posterMatch?.group(1) ?? ''; + + // 提取评分 + final ratingRegex = RegExp(r']*class="ll rating_num"[^>]*property="v:average">([^<]+)'); + final ratingMatch = ratingRegex.firstMatch(html); + final rate = ratingMatch?.group(1); + + // 提取年份 + final yearRegex = RegExp(r']*class="year">[(]([^)]+)[)]'); + final yearMatch = yearRegex.firstMatch(html); + final year = yearMatch?.group(1) ?? ''; + + // 提取导演 + List directors = []; + final directorRegex = RegExp(r'导演:\s*(.*?)'); + final directorMatch = directorRegex.firstMatch(html); + if (directorMatch != null) { + final directorLinks = RegExp(r']*>([^<]+)').allMatches(directorMatch.group(1)!); + directors = directorLinks.map((match) => match.group(1)?.trim() ?? '').where((name) => name.isNotEmpty).toList(); + } + + // 提取编剧 + List screenwriters = []; + final writerRegex = RegExp(r'编剧:\s*(.*?)'); + final writerMatch = writerRegex.firstMatch(html); + if (writerMatch != null) { + final writerLinks = RegExp(r']*>([^<]+)').allMatches(writerMatch.group(1)!); + screenwriters = writerLinks.map((match) => match.group(1)?.trim() ?? '').where((name) => name.isNotEmpty).toList(); + } + + // 提取主演 + List actors = []; + final castRegex = RegExp(r'主演:\s*(.*?)'); + final castMatch = castRegex.firstMatch(html); + if (castMatch != null) { + final castLinks = RegExp(r']*>([^<]+)').allMatches(castMatch.group(1)!); + actors = castLinks.map((match) => match.group(1)?.trim() ?? '').where((name) => name.isNotEmpty).toList(); + } + + // 提取类型 + final genreRegex = RegExp(r']*property="v:genre">([^<]+)'); + final genreMatches = genreRegex.allMatches(html); + final genres = genreMatches.map((match) => match.group(1) ?? '').where((genre) => genre.isNotEmpty).toList(); + + // 提取制片国家/地区 + final countryRegex = RegExp(r']*class="pl">制片国家/地区:([^<]+)'); + final countryMatch = countryRegex.firstMatch(html); + final countries = countryMatch?.group(1)?.trim().split('/').map((c) => c.trim()).where((c) => c.isNotEmpty).toList() ?? []; + + // 提取语言 + final languageRegex = RegExp(r']*class="pl">语言:([^<]+)'); + final languageMatch = languageRegex.firstMatch(html); + final languages = languageMatch?.group(1)?.trim().split('/').map((l) => l.trim()).where((l) => l.isNotEmpty).toList() ?? []; + + // 提取首播/上映日期 + String? releaseDate; + final firstAiredRegex = RegExp(r'首播:\s*]*property="v:initialReleaseDate"[^>]*content="([^"]*)"[^>]*>([^<]*)'); + final firstAiredMatch = firstAiredRegex.firstMatch(html); + if (firstAiredMatch != null) { + releaseDate = firstAiredMatch.group(1); + } else { + final releaseDateRegex = RegExp(r'上映日期:\s*]*property="v:initialReleaseDate"[^>]*content="([^"]*)"[^>]*>([^<]*)'); + final releaseDateMatch = releaseDateRegex.firstMatch(html); + if (releaseDateMatch != null) { + releaseDate = releaseDateMatch.group(1); + } + } + + // 提取集数(仅剧集有) + int? episodes; + final episodesRegex = RegExp(r']*class="pl">集数:([^<]+)'); + final episodesMatch = episodesRegex.firstMatch(html); + if (episodesMatch != null) { + episodes = int.tryParse(episodesMatch.group(1)?.trim() ?? ''); + } + + // 提取时长 - 支持电影和剧集 + int? episodeLength; + int? movieDuration; + + // 先尝试提取剧集的单集片长 + final singleEpisodeDurationRegex = RegExp(r']*class="pl">单集片长:([^<]+)'); + final singleEpisodeDurationMatch = singleEpisodeDurationRegex.firstMatch(html); + if (singleEpisodeDurationMatch != null) { + episodeLength = int.tryParse(singleEpisodeDurationMatch.group(1)?.trim() ?? ''); + } else { + // 如果没有单集片长,尝试提取电影的总片长 + final movieDurationRegex = RegExp(r']*class="pl">片长:([^<]+)'); + final movieDurationMatch = movieDurationRegex.firstMatch(html); + if (movieDurationMatch != null) { + movieDuration = int.tryParse(movieDurationMatch.group(1)?.trim() ?? ''); + } + } + + // 为了保持与现有代码的兼容性,将时长转换为字符串 + String? duration; + if (episodeLength != null) { + duration = '$episodeLength分钟'; + } else if (movieDuration != null) { + duration = '$movieDuration分钟'; + } + + // 提取剧情简介 - 两个正则都匹配,选择内容更长的 + String? summary; + + // 使用多行模式和非贪婪匹配来正确处理包含HTML标签的内容 + final summaryRegex1 = RegExp(r']*class="all hidden">(.*?)', multiLine: true, dotAll: true); + final summaryMatch1 = summaryRegex1.firstMatch(html); + String? summary1; + if (summaryMatch1 != null) { + summary1 = summaryMatch1.group(1) + ?.replaceAll(RegExp(r'', caseSensitive: false), '|||LINEBREAK|||') // 先用特殊标记替换
+ .replaceAll(RegExp(r'<[^>]*>'), '') // 移除所有HTML标签 + .replaceAll(RegExp(r'\s+'), ' ') // 去除重复空格,将所有空白字符(包括HTML中的\n)合并为单个空格 + .replaceAll('|||LINEBREAK|||', '\n') // 将特殊标记恢复为换行符 + .trim() + .split('\n') // 按换行符分割 + .join('\n'); // 重新组合 + } + + final summaryRegex2 = RegExp(r']*property="v:summary"[^>]*>(.*?)', multiLine: true, dotAll: true); + final summaryMatch2 = summaryRegex2.firstMatch(html); + String? summary2; + if (summaryMatch2 != null) { + summary2 = summaryMatch2.group(1) + ?.replaceAll(RegExp(r'', caseSensitive: false), '|||LINEBREAK|||') // 先用特殊标记替换
+ .replaceAll(RegExp(r'<[^>]*>'), '') // 移除所有HTML标签 + .replaceAll(RegExp(r'\s+'), ' ') // 去除重复空格,将所有空白字符(包括HTML中的\n)合并为单个空格 + .replaceAll('|||LINEBREAK|||', '\n') // 将特殊标记恢复为换行符 + .trim() + .split('\n') // 按换行符分割 + .join('\n'); // 重新组合 + } + + // 选择内容更长的简介 + if (summary1 != null && summary2 != null) { + summary = summary1.length >= summary2.length ? summary1 : summary2; + } else if (summary1 != null) { + summary = summary1; + } else if (summary2 != null) { + summary = summary2; + } + + // 提取推荐区域 + List recommends = []; + try { + // 查找推荐区域 + final recommendationsRegex = RegExp(r']*id="recommendations"[^>]*>(.*?)', multiLine: true, dotAll: true); + final recommendationsMatch = recommendationsRegex.firstMatch(html); + + if (recommendationsMatch != null) { + final recommendationsContent = recommendationsMatch.group(1) ?? ''; + + // 提取所有推荐项目 + final dlRegex = RegExp(r'
(.*?)
', multiLine: true, dotAll: true); + final dlMatches = dlRegex.allMatches(recommendationsContent); + + for (final dlMatch in dlMatches) { + final dlContent = dlMatch.group(1) ?? ''; + + // 提取链接和海报 + final linkRegex = RegExp(r']*href="https://movie\.douban\.com/subject/(\d+)/[^"]*"[^>]*>'); + final linkMatch = linkRegex.firstMatch(dlContent); + + // 提取海报图片 + final imgRegex = RegExp(r']*src="([^"]+)"[^>]*alt="([^"]*)"'); + final imgMatch = imgRegex.firstMatch(dlContent); + + // 提取评分 + final rateRegex = RegExp(r']*class="subject-rate"[^>]*>([^<]*)'); + final rateMatch = rateRegex.firstMatch(dlContent); + + if (linkMatch != null && imgMatch != null) { + final recommendId = linkMatch.group(1) ?? ''; + final posterUrl = imgMatch.group(1) ?? ''; + final title = imgMatch.group(2) ?? ''; + final recommendRate = rateMatch?.group(1)?.trim(); + + // 过滤掉空的评分 + final rate = recommendRate?.isNotEmpty == true ? recommendRate : null; + + if (recommendId.isNotEmpty && title.isNotEmpty && posterUrl.isNotEmpty) { + recommends.add(DoubanRecommendItem( + id: recommendId, + title: title, + poster: posterUrl, + rate: rate, + )); + } + } + } + } + } catch (e) { + // 推荐区域解析失败,继续执行 + print('解析推荐区域失败: $e'); + } + + return DoubanMovieDetails( + id: id, + title: title, + poster: poster, + rate: rate, + year: year, + summary: summary, + genres: genres, + directors: directors, + screenwriters: screenwriters, + actors: actors, + duration: duration, + countries: countries, + languages: languages, + releaseDate: releaseDate, + originalTitle: null, // HTML页面中暂未找到原始标题的提取逻辑 + imdbId: null, // HTML页面中暂未找到IMDB ID的提取逻辑 + recommends: recommends, + totalEpisodes: episodes, + ); + } catch (e) { + // 如果解析失败,返回基本信息 + return DoubanMovieDetails( + id: id, + title: '解析失败', + poster: '', + year: '', + ); + } + } /// 初始化缓存服务 static Future _initCache() async { if (!_cacheInitialized) { diff --git a/lib/services/local_search_cache_service.dart b/lib/services/local_search_cache_service.dart index 66f0af1e..e7b86d3b 100644 --- a/lib/services/local_search_cache_service.dart +++ b/lib/services/local_search_cache_service.dart @@ -141,7 +141,7 @@ class LocalSearchCacheService { if (_cleanupTimer != null) return; // 避免重复启动 _cleanupTimer = Timer.periodic( - Duration(milliseconds: _cacheCleanupIntervalMs), + const Duration(milliseconds: _cacheCleanupIntervalMs), (_) { _performCacheCleanup(); }, diff --git a/lib/services/page_cache_service.dart b/lib/services/page_cache_service.dart index 1a673edc..8fd7f75b 100644 --- a/lib/services/page_cache_service.dart +++ b/lib/services/page_cache_service.dart @@ -554,7 +554,7 @@ class PageCacheService final existingItem = cachedData[existingIndex]; final updatedHistory = [ existingItem, - ...cachedData.where((item) => item != query).toList() + ...cachedData.where((item) => item != query) ]; setCache(cacheKey, updatedHistory); } diff --git a/lib/services/sse_search_service.dart b/lib/services/sse_search_service.dart index 8dac40b3..c2a4931a 100644 --- a/lib/services/sse_search_service.dart +++ b/lib/services/sse_search_service.dart @@ -270,7 +270,7 @@ class SSESearchService { _buffer = ''; // 使用流式 UTF-8 解码器,自动处理跨 chunk 的多字节字符 - final utf8Decoder = const Utf8Decoder(allowMalformed: false); + const utf8Decoder = Utf8Decoder(allowMalformed: false); // 流式处理 SSE 数据 await for (final chunk in response.stream.transform(utf8Decoder)) { diff --git a/lib/services/user_data_service.dart b/lib/services/user_data_service.dart index 7e89e160..b93695e4 100644 --- a/lib/services/user_data_service.dart +++ b/lib/services/user_data_service.dart @@ -11,6 +11,10 @@ class UserDataService { static const String _preferSpeedTestKey = 'prefer_speed_test'; static const String _localSearchKey = 'local_search'; static const String _isLocalModeKey = 'is_local_mode'; + static const String _customUserAgentKey = 'custom_user_agent'; + static const String _enableBrowserHeadersKey = 'enable_browser_headers'; + static const String _customHeaderNameKey = 'custom_header_name'; + static const String _customHeaderValueKey = 'custom_header_value'; // 内存缓存 static bool? _isLocalModeCache; @@ -257,4 +261,45 @@ class UserDataService { static bool getIsLocalModeSync() { return _isLocalModeCache ?? false; } + + static const String _defaultUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Safari/537.36'; + + static Future saveCustomUserAgent(String ua) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_customUserAgentKey, ua); + } + + static Future getCustomUserAgent() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_customUserAgentKey) ?? _defaultUserAgent; + } + + static Future saveEnableBrowserHeaders(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_enableBrowserHeadersKey, enabled); + } + + static Future getEnableBrowserHeaders() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_enableBrowserHeadersKey) ?? false; + } + + static Future saveCustomHeader(String name, String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_customHeaderNameKey, name); + await prefs.setString(_customHeaderValueKey, value); + } + + static Future> getCustomHeader() async { + final prefs = await SharedPreferences.getInstance(); + final name = prefs.getString(_customHeaderNameKey) ?? ''; + final value = prefs.getString(_customHeaderValueKey) ?? ''; + if (name.isNotEmpty && value.isNotEmpty) { + return {name: value}; + } + return {}; + } } diff --git a/lib/services/version_service.dart b/lib/services/version_service.dart index a9415bcc..63f79835 100644 --- a/lib/services/version_service.dart +++ b/lib/services/version_service.dart @@ -81,7 +81,7 @@ class VersionService { // 检查上次检查时间(每天最多提示一次) final lastCheck = prefs.getInt(_lastCheckKey) ?? 0; final now = DateTime.now().millisecondsSinceEpoch; - final dayInMs = 24 * 60 * 60 * 1000; + const dayInMs = 24 * 60 * 60 * 1000; if (now - lastCheck < dayInMs) { return false; diff --git a/lib/widgets/continue_watching_section.dart b/lib/widgets/continue_watching_section.dart index 2fe5cff0..378aaaf1 100644 --- a/lib/widgets/continue_watching_section.dart +++ b/lib/widgets/continue_watching_section.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/play_record.dart'; import '../models/video_info.dart'; -import '../services/api_service.dart'; import '../services/page_cache_service.dart'; import '../services/theme_service.dart'; import '../utils/device_utils.dart'; diff --git a/lib/widgets/dlna_player_controls.dart b/lib/widgets/dlna_player_controls.dart index ffc1c35e..b15a182a 100644 --- a/lib/widgets/dlna_player_controls.dart +++ b/lib/widgets/dlna_player_controls.dart @@ -304,16 +304,16 @@ class _DLNAPlayerControlsState extends State { ), ), // 中央加载指示器 - Center( + const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const CircularProgressIndicator( + CircularProgressIndicator( color: Colors.white, strokeWidth: 3, ), - const SizedBox(height: 16), - const Text( + SizedBox(height: 16), + Text( '视频加载中...', style: TextStyle( color: Colors.white, diff --git a/lib/widgets/favorites_grid.dart b/lib/widgets/favorites_grid.dart index e80a5f52..4bc7db3b 100644 --- a/lib/widgets/favorites_grid.dart +++ b/lib/widgets/favorites_grid.dart @@ -362,10 +362,10 @@ class _FavoritesGridState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.error_outline, size: 80, - color: const Color(0xFFbdc3c7), + color: Color(0xFFbdc3c7), ), const SizedBox(height: 24), Text( diff --git a/lib/widgets/mobile_player_controls.dart b/lib/widgets/mobile_player_controls.dart index de2a7157..4ca23e91 100644 --- a/lib/widgets/mobile_player_controls.dart +++ b/lib/widgets/mobile_player_controls.dart @@ -244,8 +244,9 @@ class _MobilePlayerControlsState extends State { } void _onSwipeUpdate(DragUpdateDetails details) { - if (_isLocked || !_isSeekingViaSwipe || widget.live || _screenSize == null) + if (_isLocked || !_isSeekingViaSwipe || widget.live || _screenSize == null) { return; + } final screenWidth = _screenSize!.width; final swipeDistance = details.globalPosition.dx - _swipeStartX; final swipeRatio = swipeDistance / (screenWidth * 0.5); diff --git a/lib/widgets/player_details_panel.dart b/lib/widgets/player_details_panel.dart index f0408d39..261cc364 100644 --- a/lib/widgets/player_details_panel.dart +++ b/lib/widgets/player_details_panel.dart @@ -168,7 +168,7 @@ class PlayerDetailsPanel extends StatelessWidget { ], if (totalEpisodes != null && totalEpisodes > 1) ...[ Text( - '全${totalEpisodes}集', + '全$totalEpisodes集', style: theme.textTheme.bodyMedium?.copyWith( color: isDarkMode ? Colors.grey[400] @@ -364,7 +364,7 @@ class PlayerDetailsPanel extends StatelessWidget { const SizedBox(height: 4), if (totalEpisodes > 1) Text( - '全${totalEpisodes}集', + '全$totalEpisodes集', style: theme.textTheme.bodyMedium?.copyWith( color: isDarkMode ? Colors.grey[400] diff --git a/lib/widgets/search_result_agg_grid.dart b/lib/widgets/search_result_agg_grid.dart index 5316176e..306d4828 100644 --- a/lib/widgets/search_result_agg_grid.dart +++ b/lib/widgets/search_result_agg_grid.dart @@ -158,10 +158,10 @@ class _SearchResultAggGridState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.search_off, size: 80, - color: const Color(0xFFbdc3c7), + color: Color(0xFFbdc3c7), ), const SizedBox(height: 24), Text( diff --git a/lib/widgets/user_menu.dart b/lib/widgets/user_menu.dart index e44d2136..8a773eb4 100644 --- a/lib/widgets/user_menu.dart +++ b/lib/widgets/user_menu.dart @@ -856,10 +856,10 @@ class _UserMenuState extends State { ), child: Row( children: [ - Icon( + const Icon( LucideIcons.trash2, size: 20, - color: const Color(0xFFf59e0b), + color: Color(0xFFf59e0b), ), const SizedBox(width: 12), Text( @@ -896,10 +896,10 @@ class _UserMenuState extends State { ), child: Row( children: [ - Icon( + const Icon( LucideIcons.download, size: 20, - color: const Color(0xFF3b82f6), + color: Color(0xFF3b82f6), ), const SizedBox(width: 12), Text( diff --git a/lib/widgets/video_menu_bottom_sheet.dart b/lib/widgets/video_menu_bottom_sheet.dart index 19661f6d..614458c4 100644 --- a/lib/widgets/video_menu_bottom_sheet.dart +++ b/lib/widgets/video_menu_bottom_sheet.dart @@ -68,7 +68,7 @@ class CollapsibleScrollPhysics extends ScrollPhysics { @override ScrollPhysics buildParent(ScrollPhysics? ancestor) { // 根据平台选择合适的父物理效果 - final parentPhysics = isIOS ? BouncingScrollPhysics() : ClampingScrollPhysics(); + final parentPhysics = isIOS ? const BouncingScrollPhysics() : const ClampingScrollPhysics(); return parent?.applyTo(ancestor ?? parentPhysics) ?? parentPhysics; } } @@ -839,7 +839,7 @@ class _VideoMenuBottomSheetState extends State // 关闭按钮 GestureDetector( onTap: widget.onClose, - child: Container( + child: SizedBox( width: 32, height: 32, child: Icon( @@ -974,10 +974,10 @@ class _VideoMenuBottomSheetState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( + const Icon( Icons.star, size: 16, - color: const Color(0xFFFFB800), + color: Color(0xFFFFB800), ), const SizedBox(width: 4), Text( @@ -1138,10 +1138,10 @@ class _VideoMenuBottomSheetState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( + const Icon( Icons.star, size: 16, - color: const Color(0xFFE91E63), + color: Color(0xFFE91E63), ), const SizedBox(width: 4), Text( diff --git a/lib/widgets/video_player_widget.dart b/lib/widgets/video_player_widget.dart index 99862fa5..78062509 100644 --- a/lib/widgets/video_player_widget.dart +++ b/lib/widgets/video_player_widget.dart @@ -168,7 +168,13 @@ class _VideoPlayerWidgetState extends State if (_playerDisposed) { return; } - _player = Player(); + _player = Player( + configuration: PlayerConfiguration( + title: 'Selene', + bufferSize: 32 * 1024 * 1024, + logLevel: MPVLogLevel.error, + ), + ); _videoController = VideoController(_player!); _setupPlayerListeners(); if (_currentUrl != null) { diff --git a/lib/widgets/windows_title_bar.dart b/lib/widgets/windows_title_bar.dart index c1a31eb7..a672da66 100644 --- a/lib/widgets/windows_title_bar.dart +++ b/lib/widgets/windows_title_bar.dart @@ -170,7 +170,7 @@ class _WindowsButtonHoverState extends State<_WindowsButtonHover> { color: backgroundColor ?? Colors.transparent, child: Center( child: widget.isCloseButton && _isHovered - ? Icon( + ? const Icon( Icons.close, size: 16, color: Colors.white, diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh new file mode 100755 index 00000000..e198a25c --- /dev/null +++ b/scripts/ci-build.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# Selene CI 构建脚本 +# 自动检测输出路径、依赖版本,减少上游变更导致的 CI 挂掉 + +set -e + +PLATFORM="$1" +if [ -z "$PLATFORM" ]; then + echo "Usage: $0 " + exit 1 +fi + +log() { echo -e "\033[1;34m[CI]\033[0m $1"; } +err() { echo -e "\033[1;31m[ERROR]\033[0m $1"; exit 1; } + +# 从 pubspec.yaml 读版本 +VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: *//' | cut -d'+' -f1) +log "Version: $VERSION" + +# 通用:查找构建产物(递归搜索,不硬编码路径) +find_artifact() { + local pattern="$1" + local found + found=$(find build -name "$pattern" -type f 2>/dev/null | head -1) + if [ -z "$found" ]; then + # 尝试目录 + found=$(find build -name "$pattern" -type d 2>/dev/null | head -1) + fi + echo "$found" +} + +build_linux() { + log "Building Linux..." + flutter build linux --release + + # 动态查找 bundle 目录和可执行文件 + local bundle_dir + bundle_dir=$(find build/linux -name "bundle" -type d 2>/dev/null | head -1) + [ -z "$bundle_dir" ] && err "Cannot find Linux bundle directory" + + local binary + binary=$(find "$bundle_dir" -maxdepth 1 -type f -executable 2>/dev/null | head -1) + [ -z "$binary" ] && err "Cannot find Linux binary in $bundle_dir" + + log "Found binary: $binary" + cd "$bundle_dir" + tar czf "selene-linux.tar.gz" "$(basename "$binary")" + log "Packed: selene-linux.tar.gz" +} + +build_android() { + log "Building Android..." + + # 动态读取 NDK 版本(从 build.gradle 或 local.properties) + local ndk_version + ndk_version=$(grep -oP 'ndkVersion\s*=\s*"\K[^"]+' android/app/build.gradle 2>/dev/null || \ + grep -oP 'ndk\.version\s*=\s*\K\S+' android/local.properties 2>/dev/null || \ + echo "") + + if [ -n "$ndk_version" ]; then + log "NDK version from project: $ndk_version" + yes | sdkmanager --licenses 2>/dev/null || true + sdkmanager --install "ndk;$ndk_version" 2>/dev/null || true + fi + + flutter build apk --release \ + --split-per-abi \ + --obfuscate \ + --split-debug-info=build/app/outputs/symbols \ + --target-platform android-arm64,android-arm + + log "Android APKs:" + ls -la build/app/outputs/flutter-apk/*.apk 2>/dev/null +} + +build_ios() { + log "Building iOS..." + flutter build ios --release --no-codesign + + local app_dir + app_dir=$(find build/ios -name "Runner.app" -type d 2>/dev/null | head -1) + [ -z "$app_dir" ] && err "Cannot find Runner.app" + + cd "$(dirname "$app_dir")" + mkdir -p Payload + cp -r Runner.app Payload/ + zip -r "../../../selene-ios.ipa" Payload/ + rm -rf Payload + log "Packed: selene-ios.ipa" +} + +build_macos() { + log "Building macOS..." + flutter build macos --release + + local app_dir + app_dir=$(find build/macos -name "selene.app" -o -name "Selene.app" -o -name "Runner.app" 2>/dev/null | head -1) + [ -z "$app_dir" ] && err "Cannot find macOS .app" + + hdiutil create -volname "Selene" \ + -srcfolder "$app_dir" \ + -ov -format UDZO \ + selene-macos.dmg + log "Packed: selene-macos.dmg" +} + +case "$PLATFORM" in + linux) build_linux ;; + android) build_android ;; + ios) build_ios ;; + macos) build_macos ;; + *) err "Unknown platform: $PLATFORM" ;; +esac + +log "Done: $PLATFORM"