import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import 'package:go_router/go_router.dart'; import '../../core/contracts/library.dart'; import '../../providers/di_providers.dart'; import '../../providers/session_provider.dart'; import '../../shared/layout/adaptive_card_grid.dart'; import '../../shared/mappers/media_image_url.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/utils/media_nav.dart'; import '../../shared/constants/layout_constants.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/app_card.dart'; import '../../shared/widgets/app_skeleton.dart'; import '../../shared/widgets/app_sliver_header.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/media_poster_card.dart'; import '../../shared/constants/breakpoints.dart'; import '../../shared/widgets/page_background.dart'; import '../../shared/widgets/section_header.dart'; import '../../shared/widgets/sidebar.dart'; import '../../shared/widgets/warm_gradient_background.dart'; const _kBranchRootLocations = { 0: '/', 1: '/servers', 2: '/aggregated', 3: '/discover', 4: '/calendar', }; const _kTypeFilters = { 'Movie': '电影', 'Series': '剧', 'Episode': '集', 'Video': '视频', 'Channel': '频道', 'BoxSet': '合集', 'Person': '演职人员', }; class GlobalSearchPage extends ConsumerStatefulWidget { final bool currentServerOnly; const GlobalSearchPage({super.key, this.currentServerOnly = false}); @override ConsumerState createState() => _GlobalSearchPageState(); } class _GlobalSearchPageState extends ConsumerState { final _controller = TextEditingController(); StreamSubscription? _subscription; List _serverResults = []; String _activeQuery = ''; List _history = const []; final Set _selectedTypes = {'Movie', 'Series'}; bool _searching = false; bool _switching = false; @override void initState() { super.initState(); _loadHistory(); } @override void dispose() { _subscription?.cancel(); _controller.dispose(); super.dispose(); } Future _loadHistory() async { final store = ref.read(searchHistoryStoreProvider); final list = await store.list(); if (mounted) setState(() => _history = list); } void _onValueChanged(TextEditingValue value) { if (value.text.trim().isEmpty && _activeQuery.isNotEmpty) { _subscription?.cancel(); setState(() { _activeQuery = ''; _serverResults = []; _searching = false; }); } } void _toggleType(String type) { setState(() { if (_selectedTypes.contains(type)) { if (_selectedTypes.length > 1) _selectedTypes.remove(type); } else { _selectedTypes.add(type); } }); if (_activeQuery.isNotEmpty) { _search(_activeQuery); } } String get _typesParam => _selectedTypes.join(','); Future _search(String keyword) async { final k = keyword.trim(); _subscription?.cancel(); _subscription = null; if (k.isEmpty) { setState(() { _activeQuery = ''; _serverResults = []; _searching = false; }); return; } setState(() { _activeQuery = k; _searching = true; _serverResults = []; }); try { final uc = await ref.read(globalKeywordSearchUseCaseProvider.future); String? serverId; if (widget.currentServerOnly) { serverId = ref.read(sessionProvider).value?.activeServerId; } final stream = uc.executeStream( GlobalSearchReq( keyword: k, includeItemTypes: _typesParam, serverId: serverId, ), ); _subscription = stream.listen( (result) { if (mounted) { setState(() => _serverResults = [..._serverResults, result]); } }, onDone: () async { if (!mounted) return; setState(() => _searching = false); if (_serverResults.isNotEmpty) { await ref.read(searchHistoryStoreProvider).add(k); await _loadHistory(); } }, onError: (_) { if (mounted) setState(() => _searching = false); }, ); } catch (_) { if (mounted) setState(() => _searching = false); } } Future _onItemTap( BuildContext context, GlobalSearchServerResult serverResult, EmbyRawItem item, ) async { if (_switching) return; setState(() => _switching = true); try { await goMediaDetailOnServer( context, ref, serverId: serverResult.serverId, item: item, ); } finally { if (mounted) setState(() => _switching = false); } } @override Widget build(BuildContext context) { final sessions = ref.watch(sessionProvider).value; final activeServerId = sessions?.activeServerId; final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; final canPop = Navigator.of(context).canPop(); final searchScaffold = Scaffold( backgroundColor: Colors.transparent, extendBodyBehindAppBar: true, body: CustomScrollView( slivers: [ AppSliverHeader( title: '搜索', automaticallyImplyLeading: canPop || isCompact, ), SliverToBoxAdapter(child: _buildSearchBar()), SliverToBoxAdapter(child: _buildTypeFilters()), ..._buildBodySlivers(context, activeServerId), ], ), ); final body = isCompact ? searchScaffold : Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Sidebar( selectedBranchIndex: -1, onNavigateBranch: (index, {initialLocation = false}) { final root = _kBranchRootLocations[index]; if (root != null) context.go(root); }, location: '/global-search', ), Expanded(child: searchScaffold), ], ); return Stack( fit: StackFit.expand, children: [ Positioned.fill( child: isCompact ? const WarmGradientBackground() : const PageBackground(), ), body, if (_switching) const Positioned.fill( child: ColoredBox( color: Color(0x44000000), child: Center(child: AppLoadingRing()), ), ), ], ); } Widget _buildSearchBar() { final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; final horizontalPadding = isCompact ? 16.0 : 24.0; return Padding( padding: EdgeInsets.fromLTRB(horizontalPadding, 12, horizontalPadding, 4), child: FTextField( control: FTextFieldControl.managed( controller: _controller, onChange: _onValueChanged, ), hint: widget.currentServerOnly ? '搜索当前服务器媒体...' : '在所有服务器中搜索...', autofocus: true, prefixBuilder: (ctx, style, variants) => FTextField.prefixIconBuilder( ctx, style, variants, const Icon(Icons.search)), clearable: (v) => v.text.isNotEmpty, onSubmit: _search, ), ); } Widget _buildTypeFilters() { final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; final horizontalPadding = isCompact ? 16.0 : 24.0; return Padding( padding: EdgeInsets.fromLTRB(horizontalPadding, 6, horizontalPadding, 4), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (final entry in _kTypeFilters.entries) Padding( padding: const EdgeInsets.only(right: 8), child: FButton( variant: _selectedTypes.contains(entry.key) ? FButtonVariant.primary : FButtonVariant.outline, size: FButtonSizeVariant.sm, onPress: () => _toggleType(entry.key), child: Text(entry.value), ), ), ], ), ), ); } List _buildBodySlivers(BuildContext context, String? activeServerId) { if (_activeQuery.isEmpty) { return _buildHistorySlivers(); } if (_serverResults.isEmpty && !_searching) { return const [ SliverFillRemaining( hasScrollBody: false, child: EmptyState(icon: Icons.search_off, title: '没有匹配的结果'), ), ]; } if (_serverResults.isEmpty && _searching) { return [ SliverToBoxAdapter( child: SkeletonGrid( itemCount: 8, itemHeight: 240, minCardWidth: kPosterCardMinWidth, padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), ), ), ]; } final slivers = []; for (final sr in _serverResults) { slivers.addAll( _buildServerSlivers(context, sr, sr.serverId == activeServerId), ); } slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 24))); return slivers; } List _buildServerSlivers( BuildContext context, GlobalSearchServerResult serverResult, bool isActive, ) { final tokens = context.appTokens; final headers = buildEmbyImageHeaders( ref, token: serverResult.token, userId: serverResult.userId, ); return [ if (!widget.currentServerOnly) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(24, 12, 24, 0), child: SectionHeader( label: serverResult.serverName, count: serverResult.items.length, ), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), sliver: SliverGrid( gridDelegate: mediaGridDelegate, delegate: SliverChildBuilderDelegate((_, j) { final item = serverResult.items[j]; return AppCard( filled: false, radius: tokens.radiusCard, padding: EdgeInsets.zero, child: MediaPosterCard( item: item, width: 132, imageUrl: EmbyImageUrl.primary( baseUrl: serverResult.serverUrl, token: serverResult.token, item: item, maxHeight: 300, ), imageHeaders: headers, onTap: () => _onItemTap(context, serverResult, item), ), ); }, childCount: serverResult.items.length), ), ), ]; } List _buildHistorySlivers() { if (_history.isEmpty) { return [ SliverFillRemaining( hasScrollBody: false, child: EmptyState( icon: Icons.search, title: widget.currentServerOnly ? '搜索当前服务器中的内容' : '搜索所有服务器中的内容', ), ), ]; } final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; final horizontalPadding = isCompact ? 16.0 : 24.0; final content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SectionHeader( label: '搜索历史', trailing: FButton( variant: FButtonVariant.ghost, size: FButtonSizeVariant.sm, onPress: () async { await ref.read(searchHistoryStoreProvider).clear(); _loadHistory(); }, prefix: const Icon(Icons.delete_outline, size: 16), child: const Text('清空'), ), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: _history .map( (kw) => FButton( variant: FButtonVariant.outline, size: FButtonSizeVariant.sm, mainAxisSize: MainAxisSize.min, onPress: () { _controller.text = kw; _controller.selection = TextSelection.collapsed( offset: kw.length, ); _search(kw); }, suffix: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { await ref.read(searchHistoryStoreProvider).remove(kw); _loadHistory(); }, child: const Icon(Icons.close, size: 14), ), child: Text(kw), ), ) .toList(), ), ], ); return [ SliverPadding( padding: EdgeInsets.fromLTRB( horizontalPadding, 16, horizontalPadding, 24), sliver: SliverList( delegate: SliverChildListDelegate([ if (isCompact) content else AppCard( filled: true, enableHover: false, padding: const EdgeInsets.all(16), child: content, ), ]), ), ), ]; } }