Initial commit
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
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/auth.dart';
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/emby_headers_provider.dart';
|
||||
import '../../providers/home_banner_provider.dart';
|
||||
import '../../providers/home_page_mode_provider.dart';
|
||||
import '../../providers/library_overview_provider.dart';
|
||||
import '../../providers/playback_active_provider.dart';
|
||||
import '../../providers/server_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/shell_backdrop_provider.dart';
|
||||
import '../../providers/tmdb_prefetch_provider.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/color_utils.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/add_server_modal.dart';
|
||||
import '../../shared/widgets/app_dialog.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/error_media_row.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/widgets/frosted_panel.dart';
|
||||
import '../../shared/widgets/latest_media_row.dart';
|
||||
import '../../shared/widgets/login_form.dart';
|
||||
import '../../shared/widgets/media_poster_card.dart';
|
||||
import '../../shared/constants/breakpoints.dart';
|
||||
import '../../shared/widgets/scroll_fade_header.dart';
|
||||
import '../../shared/widgets/skeleton_media_row.dart';
|
||||
import '../discover/discover_page.dart';
|
||||
import 'widgets/banner_carousel.dart';
|
||||
import 'widgets/server_dropdown_button.dart';
|
||||
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen<AuthedSession?>(activeSessionProvider, (previous, next) {
|
||||
if (previous != null && next == null) {
|
||||
ref.read(shellBackdropProvider.notifier).clear();
|
||||
}
|
||||
});
|
||||
|
||||
final sessionAsync = ref.watch(sessionProvider);
|
||||
if (sessionAsync.isLoading && !sessionAsync.hasValue) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const _UnauthenticatedHome();
|
||||
return _AuthenticatedHome(session: session);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnauthenticatedHome extends ConsumerWidget {
|
||||
const _UnauthenticatedHome();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final servers = ref.watch(serverListProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: servers.when(
|
||||
data: (list) {
|
||||
final availableList = list.where((s) => !s.paused).toList();
|
||||
|
||||
if (list.isEmpty || availableList.isEmpty) {
|
||||
return Center(
|
||||
child: EmptyState(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: list.isEmpty ? '尚未添加 Emby 服务器' : '所有服务器已暂停',
|
||||
message: list.isEmpty
|
||||
? '添加第一个服务器,开始构建你的影库。'
|
||||
: '前往「服务器」页恢复使用,或添加新服务器。',
|
||||
action: FButton(
|
||||
variant: FButtonVariant.primary,
|
||||
onPress: () => showAddServerModal(context),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('添加服务器'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
child: FrostedPanel(
|
||||
radius: 32,
|
||||
padding: const EdgeInsets.all(28),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withValues(alpha: 0.72),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.22),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'选择服务器登录',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'所有内容都会以当前服务器为中心进行同步与推荐。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
...availableList.map(
|
||||
(server) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: FCard(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.dns_outlined),
|
||||
title: Text(server.name),
|
||||
subtitle: Text(server.baseUrl),
|
||||
onTap: () async {
|
||||
await showAppRawDialog<void>(
|
||||
context,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 460,
|
||||
),
|
||||
builder: (_) => Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: LoginForm(
|
||||
server: server,
|
||||
onBack: () => Navigator.pop(context),
|
||||
onSuccess: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => showAddServerModal(context),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('添加新服务器'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: AppLoadingRing()),
|
||||
error: (error, _) => AppErrorState(
|
||||
message: formatUserError(error, fallback: '登录态加载失败'),
|
||||
onRetry: () => ref.invalidate(sessionProvider),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthenticatedHome extends ConsumerStatefulWidget {
|
||||
final AuthedSession session;
|
||||
|
||||
const _AuthenticatedHome({required this.session});
|
||||
|
||||
@override
|
||||
ConsumerState<_AuthenticatedHome> createState() => _AuthenticatedHomeState();
|
||||
}
|
||||
|
||||
class _AuthenticatedHomeState extends ConsumerState<_AuthenticatedHome> {
|
||||
final Map<String, bool> _favoriteMap = {};
|
||||
final Map<String, bool> _playedMap = {};
|
||||
final Map<String, bool> _favoriteLoadingMap = {};
|
||||
final Map<String, bool> _playedLoadingMap = {};
|
||||
final Map<String, bool> _hideResumeLoadingMap = {};
|
||||
final Set<String> _hiddenResumeIds = {};
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ValueNotifier<double> _scrollProgress = ValueNotifier<double>(0.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
_scrollController.dispose();
|
||||
_scrollProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
const threshold = 120.0;
|
||||
final progress = (_scrollController.offset / threshold).clamp(0.0, 1.0);
|
||||
_scrollProgress.value = progress;
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(EmbyRawItem item) async {
|
||||
if (_favoriteLoadingMap[item.Id] == true) return;
|
||||
final next = !(_favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false);
|
||||
setState(() => _favoriteLoadingMap[item.Id] = true);
|
||||
try {
|
||||
final useCase = await ref.read(setFavoriteUseCaseProvider.future);
|
||||
final result = await useCase.execute(
|
||||
FavoriteSetReq(itemId: item.Id, isFavorite: next),
|
||||
);
|
||||
setState(() => _favoriteMap[item.Id] = result.isFavorite);
|
||||
} finally {
|
||||
setState(() => _favoriteLoadingMap[item.Id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePlayed(EmbyRawItem item) async {
|
||||
if (_playedLoadingMap[item.Id] == true) return;
|
||||
final next = !(_playedMap[item.Id] ?? item.UserData?.Played ?? false);
|
||||
setState(() => _playedLoadingMap[item.Id] = true);
|
||||
try {
|
||||
final useCase = await ref.read(setPlayedUseCaseProvider.future);
|
||||
final result = await useCase.execute(
|
||||
PlayedSetReq(itemId: item.Id, played: next),
|
||||
);
|
||||
setState(() => _playedMap[item.Id] = result.played);
|
||||
unawaited(_syncTraktPlayedChange(item, result.played));
|
||||
} finally {
|
||||
setState(() => _playedLoadingMap[item.Id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTraktPlayedChange(EmbyRawItem item, bool played) async {
|
||||
try {
|
||||
final service = await ref.read(traktSyncServiceProvider.future);
|
||||
await service.syncEmbyPlayedChange(item: item, played: played);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _hideFromResume(EmbyRawItem item) async {
|
||||
if (_hideResumeLoadingMap[item.Id] == true) return;
|
||||
setState(() {
|
||||
_hideResumeLoadingMap[item.Id] = true;
|
||||
_hiddenResumeIds.add(item.Id);
|
||||
});
|
||||
try {
|
||||
final useCase = await ref.read(hideFromResumeUseCaseProvider.future);
|
||||
final result = await useCase.execute(
|
||||
HideFromResumeReq(itemId: item.Id, hide: true),
|
||||
);
|
||||
if (!result.hide) {
|
||||
setState(() => _hiddenResumeIds.remove(item.Id));
|
||||
}
|
||||
} catch (_) {
|
||||
setState(() => _hiddenResumeIds.remove(item.Id));
|
||||
} finally {
|
||||
setState(() => _hideResumeLoadingMap[item.Id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _resolveFavorite(EmbyRawItem item) {
|
||||
return _favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false;
|
||||
}
|
||||
|
||||
bool _resolvePlayed(EmbyRawItem item) {
|
||||
return _playedMap[item.Id] ?? item.UserData?.Played ?? false;
|
||||
}
|
||||
|
||||
Map<String, bool> _buildFavoriteMap(List<EmbyRawItem> items) {
|
||||
return {for (final item in items) item.Id: _resolveFavorite(item)};
|
||||
}
|
||||
|
||||
Map<String, bool> _buildPlayedMap(List<EmbyRawItem> items) {
|
||||
return {for (final item in items) item.Id: _resolvePlayed(item)};
|
||||
}
|
||||
|
||||
void _openMediaDetail(EmbyRawItem item) {
|
||||
goMediaDetail(context, item, ref: ref);
|
||||
}
|
||||
|
||||
EmbyRawItem? _lastBannerItem;
|
||||
|
||||
bool _isHomeForeground() {
|
||||
return ref.read(shellLocationProvider) == '/' &&
|
||||
!ref.read(detailPageActiveProvider);
|
||||
}
|
||||
|
||||
void _updateShellBackdrop(EmbyRawItem item) {
|
||||
_lastBannerItem = item;
|
||||
if (!_isHomeForeground()) return;
|
||||
|
||||
final backdropUrl = EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
);
|
||||
final fallbackId = (item.SeriesId?.isNotEmpty ?? false)
|
||||
? item.SeriesId!
|
||||
: item.Id;
|
||||
final fallbackUrls = <String>{
|
||||
EmbyImageUrl.fanart(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
itemId: fallbackId,
|
||||
),
|
||||
backdropUrl,
|
||||
}.toList();
|
||||
final accentColor = _accentColorFor(item.Id);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isHomeForeground()) return;
|
||||
ref
|
||||
.read(shellBackdropProvider.notifier)
|
||||
.show(
|
||||
ShellBackdropState(
|
||||
primaryImageUrl: backdropUrl,
|
||||
fallbackImageUrls: fallbackUrls,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearShellBackdropDeferred() {
|
||||
_lastBannerItem = null;
|
||||
if (ref.read(shellBackdropProvider) == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isHomeForeground()) return;
|
||||
ref.read(shellBackdropProvider.notifier).clear();
|
||||
});
|
||||
}
|
||||
|
||||
Color _accentColorFor(String seed) => accentColorForSeed(
|
||||
seed,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
|
||||
final mode = ref.watch(homePageModeProvider);
|
||||
if (isCompact && mode == HomePageMode.recommend) {
|
||||
return const DiscoverPage(embedInHome: true);
|
||||
}
|
||||
|
||||
final state = ref.watch(libraryOverviewProvider);
|
||||
final bannerAsync = ref.watch(homeBannerProvider);
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final homeForeground =
|
||||
ref.watch(shellLocationProvider) == '/' &&
|
||||
!ref.watch(detailPageActiveProvider);
|
||||
ref.watch(tmdbPrefetchProvider);
|
||||
|
||||
ref.listen<String>(shellLocationProvider, (_, loc) {
|
||||
if (loc != '/') return;
|
||||
if (ref.read(detailPageActiveProvider)) return;
|
||||
if (_lastBannerItem != null) {
|
||||
_updateShellBackdrop(_lastBannerItem!);
|
||||
} else {
|
||||
_clearShellBackdropDeferred();
|
||||
}
|
||||
});
|
||||
ref.listen<bool>(detailPageActiveProvider, (_, active) {
|
||||
if (active) return;
|
||||
if (ref.read(shellLocationProvider) != '/') return;
|
||||
ref.read(libraryOverviewProvider.notifier).softRefresh();
|
||||
if (_lastBannerItem != null) {
|
||||
_updateShellBackdrop(_lastBannerItem!);
|
||||
} else {
|
||||
_clearShellBackdropDeferred();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: state.when(
|
||||
data: (data) {
|
||||
if (data.libraries.isEmpty) {
|
||||
_clearShellBackdropDeferred();
|
||||
return const Center(
|
||||
child: EmptyState(
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: '没有可用的媒体库',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final resumeItems = data.resumeItems
|
||||
.where((item) => !_hiddenResumeIds.contains(item.Id))
|
||||
.toList();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: () => Future.wait([
|
||||
ref.read(libraryOverviewProvider.notifier).softRefresh(),
|
||||
ref.read(homeBannerProvider.notifier).softRefresh(),
|
||||
]),
|
||||
displacement: 72,
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.only(top: isCompact ? 0 : 60, bottom: 32),
|
||||
children: [
|
||||
bannerAsync.when(
|
||||
data: (bannerItems) {
|
||||
if (bannerItems.isEmpty) {
|
||||
_clearShellBackdropDeferred();
|
||||
return const SizedBox.shrink(
|
||||
key: ValueKey('home-banner-empty'),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
key: const ValueKey('home-banner'),
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: HomeBannerCarousel(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
items: bannerItems,
|
||||
imageHeaders: imageHeaders,
|
||||
onItemTap: _openMediaDetail,
|
||||
onActiveItemChanged: _updateShellBackdrop,
|
||||
active: homeForeground,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Padding(
|
||||
key: ValueKey('home-banner-loading'),
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: HomeBannerSkeleton(),
|
||||
),
|
||||
error: (_, _) {
|
||||
_clearShellBackdropDeferred();
|
||||
return const SizedBox.shrink(
|
||||
key: ValueKey('home-banner-error'),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (data.resumeLoading)
|
||||
const SkeletonMediaRow(
|
||||
key: ValueKey('home-resume-row'),
|
||||
title: '继续观看',
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
titleColor: Colors.white,
|
||||
)
|
||||
else if (data.resumeError != null)
|
||||
ErrorMediaRow(
|
||||
key: const ValueKey('home-resume-row'),
|
||||
title: '继续观看',
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
titleColor: Colors.white,
|
||||
onRetry: () => ref
|
||||
.read(libraryOverviewProvider.notifier)
|
||||
.retryResume(),
|
||||
)
|
||||
else if (resumeItems.isNotEmpty)
|
||||
LatestMediaRow(
|
||||
key: const ValueKey('home-resume-row'),
|
||||
title: '继续观看',
|
||||
items: resumeItems,
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
enableContextMenu: true,
|
||||
favoriteMap: _buildFavoriteMap(resumeItems),
|
||||
playedMap: _buildPlayedMap(resumeItems),
|
||||
favoriteLoadingMap: _favoriteLoadingMap,
|
||||
playedLoadingMap: _playedLoadingMap,
|
||||
onAddFavorite: _toggleFavorite,
|
||||
onMarkPlayed: _togglePlayed,
|
||||
onHideFromResume: _hideFromResume,
|
||||
hideFromResumeLoadingMap: _hideResumeLoadingMap,
|
||||
imageHeaders: imageHeaders,
|
||||
titleColor: Colors.white,
|
||||
imageUrlBuilder: (item) => EmbyImageUrl.landscapeCard(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr),
|
||||
),
|
||||
preloadImageUrlBuilder: (item) =>
|
||||
EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
),
|
||||
onItemTap: _openMediaDetail,
|
||||
),
|
||||
if (data.libraries.isNotEmpty)
|
||||
LatestMediaRow(
|
||||
key: const ValueKey('home-library-row'),
|
||||
title: '我的媒体列表',
|
||||
items: data.libraries
|
||||
.map(
|
||||
(library) => EmbyRawItem(
|
||||
Id: library.Id,
|
||||
Name: library.Name,
|
||||
PrimaryImageTag: library.ImageTags?['Primary'],
|
||||
ImageTags: library.ImageTags,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
showHoverPlayIcon: false,
|
||||
imageHeaders: imageHeaders,
|
||||
titleColor: Colors.white,
|
||||
imageUrlBuilder: (item) => EmbyImageUrl.primary(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr),
|
||||
),
|
||||
onItemTap: (item) =>
|
||||
context.push('/library/${item.Id}'),
|
||||
),
|
||||
...data.libraries.expand((library) {
|
||||
final isLoading = data.loadingLibraryIds.contains(
|
||||
library.Id,
|
||||
);
|
||||
if (isLoading) {
|
||||
return [
|
||||
SkeletonMediaRow(
|
||||
key: ValueKey('home-latest-row-${library.Id}'),
|
||||
title: '${library.Name} · 最新更新',
|
||||
titleColor: Colors.white,
|
||||
),
|
||||
];
|
||||
}
|
||||
final error = data.latestErrorByLibrary[library.Id];
|
||||
if (error != null) {
|
||||
return [
|
||||
ErrorMediaRow(
|
||||
key: ValueKey('home-latest-row-${library.Id}'),
|
||||
title: '${library.Name} · 最新更新',
|
||||
titleColor: Colors.white,
|
||||
onRetry: () => ref
|
||||
.read(libraryOverviewProvider.notifier)
|
||||
.retryLatest(library.Id),
|
||||
),
|
||||
];
|
||||
}
|
||||
final items =
|
||||
data.latestByLibrary[library.Id] ??
|
||||
const <EmbyRawItem>[];
|
||||
if (items.isEmpty) return const <Widget>[];
|
||||
return [
|
||||
LatestMediaRow(
|
||||
key: ValueKey('home-latest-row-${library.Id}'),
|
||||
title: library.Name,
|
||||
subtitle: '最新更新',
|
||||
items: items,
|
||||
totalCount: data.totalCountByLibrary[library.Id],
|
||||
enableContextMenu: true,
|
||||
favoriteMap: _buildFavoriteMap(items),
|
||||
playedMap: _buildPlayedMap(items),
|
||||
favoriteLoadingMap: _favoriteLoadingMap,
|
||||
playedLoadingMap: _playedLoadingMap,
|
||||
onAddFavorite: _toggleFavorite,
|
||||
onMarkPlayed: _togglePlayed,
|
||||
imageHeaders: imageHeaders,
|
||||
titleColor: Colors.white,
|
||||
imageUrlBuilder: (item) => EmbyImageUrl.primary(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
maxWidth: EmbyImageUrl.targetPixelWidth(158, dpr),
|
||||
),
|
||||
preloadImageUrlBuilder: (item) =>
|
||||
EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
),
|
||||
onItemTap: _openMediaDetail,
|
||||
onSeeAll: () =>
|
||||
context.push('/library/${library.Id}'),
|
||||
),
|
||||
];
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (MediaQuery.sizeOf(context).width < Breakpoints.compact)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 8,
|
||||
left: 16,
|
||||
child: const ServerDropdownButton(),
|
||||
)
|
||||
else
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ScrollFadeHeader(scrollProgress: _scrollProgress),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: AppLoadingRing()),
|
||||
error: (error, _) => AppErrorState(
|
||||
message: formatUserError(error, fallback: '首页内容加载失败'),
|
||||
panel: true,
|
||||
onRetry: () => ref.read(libraryOverviewProvider.notifier).refresh(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user