Files
sm-emby-share/lib/features/search/global_search_page.dart
T
2026-07-14 11:11:36 +08:00

471 lines
13 KiB
Dart

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 = <int, String>{
0: '/',
1: '/servers',
2: '/aggregated',
3: '/discover',
4: '/calendar',
};
const _kTypeFilters = <String, String>{
'Movie': '电影',
'Series': '',
'Episode': '',
'Video': '视频',
'Channel': '频道',
'BoxSet': '合集',
'Person': '演职人员',
};
class GlobalSearchPage extends ConsumerStatefulWidget {
final bool currentServerOnly;
const GlobalSearchPage({super.key, this.currentServerOnly = false});
@override
ConsumerState<GlobalSearchPage> createState() => _GlobalSearchPageState();
}
class _GlobalSearchPageState extends ConsumerState<GlobalSearchPage> {
final _controller = TextEditingController();
StreamSubscription<GlobalSearchServerResult>? _subscription;
List<GlobalSearchServerResult> _serverResults = [];
String _activeQuery = '';
List<String> _history = const [];
final Set<String> _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<void> _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<void> _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<void> _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<Widget> _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 = <Widget>[];
for (final sr in _serverResults) {
slivers.addAll(
_buildServerSlivers(context, sr, sr.serverId == activeServerId),
);
}
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 24)));
return slivers;
}
List<Widget> _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<Widget> _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,
),
]),
),
),
];
}
}