371 lines
10 KiB
Dart
371 lines
10 KiB
Dart
|
|
import 'dart:async';
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
|
import 'package:forui/forui.dart';
|
||
|
|
|
||
|
|
import '../../core/contracts/library.dart';
|
||
|
|
import '../../core/domain/ports/emby_gateway.dart';
|
||
|
|
import '../../providers/di_providers.dart';
|
||
|
|
import '../../providers/session_provider.dart';
|
||
|
|
import '../../shared/mappers/media_image_url.dart';
|
||
|
|
import '../../shared/utils/format_utils.dart';
|
||
|
|
import '../../shared/utils/media_nav.dart';
|
||
|
|
import '../../shared/widgets/app_sliver_header.dart';
|
||
|
|
import '../../shared/widgets/app_loading_ring.dart';
|
||
|
|
import '../../shared/widgets/empty_state.dart';
|
||
|
|
import '../aggregated/aggregated_grouping.dart';
|
||
|
|
import '../aggregated/widgets/aggregated_grouped_grid.dart';
|
||
|
|
import '../aggregated/widgets/aggregated_media_card.dart';
|
||
|
|
import '../aggregated/widgets/server_filter_bar.dart';
|
||
|
|
|
||
|
|
const _favoriteTypeFilters = <_FavoriteTypeFilter>[
|
||
|
|
_FavoriteTypeFilter(includeItemTypes: 'Movie,Series,Episode', label: '全部'),
|
||
|
|
_FavoriteTypeFilter(includeItemTypes: 'Movie', label: '电影'),
|
||
|
|
_FavoriteTypeFilter(includeItemTypes: 'Series', label: '剧集'),
|
||
|
|
_FavoriteTypeFilter(includeItemTypes: 'Episode', label: '单集'),
|
||
|
|
];
|
||
|
|
|
||
|
|
class AggregatedFavoritesPage extends ConsumerStatefulWidget {
|
||
|
|
|
||
|
|
final bool embedded;
|
||
|
|
|
||
|
|
const AggregatedFavoritesPage({super.key, this.embedded = false});
|
||
|
|
|
||
|
|
@override
|
||
|
|
ConsumerState<AggregatedFavoritesPage> createState() =>
|
||
|
|
_AggregatedFavoritesPageState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _AggregatedFavoritesPageState
|
||
|
|
extends ConsumerState<AggregatedFavoritesPage> {
|
||
|
|
StreamSubscription<GlobalSearchServerResult>? _subscription;
|
||
|
|
|
||
|
|
_FavoriteTypeFilter _selectedFilter = _favoriteTypeFilters.first;
|
||
|
|
List<GlobalSearchServerResult> _serverResults = [];
|
||
|
|
bool _loading = false;
|
||
|
|
bool _switching = false;
|
||
|
|
final Map<String, bool> _favoriteLoadingMap = {};
|
||
|
|
|
||
|
|
final Set<String> _collapsed = {};
|
||
|
|
String? _serverFilter;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_load();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_subscription?.cancel();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _load() async {
|
||
|
|
_subscription?.cancel();
|
||
|
|
_subscription = null;
|
||
|
|
|
||
|
|
setState(() {
|
||
|
|
_loading = true;
|
||
|
|
_serverResults = [];
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
final uc = await ref.read(aggregatedFavoritesUseCaseProvider.future);
|
||
|
|
final stream = uc.executeStream(
|
||
|
|
includeItemTypes: _selectedFilter.includeItemTypes,
|
||
|
|
);
|
||
|
|
|
||
|
|
_subscription = stream.listen(
|
||
|
|
(result) {
|
||
|
|
if (mounted) {
|
||
|
|
setState(() => _serverResults = [..._serverResults, result]);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
onDone: () {
|
||
|
|
if (mounted) setState(() => _loading = false);
|
||
|
|
},
|
||
|
|
onError: (_) {
|
||
|
|
if (mounted) setState(() => _loading = false);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
} catch (_) {
|
||
|
|
if (mounted) setState(() => _loading = 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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _removeFavorite(
|
||
|
|
GlobalSearchServerResult serverResult,
|
||
|
|
EmbyRawItem item,
|
||
|
|
) async {
|
||
|
|
if (_favoriteLoadingMap[item.Id] == true) return;
|
||
|
|
setState(() => _favoriteLoadingMap[item.Id] = true);
|
||
|
|
try {
|
||
|
|
final gateway = await ref.read(embyGatewayProvider.future);
|
||
|
|
final ctx = AuthedRequestContext(
|
||
|
|
baseUrl: serverResult.serverUrl,
|
||
|
|
token: serverResult.token,
|
||
|
|
userId: serverResult.userId,
|
||
|
|
);
|
||
|
|
await gateway.setFavoriteStatus(
|
||
|
|
ctx: ctx,
|
||
|
|
itemId: item.Id,
|
||
|
|
isFavorite: false,
|
||
|
|
);
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
_serverResults = _serverResults
|
||
|
|
.map((sr) {
|
||
|
|
if (sr.serverId != serverResult.serverId) return sr;
|
||
|
|
final filtered = sr.items
|
||
|
|
.where((i) => i.Id != item.Id)
|
||
|
|
.toList();
|
||
|
|
return GlobalSearchServerResult(
|
||
|
|
serverId: sr.serverId,
|
||
|
|
serverName: sr.serverName,
|
||
|
|
serverUrl: sr.serverUrl,
|
||
|
|
userId: sr.userId,
|
||
|
|
token: sr.token,
|
||
|
|
items: filtered,
|
||
|
|
);
|
||
|
|
})
|
||
|
|
.where((sr) => sr.items.isNotEmpty)
|
||
|
|
.toList();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
if (mounted) setState(() => _favoriteLoadingMap[item.Id] = false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
List<FItem> _cardMenuItems(
|
||
|
|
GlobalSearchServerResult sr,
|
||
|
|
EmbyRawItem item,
|
||
|
|
) => [
|
||
|
|
FItem(
|
||
|
|
prefix: const Icon(Icons.favorite, size: 16),
|
||
|
|
enabled: !(_favoriteLoadingMap[item.Id] ?? false),
|
||
|
|
title: const Text('取消收藏'),
|
||
|
|
onPress: () => _removeFavorite(sr, item),
|
||
|
|
),
|
||
|
|
];
|
||
|
|
|
||
|
|
void _changeType(_FavoriteTypeFilter filter) {
|
||
|
|
if (_selectedFilter == filter) return;
|
||
|
|
setState(() => _selectedFilter = filter);
|
||
|
|
_load();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _toggle(String key) {
|
||
|
|
setState(() {
|
||
|
|
if (!_collapsed.remove(key)) _collapsed.add(key);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
ref.listen(sessionProvider, (previous, next) {
|
||
|
|
final prevIds =
|
||
|
|
previous?.value?.sessions.map((s) => s.serverId).toSet() ??
|
||
|
|
<String>{};
|
||
|
|
final nextIds =
|
||
|
|
next.value?.sessions.map((s) => s.serverId).toSet() ?? <String>{};
|
||
|
|
if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (_serverFilter != null && !nextIds.contains(_serverFilter)) {
|
||
|
|
setState(() => _serverFilter = null);
|
||
|
|
}
|
||
|
|
_load();
|
||
|
|
});
|
||
|
|
|
||
|
|
if (widget.embedded) {
|
||
|
|
return Stack(
|
||
|
|
fit: StackFit.expand,
|
||
|
|
children: [
|
||
|
|
CustomScrollView(
|
||
|
|
slivers: [
|
||
|
|
SliverOverlapInjector(
|
||
|
|
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||
|
|
context,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
SliverToBoxAdapter(child: _buildTypeFilterRow()),
|
||
|
|
..._buildContentSlivers(context),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
if (_switching) _switchingOverlay(),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return Stack(
|
||
|
|
fit: StackFit.expand,
|
||
|
|
children: [
|
||
|
|
Scaffold(
|
||
|
|
extendBodyBehindAppBar: true,
|
||
|
|
body: CustomScrollView(
|
||
|
|
slivers: [
|
||
|
|
AppSliverHeader(
|
||
|
|
title: '我的收藏',
|
||
|
|
actions: [
|
||
|
|
IconButton(
|
||
|
|
onPressed: _loading ? null : _load,
|
||
|
|
icon: _loading
|
||
|
|
? const AppLoadingRing(size: 16)
|
||
|
|
: const Icon(Icons.refresh, size: 18),
|
||
|
|
tooltip: '刷新',
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
SliverToBoxAdapter(child: _buildTypeFilterRow()),
|
||
|
|
..._buildContentSlivers(context),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (_switching) _switchingOverlay(),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _switchingOverlay() => const Positioned.fill(
|
||
|
|
child: ColoredBox(
|
||
|
|
color: Color(0x44000000),
|
||
|
|
child: Center(child: AppLoadingRing()),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
Widget _buildTypeFilterRow() => Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 8),
|
||
|
|
child: Align(
|
||
|
|
alignment: Alignment.centerLeft,
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
scrollDirection: Axis.horizontal,
|
||
|
|
child: Row(
|
||
|
|
children: _favoriteTypeFilters.map(_buildTypeButton).toList(),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
Widget _buildTypeButton(_FavoriteTypeFilter filter) {
|
||
|
|
final selected = _selectedFilter == filter;
|
||
|
|
final child = Text(filter.label);
|
||
|
|
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.only(right: 8),
|
||
|
|
child: FButton(
|
||
|
|
variant: selected ? FButtonVariant.primary : FButtonVariant.ghost,
|
||
|
|
onPress: () => _changeType(filter),
|
||
|
|
child: child,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
List<Widget> _buildContentSlivers(BuildContext context) {
|
||
|
|
if (_serverResults.isEmpty && !_loading) {
|
||
|
|
return const [
|
||
|
|
SliverFillRemaining(
|
||
|
|
hasScrollBody: false,
|
||
|
|
child: EmptyState(icon: Icons.favorite_border, title: '暂无收藏内容'),
|
||
|
|
),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
if (_serverResults.isEmpty && _loading) {
|
||
|
|
return const [
|
||
|
|
SliverFillRemaining(
|
||
|
|
hasScrollBody: false,
|
||
|
|
child: Center(child: AppLoadingRing()),
|
||
|
|
),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
final now = DateTime.now();
|
||
|
|
final entries = flatten(
|
||
|
|
_serverResults,
|
||
|
|
serverFilter: _serverFilter,
|
||
|
|
deduplicateAcrossServers: true,
|
||
|
|
);
|
||
|
|
final groups = groupEntries(entries, GroupAxis.mediaType, now);
|
||
|
|
final servers = _serverResults
|
||
|
|
.map((sr) => (id: sr.serverId, name: sr.serverName))
|
||
|
|
.toList(growable: false);
|
||
|
|
|
||
|
|
return [
|
||
|
|
SliverToBoxAdapter(
|
||
|
|
child: ServerFilterBar(
|
||
|
|
servers: servers,
|
||
|
|
selected: _serverFilter,
|
||
|
|
onSelect: (v) => setState(() => _serverFilter = v),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
...buildAggregatedGridSlivers(
|
||
|
|
groups: groups,
|
||
|
|
collapsed: _collapsed,
|
||
|
|
onToggle: _toggle,
|
||
|
|
cardBuilder: (entry) => _card(context, entry),
|
||
|
|
),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _card(BuildContext context, AggregatedEntry entry) {
|
||
|
|
final sr = entry.source;
|
||
|
|
final item = entry.item;
|
||
|
|
final episode = formatEpisodeNumber(item.IndexNumber);
|
||
|
|
final subtitle = item.Type == 'Episode' && episode != null
|
||
|
|
? '${item.SeriesName ?? ''} $episode'.trim()
|
||
|
|
: (item.ProductionYear != null ? '${item.ProductionYear}' : null);
|
||
|
|
|
||
|
|
return AggregatedMediaCard(
|
||
|
|
item: item,
|
||
|
|
imageUrl: EmbyImageUrl.landscapeCard(
|
||
|
|
baseUrl: sr.serverUrl,
|
||
|
|
token: sr.token,
|
||
|
|
item: item,
|
||
|
|
maxWidth: 400,
|
||
|
|
),
|
||
|
|
imageHeaders: buildEmbyImageHeaders(
|
||
|
|
ref,
|
||
|
|
token: sr.token,
|
||
|
|
userId: sr.userId,
|
||
|
|
),
|
||
|
|
title: item.SeriesName ?? item.Name,
|
||
|
|
subtitle: subtitle,
|
||
|
|
serverName: sr.serverName,
|
||
|
|
showProgress: false,
|
||
|
|
onTap: () => _onItemTap(context, sr, item),
|
||
|
|
contextMenuItems: _cardMenuItems(sr, item),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _FavoriteTypeFilter {
|
||
|
|
final String includeItemTypes;
|
||
|
|
final String label;
|
||
|
|
|
||
|
|
const _FavoriteTypeFilter({
|
||
|
|
required this.includeItemTypes,
|
||
|
|
required this.label,
|
||
|
|
});
|
||
|
|
}
|