Initial commit
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
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,
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user