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