Initial commit
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/tmdb_settings_provider.dart';
|
||||
import '../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/time_bucket.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/utils/tmdb_key_utils.dart';
|
||||
import '../../shared/widgets/app_sliver_header.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';
|
||||
import 'widgets/history_source_picker.dart';
|
||||
|
||||
class HistoryPage extends ConsumerStatefulWidget {
|
||||
|
||||
final bool embedded;
|
||||
|
||||
const HistoryPage({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<HistoryPage> createState() => _HistoryPageState();
|
||||
}
|
||||
|
||||
class _HistoryPageState extends ConsumerState<HistoryPage> {
|
||||
StreamSubscription<GlobalSearchServerResult>? _subscription;
|
||||
|
||||
List<GlobalSearchServerResult> _serverResults = [];
|
||||
bool _loading = false;
|
||||
bool _switching = false;
|
||||
|
||||
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(aggregatedResumeUseCaseProvider.future);
|
||||
final stream = uc.executeStream();
|
||||
|
||||
_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> _onServerSourcesTap(
|
||||
BuildContext context,
|
||||
AggregatedEntry entry,
|
||||
DateTime now,
|
||||
) async {
|
||||
if (_switching || entry.sources.length < 2) return;
|
||||
final selectedSource = await showHistorySourcePicker(
|
||||
context,
|
||||
entry: entry,
|
||||
now: now,
|
||||
);
|
||||
if (selectedSource == null || !mounted || !context.mounted) return;
|
||||
await _onItemTap(context, selectedSource.source, selectedSource.item);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
..._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: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
..._buildContentSlivers(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_switching) _switchingOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchingOverlay() => const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x44000000),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _buildContentSlivers(BuildContext context) {
|
||||
if (_serverResults.isEmpty && !_loading) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(icon: Icons.play_circle_outline, 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.watchedTime, now);
|
||||
final servers = _serverResults
|
||||
.map((sr) => (id: sr.serverId, name: sr.serverName))
|
||||
.toList(growable: false);
|
||||
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ServerFilterBar(
|
||||
servers: servers,
|
||||
selected: _serverFilter,
|
||||
onSelect: (v) => setState(() => _serverFilter = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
...buildAggregatedGridSlivers(
|
||||
groups: groups,
|
||||
collapsed: _collapsed,
|
||||
onToggle: _toggle,
|
||||
cardBuilder: (entry) => _card(context, entry, now),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context, AggregatedEntry entry, DateTime now) {
|
||||
return _TmdbAwareHistoryCard(
|
||||
entry: entry,
|
||||
now: now,
|
||||
imageHeaders: buildEmbyImageHeaders(
|
||||
ref,
|
||||
token: entry.source.token,
|
||||
userId: entry.source.userId,
|
||||
),
|
||||
onTap: () => _onItemTap(context, entry.source, entry.item),
|
||||
onServerSourcesTap: entry.sources.length > 1
|
||||
? () => unawaited(_onServerSourcesTap(context, entry, now))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TmdbAwareHistoryCard extends ConsumerWidget {
|
||||
final AggregatedEntry entry;
|
||||
final DateTime now;
|
||||
final Map<String, String> imageHeaders;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onServerSourcesTap;
|
||||
|
||||
const _TmdbAwareHistoryCard({
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.imageHeaders,
|
||||
required this.onTap,
|
||||
this.onServerSourcesTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sr = entry.source;
|
||||
final item = entry.item;
|
||||
final episode = formatEpisodeNumber(item.IndexNumber);
|
||||
final subtitle = episode != null
|
||||
? '$episode · ${item.Name}'
|
||||
: (item.ProductionYear != null ? '${item.ProductionYear}' : null);
|
||||
|
||||
final key = tmdbMediaKeyFrom(item);
|
||||
String? networkName;
|
||||
String? networkLogoUrl;
|
||||
if (key != null) {
|
||||
final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value;
|
||||
final network = enriched?.networks.firstOrNull;
|
||||
if (network != null) {
|
||||
networkName = network.name;
|
||||
final imageBase = ref
|
||||
.watch(tmdbSettingsProvider)
|
||||
.value
|
||||
?.effectiveImageBaseUrl;
|
||||
if (imageBase != null) {
|
||||
networkLogoUrl = TmdbImageUrl.logo(
|
||||
imageBase,
|
||||
network.logoPath,
|
||||
size: 'w92',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AggregatedMediaCard(
|
||||
item: item,
|
||||
imageUrl: EmbyImageUrl.landscapeCard(
|
||||
baseUrl: sr.serverUrl,
|
||||
token: sr.token,
|
||||
item: item,
|
||||
maxWidth: 400,
|
||||
),
|
||||
imageHeaders: imageHeaders,
|
||||
title: item.SeriesName ?? item.Name,
|
||||
subtitle: subtitle,
|
||||
serverName: sr.serverName,
|
||||
serverSourceCount: entry.sources.length,
|
||||
networkName: networkName,
|
||||
networkLogoUrl: networkLogoUrl,
|
||||
timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.watchedTime), now),
|
||||
showProgress: true,
|
||||
onTap: onTap,
|
||||
onServerSourcesTap: onServerSourcesTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/emby_ticks.dart';
|
||||
import '../../../shared/utils/time_bucket.dart';
|
||||
import '../../../shared/widgets/adaptive_modal.dart';
|
||||
import '../../aggregated/aggregated_grouping.dart';
|
||||
|
||||
|
||||
Future<AggregatedEntrySource?> showHistorySourcePicker(
|
||||
BuildContext context, {
|
||||
required AggregatedEntry entry,
|
||||
DateTime? now,
|
||||
}) {
|
||||
return showAdaptiveModal<AggregatedEntrySource>(
|
||||
context: context,
|
||||
maxWidth: 520,
|
||||
maxHeight: 640,
|
||||
builder: (modalContext) => HistorySourcePickerContent(
|
||||
entry: entry,
|
||||
now: now ?? DateTime.now(),
|
||||
onSourceSelected: (source) {
|
||||
Navigator.of(modalContext).pop(source);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class HistorySourcePickerContent extends StatelessWidget {
|
||||
final AggregatedEntry entry;
|
||||
final DateTime now;
|
||||
final ValueChanged<AggregatedEntrySource> onSourceSelected;
|
||||
|
||||
const HistorySourcePickerContent({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.onSourceSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final desiredHeight = 104.0 + (entry.sources.length * 92.0);
|
||||
final contentHeight = math.min(desiredHeight, 560.0);
|
||||
|
||||
return SizedBox(
|
||||
height: contentHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择服务器',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
entry.item.SeriesName ?? entry.item.Name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '关闭',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: context.appTokens.separatorColor),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: entry.sources.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 2),
|
||||
itemBuilder: (context, index) {
|
||||
final source = entry.sources[index];
|
||||
return _HistorySourceRow(
|
||||
source: source,
|
||||
now: now,
|
||||
isPrimary: index == 0,
|
||||
onTap: () => onSourceSelected(source),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistorySourceRow extends StatelessWidget {
|
||||
final AggregatedEntrySource source;
|
||||
final DateTime now;
|
||||
final bool isPrimary;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _HistorySourceRow({
|
||||
required this.source,
|
||||
required this.now,
|
||||
required this.isPrimary,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final progress = _HistorySourceProgress.fromItem(source.item);
|
||||
final lastPlayedLabel =
|
||||
relativeTimeLabel(source.dateFor(GroupAxis.watchedTime), now) ??
|
||||
'未记录播放时间';
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
source.source.serverName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isPrimary) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'最近播放',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.value ?? 0,
|
||||
minHeight: 4,
|
||||
backgroundColor: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.08,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.label} · $lastPlayedLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 20,
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistorySourceProgress {
|
||||
final double? value;
|
||||
final String label;
|
||||
|
||||
const _HistorySourceProgress({required this.value, required this.label});
|
||||
|
||||
factory _HistorySourceProgress.fromItem(EmbyRawItem item) {
|
||||
final userData = item.UserData;
|
||||
if (userData?.Played == true) {
|
||||
return const _HistorySourceProgress(value: 1, label: '已看完');
|
||||
}
|
||||
|
||||
final positionTicks = userData?.PlaybackPositionTicks;
|
||||
final runtimeTicks = item.RunTimeTicks;
|
||||
if (positionTicks == null || runtimeTicks == null || runtimeTicks <= 0) {
|
||||
return const _HistorySourceProgress(value: null, label: '进度未知');
|
||||
}
|
||||
|
||||
final progress = (positionTicks / runtimeTicks).clamp(0.0, 1.0);
|
||||
final watchedPercent = (progress * 100).round();
|
||||
final remainingTicks = runtimeTicks - positionTicks;
|
||||
if (remainingTicks <= 0) {
|
||||
return const _HistorySourceProgress(value: 1, label: '已看完');
|
||||
}
|
||||
|
||||
final remainingMinutes = (remainingTicks / kTicksPerMinute).ceil();
|
||||
return _HistorySourceProgress(
|
||||
value: progress,
|
||||
label: '已看 $watchedPercent% · 剩余 $remainingMinutes 分钟',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user