317 lines
8.8 KiB
Dart
317 lines
8.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../core/contracts/library.dart';
|
|
import '../../providers/di_providers.dart';
|
|
import '../../providers/emby_headers_provider.dart';
|
|
import '../../providers/library_overview_provider.dart';
|
|
import '../../providers/session_provider.dart';
|
|
import '../../providers/shell_backdrop_provider.dart';
|
|
import '../../shared/constants/layout_constants.dart';
|
|
import '../../shared/mappers/media_image_url.dart';
|
|
import '../../shared/utils/user_error_formatter.dart';
|
|
import '../../shared/widgets/app_loading_ring.dart';
|
|
import '../../shared/widgets/app_error_state.dart';
|
|
import '../../shared/utils/media_nav.dart';
|
|
import '../../shared/widgets/app_sliver_header.dart';
|
|
import '../../shared/widgets/empty_state.dart';
|
|
import '../../shared/widgets/media_poster_card.dart';
|
|
|
|
const _kPageSize = 20;
|
|
const _kScrollThreshold = 200.0;
|
|
const _kFields =
|
|
'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,'
|
|
'Overview,UserDataLastPlayedDate,OfficialRating,Genres,Status,'
|
|
'People,Studios,ProviderIds';
|
|
const _kImageTypes = 'Primary,Backdrop,Thumb,Logo';
|
|
const _kIncludeTypes = 'Series,Movie,Video,MusicVideo';
|
|
const _kCollectionIncludeTypes = 'BoxSet,Folder,Series,Movie,Video,MusicVideo';
|
|
const _kSortBy = 'DateLastContentAdded,DateCreated,SortName';
|
|
|
|
class LibraryListPage extends ConsumerStatefulWidget {
|
|
final String libraryId;
|
|
|
|
|
|
final String? title;
|
|
|
|
|
|
final bool isCollection;
|
|
|
|
const LibraryListPage({
|
|
super.key,
|
|
required this.libraryId,
|
|
this.title,
|
|
this.isCollection = false,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<LibraryListPage> createState() => _LibraryListPageState();
|
|
}
|
|
|
|
class _LibraryListPageState extends ConsumerState<LibraryListPage> {
|
|
final List<EmbyRawItem> _items = [];
|
|
int _startIndex = 0;
|
|
bool _hasMore = true;
|
|
bool _isFirstLoad = true;
|
|
bool _isLoadingMore = false;
|
|
String? _loadError;
|
|
|
|
final _scrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fetchPage();
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(LibraryListPage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.libraryId != widget.libraryId) {
|
|
_resetAndFetch();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients) return;
|
|
final remaining =
|
|
_scrollController.position.maxScrollExtent -
|
|
_scrollController.position.pixels;
|
|
if (remaining <= _kScrollThreshold && _hasMore && !_isLoadingMore) {
|
|
_fetchPage();
|
|
}
|
|
}
|
|
|
|
void _resetAndFetch() {
|
|
setState(() {
|
|
_items.clear();
|
|
_startIndex = 0;
|
|
_hasMore = true;
|
|
_isFirstLoad = true;
|
|
_isLoadingMore = false;
|
|
_loadError = null;
|
|
});
|
|
_fetchPage();
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
setState(() {
|
|
_items.clear();
|
|
_startIndex = 0;
|
|
_hasMore = true;
|
|
_isFirstLoad = true;
|
|
_isLoadingMore = false;
|
|
_loadError = null;
|
|
});
|
|
await _fetchPage();
|
|
}
|
|
|
|
Future<void> _fetchPage() async {
|
|
if (_isLoadingMore || !_hasMore) return;
|
|
setState(() {
|
|
_isLoadingMore = true;
|
|
_loadError = null;
|
|
});
|
|
try {
|
|
final uc = await ref.read(libraryItemsUseCaseProvider.future);
|
|
final res = await uc.execute(
|
|
LibraryItemsReq(
|
|
parentId: widget.libraryId,
|
|
includeItemTypes: widget.isCollection
|
|
? _kCollectionIncludeTypes
|
|
: _kIncludeTypes,
|
|
sortBy: _kSortBy,
|
|
sortOrder: SortOrder.descending,
|
|
fields: _kFields,
|
|
enableImageTypes: _kImageTypes,
|
|
startIndex: _startIndex,
|
|
limit: _kPageSize,
|
|
recursive: !widget.isCollection,
|
|
),
|
|
);
|
|
if (!mounted) return;
|
|
final fetched = res.items;
|
|
setState(() {
|
|
_items.addAll(fetched);
|
|
_startIndex += fetched.length;
|
|
_hasMore = fetched.length == _kPageSize;
|
|
_isFirstLoad = false;
|
|
_isLoadingMore = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_loadError = e.toString();
|
|
_isFirstLoad = false;
|
|
_isLoadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final session = ref.watch(activeSessionProvider);
|
|
if (session == null) return const SizedBox.shrink();
|
|
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
|
final hasBackdrop = ref.watch(
|
|
shellBackdropProvider.select((state) => state != null),
|
|
);
|
|
final immersiveTextColor = hasBackdrop ? Colors.white : null;
|
|
final libraryName =
|
|
ref
|
|
.watch(libraryOverviewProvider)
|
|
.value
|
|
?.libraries
|
|
.where((l) => l.Id == widget.libraryId)
|
|
.firstOrNull
|
|
?.Name ??
|
|
widget.title ??
|
|
'';
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
extendBodyBehindAppBar: true,
|
|
body: RefreshIndicator(
|
|
onRefresh: _refresh,
|
|
child: CustomScrollView(
|
|
controller: _scrollController,
|
|
physics: const AlwaysScrollableScrollPhysics(
|
|
parent: BouncingScrollPhysics(),
|
|
),
|
|
slivers: [
|
|
AppSliverHeader(
|
|
title: libraryName,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
),
|
|
..._buildContentSlivers(
|
|
session,
|
|
imageHeaders,
|
|
immersiveTextColor: immersiveTextColor,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildContentSlivers(
|
|
dynamic session,
|
|
Map<String, String>? imageHeaders, {
|
|
required Color? immersiveTextColor,
|
|
}) {
|
|
if (_isFirstLoad) {
|
|
if (_loadError != null) {
|
|
return [
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: AppErrorState(
|
|
message: formatUserError(_loadError, fallback: '媒体列表加载失败'),
|
|
foregroundColor: immersiveTextColor,
|
|
onRetry: _resetAndFetch,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
return const [
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: Center(child: AppLoadingRing()),
|
|
),
|
|
];
|
|
}
|
|
|
|
if (_items.isEmpty && !_isLoadingMore && _loadError != null) {
|
|
return [
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: AppErrorState(
|
|
message: formatUserError(_loadError, fallback: '媒体列表加载失败'),
|
|
foregroundColor: immersiveTextColor,
|
|
onRetry: _resetAndFetch,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
if (_items.isEmpty && !_isLoadingMore) {
|
|
return [
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: EmptyState(
|
|
icon: Icons.inbox_outlined,
|
|
title: '暂无媒体内容',
|
|
foregroundColor: immersiveTextColor,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
return [
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
sliver: SliverGrid(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(_, i) => MediaPosterCard(
|
|
item: _items[i],
|
|
width: double.infinity,
|
|
textColor: immersiveTextColor,
|
|
imageUrl: EmbyImageUrl.primary(
|
|
baseUrl: session.serverUrl,
|
|
token: session.token,
|
|
item: _items[i],
|
|
maxHeight: 360,
|
|
),
|
|
preloadImageUrl: EmbyImageUrl.detailBanner(
|
|
baseUrl: session.serverUrl,
|
|
token: session.token,
|
|
item: _items[i],
|
|
),
|
|
imageHeaders: imageHeaders,
|
|
onTap: () => goMediaDetail(context, _items[i]),
|
|
),
|
|
childCount: _items.length,
|
|
),
|
|
gridDelegate: mediaGridDelegate,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: _buildFooter(immersiveTextColor: immersiveTextColor),
|
|
),
|
|
];
|
|
}
|
|
|
|
Widget _buildFooter({required Color? immersiveTextColor}) {
|
|
if (_isLoadingMore) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Center(child: AppLoadingRing()),
|
|
);
|
|
}
|
|
if (!_hasMore && _items.isNotEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
|
child: Center(
|
|
child: Text(
|
|
'没有更多了',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: immersiveTextColor?.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
}
|