Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+316
View File
@@ -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();
}
}