diff --git a/assets/imgs/home_eyes.png b/assets/imgs/home_eyes.png new file mode 100644 index 0000000..e4d21d1 Binary files /dev/null and b/assets/imgs/home_eyes.png differ diff --git a/assets/other/empty_img.png b/assets/other/empty_img.png new file mode 100644 index 0000000..a527354 Binary files /dev/null and b/assets/other/empty_img.png differ diff --git a/lib/common/api/api_string.dart b/lib/common/api/api_string.dart index 06ab3b0..5c315bc 100644 --- a/lib/common/api/api_string.dart +++ b/lib/common/api/api_string.dart @@ -36,6 +36,8 @@ class ApiStringUser { class ApiStringVideo { static const String getRecommandsList = "/getRecommands"; + // getDetailsRecommand + static const String getDetailsRecommand = "/getDetailsRecommand"; static const String getVideoDetail = "/getVideoDetails"; static const String getHomeModules = "/home/all-modules"; diff --git a/lib/common/api/ny_network_intercept.dart b/lib/common/api/ny_network_intercept.dart index 2d9c20e..64180a2 100644 --- a/lib/common/api/ny_network_intercept.dart +++ b/lib/common/api/ny_network_intercept.dart @@ -20,7 +20,7 @@ class NyNetworkIntercept extends Interceptor { // 打印请求日志 HelpLog.print("🔶 REQUEST [${options.method.toUpperCase()}] ${options.uri}"); - HelpLog.print("Headers: ${options.headers}"); + // HelpLog.print("Headers: ${options.headers}"); if (options.data != null) { HelpLog.print("Payload: ${options.data}"); } @@ -68,8 +68,6 @@ class NyNetworkIntercept extends Interceptor { handler.next(err); } - // ============ 私有方法 ============ - Future _addRequestHeaders(RequestOptions options) async { if (!kReleaseMode) { options.headers['security'] = 'false'; diff --git a/lib/common/api/video_request.dart b/lib/common/api/video_request.dart index 3ed62cd..e37a2af 100644 --- a/lib/common/api/video_request.dart +++ b/lib/common/api/video_request.dart @@ -36,13 +36,28 @@ class VideoRequest { if (list != null) { // id 28 不显示,删除 categoryList = list.map((e) => NyCategoryModel.fromJson(e)).toList(); - categoryList.removeWhere((element) => element.id == 28); categoryList.removeWhere((element) => element.name.length >= 10); } return categoryList; }); } + // getRecommandsList + static Future> getRecommandsList({ + int page = 1, + int pageSize = 10, + }) { + Map params = _setPage(page: page, pageSize: pageSize); + return ApiRequest.get(ApiStringVideo.getRecommandsList, queryParameters: params).then((value) { + if (value.isSuccess) { + // HelpLog.print('getRecommandsList: ${value.data}'); + List list = parseDataForList(value.data); + return list.map((e) => NyDramaModel.fromJson(e)).toList(); + } + return []; + }); + } + static Future> getCategoriesDetail({ required int categoryId, int page = 1, @@ -58,7 +73,7 @@ class VideoRequest { } // getSearchHots - static Future> getSearchHots() { + static Future> getSearchTrending() { return ApiRequest.get(ApiStringVideo.getSearchHots).then((value) { if (value.isSuccess) { HelpLog.print('getSearchHots: ${value.data}'); diff --git a/lib/common/help/help_image.dart b/lib/common/help/help_image.dart index ba57710..8aa7bf1 100644 --- a/lib/common/help/help_image.dart +++ b/lib/common/help/help_image.dart @@ -36,6 +36,7 @@ class ConstImage { static const String btnBgRad2 = "assets/imgs/btn_bg_rad2.png"; // remove static const String remove = "assets/imgs/remove.png"; + static const String homeEyes = "assets/imgs/home_eyes.png"; /// other static const String mineBg = "assets/other/mine_bg.png"; @@ -49,6 +50,7 @@ class ConstImage { // pricary web_site static const String primary = "assets/other/pricary.png"; static const String webSite = "assets/other/web_site.png"; + static const String emptyImg = "assets/other/empty_img.png"; } diff --git a/lib/pages/first/pages/first_page.dart b/lib/pages/first/pages/first_page.dart index 67bf623..8d831bd 100644 --- a/lib/pages/first/pages/first_page.dart +++ b/lib/pages/first/pages/first_page.dart @@ -70,10 +70,12 @@ class _RootPageState extends State { AppNav.to(SearchPage()); }, ), - Expanded( + _homeDatas.isEmpty ? SizedBox() : Expanded( child: CustomScrollView( slivers: [ - SliverToBoxAdapter(child: HomeFirstView()), + SliverToBoxAdapter(child: HomeFirstView( + categories: _categories, + )), SliverPadding( padding: const EdgeInsets.only(left: 16, right: 10, top: 16, bottom: 8), sliver: SliverToBoxAdapter( @@ -83,7 +85,11 @@ class _RootPageState extends State { SliverPadding( padding: const EdgeInsetsGeometry.symmetric(horizontal: 16), sliver: SliverToBoxAdapter( - child: HomeRankWidget(), + child: HomeRankWidget( + dramaRanks: _homeDatas + .firstWhere((element) => element.moduleKey == 'week_ranking') + .dataList, + ), ), ), const SliverToBoxAdapter(child: HomeRemainingSections()), diff --git a/lib/pages/first/pages/search_page.dart b/lib/pages/first/pages/search_page.dart index 8546c41..19ff582 100644 --- a/lib/pages/first/pages/search_page.dart +++ b/lib/pages/first/pages/search_page.dart @@ -1,9 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:novyronst/common/api/video_request.dart'; import 'package:novyronst/common/const/const_color.dart'; +import 'package:novyronst/common/const/const_string.dart'; +import 'package:novyronst/common/help/app_nav.dart'; +import 'package:novyronst/common/help/help_image.dart'; +import 'package:novyronst/common/help/help_time.dart'; +import 'package:novyronst/common/model/ny_drama_model.dart'; +import 'package:novyronst/pages/first/pages/search_result_page.dart'; import 'package:novyronst/pages/first/widgets/recent_history_view.dart'; import 'package:novyronst/pages/first/widgets/search_view.dart'; +import 'package:novyronst/pages/widgets/ny_tag_widget.dart'; import 'package:novyronst/tools/util/search_history_util.dart'; import 'package:novyronst/tools/widgets/novy_app_bar.dart'; +import 'package:novyronst/tools/widgets/novy_com_widget.dart'; class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -18,6 +27,7 @@ class _SearchPageState extends State { List _history = []; final _searchController = TextEditingController(); + List _trendingSearches = []; Future _loadSearchHistory() async { final list = await SearchHistoryUtil.getSearchList(); @@ -27,13 +37,10 @@ class _SearchPageState extends State { }); } - Future _onGoSearch(String keyword,) async { + Future _onGoSearch(String keyword) async { await SearchHistoryUtil.addSearch(keyword); await _loadSearchHistory(); - // HelpNav.to(ResultPage( - // keyword: keyword, - // )); - // 发起 搜索 + AppNav.to(SearchResultPage(searchText: keyword)); } @@ -41,25 +48,40 @@ class _SearchPageState extends State { return _history.isEmpty ? SizedBox() : RecentHistoryView( searchHistory: _history, onTapClear: () { - // HelpNav.showDialog(ComConfirmWidget( - // title: 'Clear Search History?', - // )); + _history.clear(); setState(() {}); }, onTapHistory: (value) { print('object: $value'); - // HelpNav.to(ResultPage(keyword: value)); + AppNav.to(SearchResultPage(searchText: value)); }, ); } + void _getTrendingSearches() { + VideoRequest.getSearchTrending().then((value) { + setState(() { + _trendingSearches = value; + }); + }); + } + + @override + void initState() { + super.initState(); + + _getTrendingSearches(); + _loadSearchHistory(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: ConstColor.withValue('#110D08'), appBar: NovyAppBar( titleWidget: SeSearchView( + controller: _searchController, onClickSearch: (keyword) => _onGoSearch(keyword), ), ), @@ -71,6 +93,90 @@ class _SearchPageState extends State { child: _buildHistoryView(), ) ), + const SliverToBoxAdapter(child: SizedBox(height: 16,)), + SliverList.separated( + itemBuilder: (context, index) { + return TrendingSearchItem(model: _trendingSearches[index]); + }, + itemCount: _trendingSearches.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + ) + ], + ), + ); + } +} + +class TrendingSearchItem extends StatelessWidget { + const TrendingSearchItem({super.key, + required this.model, + this.toCate = true, + }); + + final NyDramaModel model; + final bool toCate; + + @override + Widget build(BuildContext context) { + + Widget _tagWidget = SizedBox(); + String _tag = ''; + if (toCate) { + if (model.category!.isNotEmpty) { + _tag = '${model.category!.first}'; + } + _tagWidget = NyTagWidget( + text: _tag, + bgColor: Colors.white.withAlpha(127), + textColor: Colors.white.withAlpha(127), + ); + } else { + if (model.categoryList!.isNotEmpty) { + _tag = model.categoryList!.first.name; + } + _tagWidget = NyTagWidget( + text: _tag, + bgColor: Colors.white.withAlpha(127), + textColor: Colors.white.withAlpha(127), + ); + } + + + + return Container( + height: 96, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + HelpImage.network(model.imageUrl, width: 72, height: 96), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NovyText(model.name, fontSize: 14, + fontFamily: ConstFont.montBold, + ), + SizedBox( + height: 36, + child: NovyText(model.description, fontSize: 11, + color: Colors.white.withAlpha(127), + maxLines: 2, + ), + ), + const SizedBox(height: 5), + Row( + children: [ + _tagWidget, + if (_tag!='') const SizedBox(width: 12), + NyHotWidget(text: HelpTime.formatNum(model.watchTotal), + textColor: ConstColor.btGreen, + ) + ], + ) + ], + ), + ) ], ), ); diff --git a/lib/pages/first/pages/search_result_page.dart b/lib/pages/first/pages/search_result_page.dart index e69de29..4a896cd 100644 --- a/lib/pages/first/pages/search_result_page.dart +++ b/lib/pages/first/pages/search_result_page.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:novyronst/common/api/video_request.dart'; +import 'package:novyronst/common/model/ny_drama_model.dart'; +import 'package:novyronst/pages/first/pages/search_page.dart'; +import 'package:novyronst/pages/widgets/ny_empty_view.dart'; +import 'package:novyronst/tools/util/search_history_util.dart'; +import 'package:novyronst/tools/widgets/app_toast_util.dart'; + +import '../../../common/const/const_color.dart'; +import '../../../tools/widgets/novy_app_bar.dart'; +import '../widgets/search_view.dart'; + +class SearchResultPage extends StatefulWidget { + + const SearchResultPage({super.key, + required this.searchText, + }); + + final String searchText; + + @override + State createState() { + return _SearchResultPageState(); + } +} + +class _SearchResultPageState extends State { + + final _searchController = TextEditingController(); + final _searchFocus = FocusNode(); + List _searchResult = []; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + + _searchController.text = widget.searchText; + _searchList(); + } + + void _searchList() async { + String keyword = _searchController.text; + if (keyword.trim().isEmpty) { + _searchFocus.requestFocus(); + return; + } + _isLoading = true; + AppToastUtil.showLoading(); + // await SearchHistoryUtil.addSearch(keyword); + VideoRequest.search(keyword: keyword).then((valuer) { + _isLoading = false; + AppToastUtil.dismiss(); + setState(() { + _searchResult = valuer; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ConstColor.withValue('#110D08'), + appBar: NovyAppBar( + titleWidget: SeSearchView( + controller: _searchController, + focusNode: _searchFocus, + onClickSearch: (keyword) => _searchList(), + ), + ), + body: _searchResult.isEmpty && !_isLoading ? NyEmptyView() : ListView.separated( + itemBuilder: (context, index) { + return TrendingSearchItem( + model: _searchResult[index], + toCate: false, + ); + }, + separatorBuilder: (context, index) => SizedBox(height: 12), + itemCount: _searchResult.length + ) + ); + } +} \ No newline at end of file diff --git a/lib/pages/first/widgets/home_first_view.dart b/lib/pages/first/widgets/home_first_view.dart index 5149a75..525178e 100644 --- a/lib/pages/first/widgets/home_first_view.dart +++ b/lib/pages/first/widgets/home_first_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:novyronst/common/model/ny_category_model.dart'; import 'package:novyronst/pages/widgets/cut_corner_clipper.dart'; import 'package:novyronst/pages/widgets/ny_progress_widget.dart'; import 'package:novyronst/pages/widgets/ny_tag_widget.dart'; @@ -10,7 +11,11 @@ import 'package:novyronst/tools/widgets/novy_img_bg.dart'; class HomeFirstView extends StatefulWidget { - const HomeFirstView({super.key}); + const HomeFirstView({super.key, + required this.categories, + }); + + final List categories; @override State createState() { @@ -46,14 +51,15 @@ class _HomeFirstViewState extends State { SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: List.generate(3, (index) { + children: List.generate(widget.categories.length, (index) { + final _item = widget.categories[index]; return Container( margin: const EdgeInsets.only(right: 8), child: NovyImgBg(imgBg: ConstImage.cateBg, height: 52, child: Stack( children:[ - NovyText('ClassPp', color: ConstColor.btGreen, + NovyText(_item.name, color: ConstColor.btGreen, fontSize: 21, textAlign: TextAlign.center, fontFamily: ConstFont.montBlack, diff --git a/lib/pages/first/widgets/home_rank_widget.dart b/lib/pages/first/widgets/home_rank_widget.dart index b646277..5fe22b4 100644 --- a/lib/pages/first/widgets/home_rank_widget.dart +++ b/lib/pages/first/widgets/home_rank_widget.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:novyronst/common/const/const_color.dart'; import 'package:novyronst/common/const/const_string.dart'; +import 'package:novyronst/common/help/help_image.dart'; +import 'package:novyronst/common/model/ny_drama_model.dart'; import 'package:novyronst/tools/widgets/novy_com_widget.dart'; import '../../../tools/util/app_screen.dart'; @@ -10,7 +13,12 @@ import '../../widgets/ny_tag_widget.dart'; class HomeRankWidget extends StatelessWidget { - const HomeRankWidget({super.key}); + const HomeRankWidget({ + super.key, + required this.dramaRanks, + }); + + final List dramaRanks; @override Widget build(BuildContext context) { @@ -20,7 +28,7 @@ class HomeRankWidget extends StatelessWidget { cutSize: 40, child: Padding( padding: const EdgeInsets.all(8), - child: Column( + child: dramaRanks.isEmpty ? SizedBox(height: 20,) : Column( children: [ _buildRankList(), const SizedBox(height: 5), @@ -37,6 +45,7 @@ class HomeRankWidget extends StatelessWidget { } Row _buildRankList() { + return Row( children: [ _firstVideoView(), @@ -47,38 +56,116 @@ class HomeRankWidget extends StatelessWidget { } Widget _firstVideoView() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 160.w, height: 240.w, - decoration: BoxDecoration( - color: Colors.red, - ) - ), - const SizedBox(height: 10), - NovyText('Star of the ocean', fontSize: 15, - color: Colors.black, - fontFamily: ConstFont.montMedium, - ), - const SizedBox(height: 5), - NyTagWidget(text: 'Safiray'), - const SizedBox(height: 8), - ], + NyDramaModel _item = dramaRanks.first; + String _tag = "Safiray"; + if (_item.categoryList!=null && _item.categoryList!.isNotEmpty) { + _tag = _item.categoryList!.first.name; + } + return SizedBox( + width: 160.w, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + SizedBox( + width: 160.w, height: 240.w, + child: HelpImage.network(_item.imageUrl, fit: BoxFit.cover), + ), + Positioned( + top: -10, left: 1, + child: _colorsText(1), + ) + ], + ), + const SizedBox(height: 10), + NovyText(_item.name, fontSize: 15, + color: Colors.black, + fontFamily: ConstFont.montMedium, + ), + const SizedBox(height: 5), + Row( + children: [ + NyTagWidget(text: _tag), + ], + ), + const SizedBox(height: 8), + ], + ), ); } Widget _otherVideoView() { + int _count = dramaRanks.length - 1; + if (_count > 3) _count = 3; return Column( - children: List.generate(3, (index) { + children: List.generate(_count, (index) { + NyDramaModel _item = dramaRanks[index + 1]; + String _tag = ""; + if (_item.categoryList!=null && _item.categoryList!.isNotEmpty) { + _tag = _item.categoryList!.first.name; + } + return Container( margin: const EdgeInsets.only(bottom: 8), width: 160.w, height: 93.w, - decoration: BoxDecoration( - color: Colors.grey, - ) + child: Row( + children: [ + HelpImage.network(_item.imageUrl, width: 62.w, height: 93.w), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _colorsText(index + 2), + const SizedBox(height: 8), + NovyText(_item.name, fontSize: 13, + color: Colors.black, + ), + const SizedBox(height: 5), + _tag.isNotEmpty ? NyTagWidget(text: _tag) : SizedBox() + ], + ), + ) + ], + ), ); }) ); } + + _colorsText(int index) { + if (index == 4) { + return Text( + '$index', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.grey.withAlpha(127), + fontStyle: FontStyle.italic, + ), + ); + } + return ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + colors: [ + ConstColor.withValue('#3ED71F'), + ConstColor.withValue('#FFFF65'), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ).createShader(bounds); + }, + child: Text( + '$index', + style: TextStyle( + fontSize: index==1 ? 40: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + fontStyle: FontStyle.italic, + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/pages/first/widgets/home_remaining_sections.dart b/lib/pages/first/widgets/home_remaining_sections.dart index 7c92dda..69fcfbc 100644 --- a/lib/pages/first/widgets/home_remaining_sections.dart +++ b/lib/pages/first/widgets/home_remaining_sections.dart @@ -8,28 +8,6 @@ import 'package:novyronst/tools/widgets/novy_com_widget.dart'; class HomeRemainingSections extends StatelessWidget { const HomeRemainingSections({super.key}); - static const List<_DramaItem> _rankingItems = [ - _DramaItem( - title: 'Star Of The Ocean', - category: 'Period Drama', - palette: [Color(0xFF6E4968), Color(0xFFE5A3B8)], - ), - _DramaItem( - title: 'Not Your Bride', - category: 'Period Drama', - palette: [Color(0xFF262431), Color(0xFFE0A15F)], - ), - _DramaItem( - title: 'Not Your Bride', - category: 'Period Drama', - palette: [Color(0xFF261B16), Color(0xFFD9C2A2)], - ), - _DramaItem( - title: 'Not Your Bride', - category: 'Period Drama', - palette: [Color(0xFF223E36), Color(0xFFFFC568)], - ), - ]; static const _ActorInfo _actor = _ActorInfo( name: 'Cheng Zi', @@ -84,8 +62,7 @@ class HomeRemainingSections extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 10, 16, 92), child: Column( children: [ - const _RankingSection(items: _rankingItems), - const SizedBox(height: 18), + const SizedBox(height: 10), const _ActorSection(actor: _actor, items: _seriesItems), const SizedBox(height: 18), ..._categoryTitles.map((title) { @@ -100,51 +77,6 @@ class HomeRemainingSections extends StatelessWidget { } } -class _RankingSection extends StatelessWidget { - const _RankingSection({required this.items}); - - final List<_DramaItem> items; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.black, width: 2), - ), - child: Column( - children: [ - SizedBox( - height: 230, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 6, child: _TopRankingPoster(item: items.first)), - const SizedBox(width: 8), - Expanded( - flex: 5, - child: Column( - children: List.generate(3, (index) { - return Expanded( - child: _RankingListItem( - rank: index + 2, - item: items[index + 1], - ), - ); - }), - ), - ), - ], - ), - ), - const SizedBox(height: 5), - const _GreenActionButton(text: 'View more rankings'), - ], - ), - ); - } -} class _TopRankingPoster extends StatelessWidget { const _TopRankingPoster({required this.item}); diff --git a/lib/pages/first/widgets/search_view.dart b/lib/pages/first/widgets/search_view.dart index d0d5e03..1b3cdac 100644 --- a/lib/pages/first/widgets/search_view.dart +++ b/lib/pages/first/widgets/search_view.dart @@ -94,9 +94,13 @@ class _SearchViewState extends State { class SeSearchView extends StatefulWidget { const SeSearchView({super.key, this.onClickSearch, + required this.controller, + this.focusNode, }); final Function(String)? onClickSearch; + final TextEditingController controller; + final FocusNode? focusNode; @override State createState() { @@ -106,7 +110,12 @@ class SeSearchView extends StatefulWidget { class _SeSearchViewState extends State { - final _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + + } @override Widget build(BuildContext context) { @@ -123,8 +132,8 @@ class _SeSearchViewState extends State { Icon(Icons.search, color: Colors.white, size: 24), Expanded(child: CupertinoTextField( - controller: _searchController, - // focusNode: widget.focusNode, + controller: widget.controller, + focusNode: widget.focusNode, decoration: BoxDecoration(), placeholder: 'What are...... ', placeholderStyle: TextStyle( @@ -137,8 +146,8 @@ class _SeSearchViewState extends State { ), textInputAction: TextInputAction.search, onEditingComplete: () { - if (_searchController.text.trim().isNotEmpty) { - widget.onClickSearch?.call(_searchController.text); + if (widget.controller.text.trim().isNotEmpty) { + widget.onClickSearch?.call(widget.controller.text); } }, ),), @@ -146,8 +155,8 @@ class _SeSearchViewState extends State { HelpImage.asset(ConstImage.homeSearch, width: 78, height: 36, fit: BoxFit.fill ).addInkWell(onTap: () { - if (_searchController.text.trim().isNotEmpty) { - widget.onClickSearch?.call(_searchController.text); + if (widget.controller.text.trim().isNotEmpty) { + widget.onClickSearch?.call(widget.controller.text); } }) diff --git a/lib/pages/play/pages/play_page.dart b/lib/pages/play/pages/play_page.dart index 6c0f24c..d899b80 100644 --- a/lib/pages/play/pages/play_page.dart +++ b/lib/pages/play/pages/play_page.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:novyronst/common/api/video_request.dart'; +import 'package:novyronst/common/model/ny_drama_model.dart'; +import 'package:novyronst/pages/play/widgets/player_view.dart'; class PlayPage extends StatefulWidget { - const PlayPage({super.key}); + const PlayPage({super.key, this.isActive = true}); + + final bool isActive; @override State createState() { @@ -10,9 +15,114 @@ class PlayPage extends StatefulWidget { } class _RootPageState extends State { + final List _videoList = []; + int _page = 1; + bool _isLoading = false; + bool _isLoadingMore = false; + bool _hasMore = true; + + @override + void initState() { + super.initState(); + _getVideoList(refresh: true); + } + + Future _getVideoList({bool refresh = false}) async { + if (_isLoading || _isLoadingMore) return; + if (!refresh && !_hasMore) return; + + final nextPage = refresh ? 1 : _page + 1; + setState(() { + if (refresh) { + _isLoading = true; + } else { + _isLoadingMore = true; + } + }); + + final value = await VideoRequest.getRecommandsList(page: nextPage); + if (!mounted) return; + + setState(() { + if (refresh) { + _videoList + ..clear() + ..addAll(value); + } else { + _videoList.addAll(value); + } + + _page = nextPage; + _hasMore = value.isNotEmpty; + _isLoading = false; + _isLoadingMore = false; + }); + } + + void _handlePageChanged(int index) { + if (index >= _videoList.length - 1) { + _getVideoList(); + } + } + + Future _toggleCollect(int index) async { + if (index < 0 || index >= _videoList.length) return; + + final item = _videoList[index]; + final videoId = item.shortPlayVideoId ?? item.id; + if (videoId == null) return; + + final shortPlayId = item.shortPlayId ?? item.shortId; + final oldIsCollect = item.isCollect ?? false; + final oldCollectTotal = item.collectTotal; + final newIsCollect = !oldIsCollect; + final newCollectTotal = oldCollectTotal + (newIsCollect ? 1 : -1); + + setState(() { + _videoList[index] = item.copyWith( + isCollect: newIsCollect, + collectTotal: newCollectTotal < 0 ? 0 : newCollectTotal, + ); + }); + + final success = newIsCollect + ? await VideoRequest.collect(shortPlayId: shortPlayId, videoId: videoId) + : await VideoRequest.cancelCollect(shortPlayId: shortPlayId); + + if (!mounted || success) return; + + setState(() { + _videoList[index] = _videoList[index].copyWith( + isCollect: oldIsCollect, + collectTotal: oldCollectTotal, + ); + }); + } @override Widget build(BuildContext context) { - return Scaffold(); + return Scaffold(backgroundColor: Colors.black, body: _buildBody()); } -} \ No newline at end of file + + Widget _buildBody() { + if (_isLoading && _videoList.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (_videoList.isEmpty) { + return const Center( + child: Text('No videos', style: TextStyle(color: Colors.white70)), + ); + } + + return PlayerView( + videos: _videoList, + isActive: widget.isActive, + isLoadingMore: _isLoadingMore, + onPageChanged: _handlePageChanged, + onCollectTap: _toggleCollect, + ); + } +} diff --git a/lib/pages/play/widgets/player_view.dart b/lib/pages/play/widgets/player_view.dart new file mode 100644 index 0000000..c6a43f7 --- /dev/null +++ b/lib/pages/play/widgets/player_view.dart @@ -0,0 +1,477 @@ +import 'package:flutter/material.dart'; +import 'package:novyronst/common/const/const_color.dart'; +import 'package:novyronst/common/model/ny_drama_model.dart'; +import 'package:novyronst/tools/widgets/novy_com_widget.dart'; +import 'package:video_player/video_player.dart'; + +class PlayerView extends StatefulWidget { + const PlayerView({ + super.key, + required this.videos, + this.initialIndex = 0, + this.onPageChanged, + this.onCollectTap, + this.isLoadingMore = false, + this.isActive = true, + }); + + final List videos; + final int initialIndex; + final ValueChanged? onPageChanged; + final ValueChanged? onCollectTap; + final bool isLoadingMore; + final bool isActive; + + @override + State createState() => _PlayerViewState(); +} + +class _PlayerViewState extends State { + late final PageController _pageController; + final Map _controllers = {}; + final Map> _initializingControllers = {}; + int _currentIndex = 0; + + @override + void initState() { + super.initState(); + _currentIndex = widget.videos.isEmpty + ? 0 + : widget.initialIndex.clamp(0, widget.videos.length - 1); + _pageController = PageController(initialPage: _currentIndex); + _cachePlaybackWindow(_currentIndex); + widget.isActive ? _playActiveVideo() : _pauseAllVideos(); + } + + @override + void didUpdateWidget(covariant PlayerView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.videos.isEmpty) return; + + if (_currentIndex >= widget.videos.length) { + _currentIndex = widget.videos.length - 1; + } + + _cachePlaybackWindow(_currentIndex); + widget.isActive ? _playActiveVideo() : _pauseAllVideos(); + } + + @override + void dispose() { + _pageController.dispose(); + for (final controller in _controllers.values) { + controller + ..removeListener(_handleControllerChanged) + ..dispose(); + } + _controllers.clear(); + _initializingControllers.clear(); + super.dispose(); + } + + Future _cachePlaybackWindow(int index) async { + final keepIndexes = {index}; + if (index + 1 < widget.videos.length) { + keepIndexes.add(index + 1); + } + + final disposeIndexes = _controllers.keys + .where((controllerIndex) => !keepIndexes.contains(controllerIndex)) + .toList(); + for (final controllerIndex in disposeIndexes) { + final controller = _controllers.remove(controllerIndex); + _initializingControllers.remove(controllerIndex); + controller + ?..removeListener(_handleControllerChanged) + ..dispose(); + } + + await _ensureController(index); + if (index + 1 < widget.videos.length) { + await _ensureController(index + 1); + } + } + + Future _ensureController(int index) async { + if (index < 0 || index >= widget.videos.length) return null; + final cachedController = _controllers[index]; + if (cachedController != null) { + if (cachedController.value.isInitialized) return cachedController; + final initializingController = _initializingControllers[index]; + if (initializingController != null) return initializingController; + return cachedController; + } + + final videoUrl = resolveDramaVideoUrl(widget.videos[index]); + if (videoUrl == null) return null; + + final controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); + _controllers[index] = controller; + controller.addListener(_handleControllerChanged); + + final initializingController = _initializeController(index, controller); + _initializingControllers[index] = initializingController; + return initializingController; + } + + Future _initializeController( + int index, + VideoPlayerController controller, + ) async { + try { + await controller.initialize(); + await controller.setLooping(true); + if (!mounted || _controllers[index] != controller) return controller; + _initializingControllers.remove(index); + setState(() {}); + if (index == _currentIndex && + shouldPlayActiveVideo( + controller.value, + isPageActive: widget.isActive, + )) { + await controller.play(); + } + } catch (_) { + if (_controllers[index] == controller) { + _controllers.remove(index); + } + _initializingControllers.remove(index); + controller + ..removeListener(_handleControllerChanged) + ..dispose(); + if (mounted) setState(() {}); + return null; + } + + return controller; + } + + Future _playActiveVideo() async { + if (!widget.isActive) { + await _pauseAllVideos(); + return; + } + + for (final entry in _controllers.entries) { + if (entry.key != _currentIndex && entry.value.value.isPlaying) { + await entry.value.pause(); + } + } + + final controller = await _ensureController(_currentIndex); + if (!mounted || + controller == null || + _currentIndex >= widget.videos.length) { + return; + } + if (shouldPlayActiveVideo( + controller.value, + isPageActive: widget.isActive, + )) { + await controller.play(); + } + } + + Future _pauseAllVideos() async { + for (final controller in _controllers.values) { + if (controller.value.isPlaying) { + await controller.pause(); + } + } + } + + void _handleControllerChanged() { + if (mounted) { + setState(() {}); + } + } + + void _handlePageChanged(int index) { + _currentIndex = index; + widget.onPageChanged?.call(index); + _cachePlaybackWindow(index); + widget.isActive ? _playActiveVideo() : _pauseAllVideos(); + } + + void _togglePlayback() { + final controller = _controllers[_currentIndex]; + if (!widget.isActive || + controller == null || + !controller.value.isInitialized) { + return; + } + controller.value.isPlaying ? controller.pause() : controller.play(); + } + + @override + Widget build(BuildContext context) { + if (widget.videos.isEmpty) { + return const SizedBox.shrink(); + } + + return PageView.builder( + controller: _pageController, + scrollDirection: Axis.vertical, + itemCount: widget.videos.length, + onPageChanged: _handlePageChanged, + itemBuilder: (context, index) { + return _PlayerPage( + drama: widget.videos[index], + controller: _controllers[index], + isPageActive: widget.isActive, + isLoadingMore: + widget.isLoadingMore && index == widget.videos.length - 1, + onTap: _togglePlayback, + onCollectTap: () => widget.onCollectTap?.call(index), + ); + }, + ); + } +} + +class _PlayerPage extends StatelessWidget { + const _PlayerPage({ + required this.drama, + required this.controller, + required this.isPageActive, + required this.isLoadingMore, + required this.onTap, + required this.onCollectTap, + }); + + final NyDramaModel drama; + final VideoPlayerController? controller; + final bool isPageActive; + final bool isLoadingMore; + final VoidCallback onTap; + final VoidCallback onCollectTap; + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.paddingOf(context).bottom + 86; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: Colors.black, child: _buildVideoBody()), + const _BottomShade(), + if (isPageActive && shouldShowPauseIcon(controller?.value)) + const _PauseIndicator(), + Positioned( + left: 16, + right: 16, + bottom: bottomPadding - 10, + child: _buildMeta(), + ), + if (isLoadingMore) + Positioned( + left: 0, + right: 0, + bottom: bottomPadding - 30, + child: const Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildVideoBody() { + final activeController = controller; + if (activeController != null && activeController.value.isInitialized) { + final size = activeController.value.size; + return FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: size.width, + height: size.height, + child: VideoPlayer(activeController), + ), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + if (drama.imageUrl.isNotEmpty) + Image.network( + drama.imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const SizedBox.shrink(), + ), + Center( + child: resolveDramaVideoUrl(drama) == null + ? const Icon( + Icons.videocam_off_outlined, + color: Colors.white54, + size: 42, + ) + : const CircularProgressIndicator(color: Colors.white), + ), + ], + ); + } + + Widget _buildMeta() { + final isCollect = drama.isCollect ?? false; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NovyText( + drama.name, + fontSize: 20, + fontWeight: FontWeight.w700, + maxLines: 1, + shadow: const Shadow(color: Colors.black54, blurRadius: 8), + ), + const SizedBox(height: 8), + NovyText( + drama.description, + color: Colors.white.withValues(alpha: 0.82), + fontSize: 14, + maxLines: 2, + lineHeight: 1.3, + shadow: const Shadow(color: Colors.black54, blurRadius: 8), + ), + ], + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 56, + child: IconButton( + onPressed: onCollectTap, + iconSize: 34, + color: isCollect ? ConstColor.btGreen : Colors.white, + icon: Icon(isCollect ? Icons.favorite : Icons.favorite_border), + ), + ), + ], + ), + const SizedBox(height: 16), + _buildProgress(), + ], + ); + } + + Widget _buildProgress() { + final activeController = controller; + if (activeController != null && activeController.value.isInitialized) { + return VideoProgressIndicator( + activeController, + allowScrubbing: true, + padding: EdgeInsets.zero, + colors: VideoProgressColors( + playedColor: ConstColor.btGreen, + bufferedColor: Colors.white.withValues(alpha: 0.36), + backgroundColor: Colors.white.withValues(alpha: 0.18), + ), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + minHeight: 3, + value: 0, + color: ConstColor.btGreen, + backgroundColor: Colors.white.withValues(alpha: 0.18), + ), + ); + } +} + +class _BottomShade extends StatelessWidget { + const _BottomShade(); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.12), + Colors.black.withValues(alpha: 0.72), + ], + stops: const [0.42, 0.68, 1], + ), + ), + ), + ); + } +} + +class _PauseIndicator extends StatelessWidget { + const _PauseIndicator(); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.38), + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.28)), + ), + child: const Icon( + Icons.play_arrow_outlined, + color: Colors.white, + size: 42, + ), + ), + ), + ); + } +} + +String? resolveDramaVideoUrl(NyDramaModel drama) { + final infoUrl = drama.videoInfo?['video_url']?.toString().trim(); + if (infoUrl != null && infoUrl.isNotEmpty) return infoUrl; + + final videoUrl = drama.videoUrl?.trim(); + if (videoUrl != null && videoUrl.isNotEmpty) return videoUrl; + + return null; +} + +bool shouldShowPauseIcon(VideoPlayerValue? value) { + return value != null && value.isInitialized && !value.isPlaying; +} + +bool shouldPlayActiveVideo( + VideoPlayerValue? value, { + required bool isPageActive, +}) { + return isPageActive && + value != null && + value.isInitialized && + !value.isPlaying; +} diff --git a/lib/pages/widgets/ny_empty_view.dart b/lib/pages/widgets/ny_empty_view.dart new file mode 100644 index 0000000..39f3384 --- /dev/null +++ b/lib/pages/widgets/ny_empty_view.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:novyronst/common/const/const_color.dart'; +import 'package:novyronst/common/help/help_image.dart'; +import 'package:novyronst/tools/widgets/novy_com_widget.dart'; + +class NyEmptyView extends StatelessWidget { + + const NyEmptyView({super.key, + this.title = 'No Data', + // 'Nothing here yet.\nCome back later for more fun!', + // Something went wrong.\nPlease try again. + this.subtitle = 'Nothing here yet.\nCome back later for more fun!', + this.btnText = 'RETRY', + this.imgName, + }); + + final String title; + final String subtitle; + final String btnText; + final String? imgName; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 100), + HelpImage.asset(ConstImage.emptyImg, width: 136), + const SizedBox(height: 14), + // _customText(title), + // const SizedBox(height: 10), + NovyText( title, + fontSize: 16, + color: Colors.white.withAlpha(127), + textAlign: TextAlign.center, + ), + // const SizedBox(height: 14), + + ], + ), + ); + } + + _customText(String text) { + return Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.white, // 内部字体白色 + fontStyle: FontStyle.italic, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + shadows: [ + Shadow( + color: Colors.black, // 描边颜色 + offset: Offset(1, 1), // 偏移量 + blurRadius: 0, // 不模糊,形成硬边描边 + ), + Shadow( + color: Colors.black, + offset: Offset(-1, -1), + blurRadius: 0, + ), + Shadow( + color: Colors.black, + offset: Offset(1, -1), + blurRadius: 0, + ), + Shadow( + color: Colors.black, + offset: Offset(-1, 1), + blurRadius: 0, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/widgets/ny_tag_widget.dart b/lib/pages/widgets/ny_tag_widget.dart index 1b2ee6d..3cab449 100644 --- a/lib/pages/widgets/ny_tag_widget.dart +++ b/lib/pages/widgets/ny_tag_widget.dart @@ -36,9 +36,11 @@ class NyTagWidget extends StatelessWidget { class NyHotWidget extends StatelessWidget { const NyHotWidget({super.key, required this.text, + this.textColor, }); final String text; + final Color? textColor; @override Widget build(BuildContext context) { @@ -47,7 +49,7 @@ class NyHotWidget extends StatelessWidget { HelpImage.asset(ConstImage.hot, width: 12), NovyText(text, fontSize: 10, - color: Colors.black, + color: textColor ?? Colors.black, ) ], ); diff --git a/lib/root/root_page.dart b/lib/root/root_page.dart index 5a1f554..2142ab7 100644 --- a/lib/root/root_page.dart +++ b/lib/root/root_page.dart @@ -21,47 +21,51 @@ class RootPage extends StatefulWidget { } class _RootPageState extends State { - final _pageController = PageController(); - final List _pages = [ - NovyKeepAlive(child: FirstPage()), - NovyKeepAlive(child: PlayPage()), - CollectionPage(), - NovyKeepAlive(child: MyPage()), - ]; + int _currentIndex = 0; @override Widget build(BuildContext context) { + final pages = [ + NovyKeepAlive(child: FirstPage()), + NovyKeepAlive(child: PlayPage(isActive: _currentIndex == 1)), + CollectionPage(), + NovyKeepAlive(child: MyPage()), + ]; + return Scaffold( body: Stack( children: [ PageView( physics: const NeverScrollableScrollPhysics(), controller: _pageController, - children: _pages, + children: pages, ), Positioned( - left: 0, right: 0, bottom: 0, + left: 0, + right: 0, + bottom: 0, child: SafeArea( top: false, child: AppBottomView( onSelectedIndex: (index) { + setState(() { + _currentIndex = index; + }); _pageController.jumpToPage(index); }, ), ), - ) + ), ], - ) + ), ); } } /// 底部tabbar class AppBottomView extends StatefulWidget { - const AppBottomView({super.key, - required this.onSelectedIndex, - }); + const AppBottomView({super.key, required this.onSelectedIndex}); final Function(int) onSelectedIndex; @@ -72,14 +76,17 @@ class AppBottomView extends StatefulWidget { } class _AppBottomViewState extends State { - final List _unselectedIcons = [ - ConstImage.btHomeUnsel, ConstImage.btPlayUnsel, - ConstImage.btCollectUnsel, ConstImage.btMineUnsel + ConstImage.btHomeUnsel, + ConstImage.btPlayUnsel, + ConstImage.btCollectUnsel, + ConstImage.btMineUnsel, ]; final List _selectedIcons = [ - ConstImage.btHomeSel, ConstImage.btPlaySel, - ConstImage.btCollectSel, ConstImage.btMineSel + ConstImage.btHomeSel, + ConstImage.btPlaySel, + ConstImage.btCollectSel, + ConstImage.btMineSel, ]; final List _titles = ['Home', 'Play', 'Collect', 'Me']; int _currentIndex = 0; @@ -92,38 +99,45 @@ class _AppBottomViewState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Stack( children: [ - Opacity(opacity: 0.9, - child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 1), - child: HelpImage.asset(ConstImage.appBottom, - width: AppScreen.screenWidth-32, height: _height, + Opacity( + opacity: 0.9, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 1), + child: HelpImage.asset( + ConstImage.appBottom, + width: AppScreen.screenWidth - 32, + height: _height, fit: BoxFit.fill, ), ), ), Positioned.fill( - child: Padding(padding: const EdgeInsets.symmetric(horizontal: 10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: List.generate(_unselectedIcons.length, (index) { - return _buildTabItem(_titles[index], index, onTap: () { - setState(() { - _currentIndex = index; - }); - widget.onSelectedIndex(index); - - }); + return _buildTabItem( + _titles[index], + index, + onTap: () { + setState(() { + _currentIndex = index; + }); + widget.onSelectedIndex(index); + }, + ); }), ), ), - ) + ), ], ), ); } - _aaaa(){ - + _aaaa() { return ClipRRect( borderRadius: BorderRadius.circular(12), child: BackdropFilter( @@ -131,10 +145,7 @@ class _AppBottomViewState extends State { child: Container( color: Colors.white.withOpacity(0.1), child: const Center( - child: Text( - '毛玻璃效果', - style: TextStyle(color: Colors.white), - ), + child: Text('毛玻璃效果', style: TextStyle(color: Colors.white)), ), ), ), @@ -142,20 +153,23 @@ class _AppBottomViewState extends State { } Widget _buildTabItem(String title, int index, {required Function() onTap}) { - String icon = _currentIndex == index ? _selectedIcons[index] : _unselectedIcons[index]; - Color tcolor = _currentIndex == index ? ConstColor.btGreen : ConstColor.btWhite; + String icon = _currentIndex == index + ? _selectedIcons[index] + : _unselectedIcons[index]; + Color tcolor = _currentIndex == index + ? ConstColor.btGreen + : ConstColor.btWhite; return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - width: 60, height: 70, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + width: 60, + height: 70, child: Column( children: [ HelpImage.asset(icon, width: 32, height: 32), const SizedBox(height: 2), - NovyText(title, fontSize: 11, - color: tcolor, - ) + NovyText(title, fontSize: 11, color: tcolor), ], - ) + ), ).addInkWell(onTap: onTap); } -} \ No newline at end of file +}