471 lines
13 KiB
Dart
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,
|
||
|
|
),
|
||
|
|
]),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|