Initial commit
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
|
||||
|
||||
library;
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../shared/utils/time_bucket.dart';
|
||||
|
||||
|
||||
enum GroupAxis { watchedTime, addedTime, mediaType }
|
||||
|
||||
|
||||
class AggregatedEntrySource {
|
||||
final EmbyRawItem item;
|
||||
final GlobalSearchServerResult source;
|
||||
|
||||
const AggregatedEntrySource({required this.item, required this.source});
|
||||
|
||||
|
||||
DateTime? dateFor(GroupAxis axis) {
|
||||
switch (axis) {
|
||||
case GroupAxis.watchedTime:
|
||||
final userData = item.extra['UserData'];
|
||||
return parseEmbyDate(
|
||||
userData is Map ? userData['LastPlayedDate'] : null,
|
||||
);
|
||||
case GroupAxis.addedTime:
|
||||
return parseEmbyDate(item.extra['DateCreated']);
|
||||
case GroupAxis.mediaType:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AggregatedEntry {
|
||||
final EmbyRawItem item;
|
||||
final GlobalSearchServerResult source;
|
||||
final List<AggregatedEntrySource> sources;
|
||||
|
||||
AggregatedEntry({
|
||||
required this.item,
|
||||
required this.source,
|
||||
List<AggregatedEntrySource>? sources,
|
||||
}) : assert(sources == null || sources.isNotEmpty),
|
||||
sources = List.unmodifiable(
|
||||
sources ?? [AggregatedEntrySource(item: item, source: source)],
|
||||
);
|
||||
|
||||
|
||||
DateTime? dateFor(GroupAxis axis) {
|
||||
return sources.first.dateFor(axis);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AggregatedGroup {
|
||||
final String key;
|
||||
final String label;
|
||||
final int order;
|
||||
final List<AggregatedEntry> entries;
|
||||
|
||||
const AggregatedGroup({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.order,
|
||||
required this.entries,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
List<AggregatedEntry> flatten(
|
||||
List<GlobalSearchServerResult> results, {
|
||||
String? serverFilter,
|
||||
bool deduplicateAcrossServers = false,
|
||||
}) {
|
||||
final out = <AggregatedEntry>[];
|
||||
for (final sr in results) {
|
||||
if (serverFilter != null && sr.serverId != serverFilter) continue;
|
||||
for (final item in sr.items) {
|
||||
out.add(AggregatedEntry(item: item, source: sr));
|
||||
}
|
||||
}
|
||||
if (deduplicateAcrossServers && serverFilter == null && out.length > 1) {
|
||||
return _deduplicateEntries(out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const _typeOrder = <String, ({String label, int order})>{
|
||||
'Movie': (label: '电影', order: 0),
|
||||
'Series': (label: '剧集', order: 1),
|
||||
'Episode': (label: '单集', order: 2),
|
||||
};
|
||||
const _typeOther = (label: '其它', order: 3);
|
||||
|
||||
|
||||
List<AggregatedGroup> groupEntries(
|
||||
List<AggregatedEntry> entries,
|
||||
GroupAxis axis,
|
||||
DateTime now,
|
||||
) {
|
||||
if (axis == GroupAxis.mediaType) {
|
||||
return _groupByType(entries);
|
||||
}
|
||||
return _groupByTime(entries, axis, now);
|
||||
}
|
||||
|
||||
List<AggregatedGroup> _groupByTime(
|
||||
List<AggregatedEntry> entries,
|
||||
GroupAxis axis,
|
||||
DateTime now,
|
||||
) {
|
||||
final scheme = axis == GroupAxis.watchedTime
|
||||
? TimeBucketScheme.watched
|
||||
: TimeBucketScheme.added;
|
||||
final buckets =
|
||||
<String, ({TimeBucket bucket, List<AggregatedEntry> items})>{};
|
||||
|
||||
for (final e in entries) {
|
||||
final b = bucketFor(e.dateFor(axis), now, scheme);
|
||||
(buckets[b.key] ??= (bucket: b, items: [])).items.add(e);
|
||||
}
|
||||
|
||||
final groups = buckets.values.map((v) {
|
||||
final items = [...v.items]
|
||||
..sort((a, b) {
|
||||
final da = a.dateFor(axis);
|
||||
final db = b.dateFor(axis);
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return db.compareTo(da);
|
||||
});
|
||||
return AggregatedGroup(
|
||||
key: v.bucket.key,
|
||||
label: v.bucket.label,
|
||||
order: v.bucket.order,
|
||||
entries: items,
|
||||
);
|
||||
}).toList()..sort((a, b) => a.order.compareTo(b.order));
|
||||
return groups;
|
||||
}
|
||||
|
||||
List<AggregatedGroup> _groupByType(List<AggregatedEntry> entries) {
|
||||
final groups =
|
||||
<String, ({String label, int order, List<AggregatedEntry> items})>{};
|
||||
|
||||
for (final e in entries) {
|
||||
final meta = _typeOrder[e.item.Type] ?? _typeOther;
|
||||
final key = e.item.Type ?? 'Other';
|
||||
(groups[key] ??= (
|
||||
label: meta.label,
|
||||
order: meta.order,
|
||||
items: [],
|
||||
)).items.add(e);
|
||||
}
|
||||
|
||||
final out = groups.entries.map((kv) {
|
||||
final items = [...kv.value.items]
|
||||
..sort((a, b) => (a.item.Name).compareTo(b.item.Name));
|
||||
return AggregatedGroup(
|
||||
key: kv.key,
|
||||
label: kv.value.label,
|
||||
order: kv.value.order,
|
||||
entries: items,
|
||||
);
|
||||
}).toList()..sort((a, b) => a.order.compareTo(b.order));
|
||||
return out;
|
||||
}
|
||||
|
||||
Set<String> _candidateKeys(EmbyRawItem item) {
|
||||
final type = item.Type;
|
||||
if (type == null || type.isEmpty) return const {};
|
||||
final ids = normalizedProviderIdsOfItem(item);
|
||||
if (ids.isEmpty) return const {};
|
||||
return {for (final e in ids.entries) '$type:${e.key}:${e.value}'};
|
||||
}
|
||||
|
||||
List<AggregatedEntry> _deduplicateEntries(List<AggregatedEntry> entries) {
|
||||
final uf = _UnionFind(entries.length);
|
||||
final keyToIndex = <String, int>{};
|
||||
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
final keys = _candidateKeys(entries[i].item);
|
||||
for (final k in keys) {
|
||||
final prev = keyToIndex[k];
|
||||
if (prev != null) {
|
||||
uf.union(prev, i);
|
||||
} else {
|
||||
keyToIndex[k] = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final groups = uf.groups();
|
||||
final survivors = <AggregatedEntry>[];
|
||||
for (final group in groups) {
|
||||
final orderedIndices = [...group]
|
||||
..sort((leftIndex, rightIndex) {
|
||||
final leftDate = _lastPlayedDate(entries[leftIndex]);
|
||||
final rightDate = _lastPlayedDate(entries[rightIndex]);
|
||||
if (leftDate == null && rightDate == null) {
|
||||
return leftIndex.compareTo(rightIndex);
|
||||
}
|
||||
if (leftDate == null) return 1;
|
||||
if (rightDate == null) return -1;
|
||||
|
||||
final dateComparison = rightDate.compareTo(leftDate);
|
||||
return dateComparison != 0
|
||||
? dateComparison
|
||||
: leftIndex.compareTo(rightIndex);
|
||||
});
|
||||
|
||||
final sourceServerIds = <String>{};
|
||||
final sources = <AggregatedEntrySource>[];
|
||||
for (final entryIndex in orderedIndices) {
|
||||
final entry = entries[entryIndex];
|
||||
if (!sourceServerIds.add(entry.source.serverId)) continue;
|
||||
sources.add(
|
||||
AggregatedEntrySource(item: entry.item, source: entry.source),
|
||||
);
|
||||
}
|
||||
|
||||
final primarySource = sources.first;
|
||||
survivors.add(
|
||||
AggregatedEntry(
|
||||
item: primarySource.item,
|
||||
source: primarySource.source,
|
||||
sources: sources,
|
||||
),
|
||||
);
|
||||
}
|
||||
return survivors;
|
||||
}
|
||||
|
||||
DateTime? _lastPlayedDate(AggregatedEntry e) =>
|
||||
e.dateFor(GroupAxis.watchedTime);
|
||||
|
||||
class _UnionFind {
|
||||
final List<int> _parent;
|
||||
final List<int> _rank;
|
||||
|
||||
_UnionFind(int n)
|
||||
: _parent = List.generate(n, (i) => i),
|
||||
_rank = List.filled(n, 0);
|
||||
|
||||
int find(int x) {
|
||||
if (_parent[x] != x) _parent[x] = find(_parent[x]);
|
||||
return _parent[x];
|
||||
}
|
||||
|
||||
void union(int a, int b) {
|
||||
final ra = find(a), rb = find(b);
|
||||
if (ra == rb) return;
|
||||
if (_rank[ra] < _rank[rb]) {
|
||||
_parent[ra] = rb;
|
||||
} else if (_rank[ra] > _rank[rb]) {
|
||||
_parent[rb] = ra;
|
||||
} else {
|
||||
_parent[rb] = ra;
|
||||
_rank[ra]++;
|
||||
}
|
||||
}
|
||||
|
||||
List<List<int>> groups() {
|
||||
final map = <int, List<int>>{};
|
||||
for (var i = 0; i < _parent.length; i++) {
|
||||
(map[find(i)] ??= []).add(i);
|
||||
}
|
||||
return map.values.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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 '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/utils/time_bucket.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import 'aggregated_grouping.dart';
|
||||
import 'widgets/aggregated_grouped_grid.dart';
|
||||
import 'widgets/aggregated_media_card.dart';
|
||||
import 'widgets/server_filter_bar.dart';
|
||||
|
||||
class AggregatedRecentPage extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const AggregatedRecentPage({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AggregatedRecentPage> createState() =>
|
||||
_AggregatedRecentPageState();
|
||||
}
|
||||
|
||||
class _AggregatedRecentPageState extends ConsumerState<AggregatedRecentPage> {
|
||||
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(aggregatedRecentUseCaseProvider.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);
|
||||
}
|
||||
}
|
||||
|
||||
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.new_releases_outlined,
|
||||
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.addedTime, 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) {
|
||||
final sr = entry.source;
|
||||
final item = entry.item;
|
||||
final subtitle = item.ProductionYear != null
|
||||
? '${item.ProductionYear}'
|
||||
: null;
|
||||
|
||||
return AggregatedMediaCard(
|
||||
item: item,
|
||||
imageUrl: EmbyImageUrl.landscapeCard(
|
||||
baseUrl: sr.serverUrl,
|
||||
token: sr.token,
|
||||
item: item,
|
||||
maxWidth: 400,
|
||||
),
|
||||
imageHeaders: buildEmbyImageHeaders(
|
||||
ref,
|
||||
token: sr.token,
|
||||
userId: sr.userId,
|
||||
),
|
||||
title: item.Name,
|
||||
subtitle: subtitle,
|
||||
serverName: sr.serverName,
|
||||
timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.addedTime), now),
|
||||
showProgress: false,
|
||||
onTap: () => _onItemTap(context, sr, item),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/page_background.dart';
|
||||
import '../../shared/widgets/pill_tab_bar.dart';
|
||||
import '../favorites/aggregated_favorites_page.dart';
|
||||
import '../history/history_page.dart';
|
||||
import 'aggregated_recent_page.dart';
|
||||
|
||||
const _kTabHistory = 'history';
|
||||
const _kTabFavorites = 'favorites';
|
||||
const _kTabRecent = 'recent';
|
||||
|
||||
int _tabIndexFromKey(String? key) => switch (key) {
|
||||
_kTabFavorites => 1,
|
||||
_kTabRecent => 2,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
String _tabKeyFromIndex(int index) => switch (index) {
|
||||
1 => _kTabFavorites,
|
||||
2 => _kTabRecent,
|
||||
_ => _kTabHistory,
|
||||
};
|
||||
|
||||
class AggregatedViewPage extends ConsumerStatefulWidget {
|
||||
|
||||
final String? initialTab;
|
||||
|
||||
|
||||
final bool isCompactMode;
|
||||
|
||||
const AggregatedViewPage({
|
||||
super.key,
|
||||
this.initialTab,
|
||||
this.isCompactMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AggregatedViewPage> createState() => _AggregatedViewPageState();
|
||||
}
|
||||
|
||||
class _AggregatedViewPageState extends ConsumerState<AggregatedViewPage> {
|
||||
late int _index;
|
||||
int _historyRefreshSeed = 0;
|
||||
int _favoritesRefreshSeed = 0;
|
||||
int _recentRefreshSeed = 0;
|
||||
|
||||
String get _storagePrefix => widget.isCompactMode ? 'watch' : 'aggregated';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_index = _tabIndexFromKey(widget.initialTab);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AggregatedViewPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialTab != oldWidget.initialTab) {
|
||||
final next = _tabIndexFromKey(widget.initialTab);
|
||||
if (next != _index) {
|
||||
setState(() => _index = next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onTabChanged(int next) {
|
||||
if (next == _index) return;
|
||||
setState(() => _index = next);
|
||||
final key = _tabKeyFromIndex(next);
|
||||
context.go('/aggregated?tab=$key');
|
||||
}
|
||||
|
||||
Future<void> _refreshActive() async {
|
||||
setState(() {
|
||||
switch (_index) {
|
||||
case 1:
|
||||
_favoritesRefreshSeed++;
|
||||
break;
|
||||
case 2:
|
||||
_recentRefreshSeed++;
|
||||
break;
|
||||
default:
|
||||
_historyRefreshSeed++;
|
||||
}
|
||||
});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.appTokens;
|
||||
final firstTabLabel = widget.isCompactMode ? '继续观看' : '播放记录';
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: PageBackground()),
|
||||
RefreshIndicator(
|
||||
onRefresh: _refreshActive,
|
||||
child: NestedScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
floatHeaderSlivers: false,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context,
|
||||
),
|
||||
sliver: AppSliverHeader(
|
||||
toolbarHeight: tokens.titleBarHeight,
|
||||
automaticallyImplyLeading: false,
|
||||
titleWidget: _buildTitleRow(firstTabLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: [
|
||||
KeyedSubtree(
|
||||
key: PageStorageKey(
|
||||
'${_storagePrefix}_history_$_historyRefreshSeed',
|
||||
),
|
||||
child: const HistoryPage(embedded: true),
|
||||
),
|
||||
KeyedSubtree(
|
||||
key: PageStorageKey(
|
||||
'${_storagePrefix}_favorites_$_favoritesRefreshSeed',
|
||||
),
|
||||
child: const AggregatedFavoritesPage(embedded: true),
|
||||
),
|
||||
KeyedSubtree(
|
||||
key: PageStorageKey(
|
||||
'${_storagePrefix}_recent_$_recentRefreshSeed',
|
||||
),
|
||||
child: const AggregatedRecentPage(embedded: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitleRow(String firstTabLabel) {
|
||||
final tabBar = PillTabBar(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
tabs: [firstTabLabel, '我的收藏', '最近添加'],
|
||||
selectedIndex: _index,
|
||||
onSelect: _onTabChanged,
|
||||
);
|
||||
|
||||
return Align(alignment: Alignment.centerLeft, child: tabBar);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/layout/adaptive_card_grid.dart';
|
||||
import '../../../shared/widgets/section_header.dart';
|
||||
import 'aggregated_media_card.dart';
|
||||
import '../aggregated_grouping.dart';
|
||||
|
||||
|
||||
List<Widget> buildAggregatedGridSlivers({
|
||||
required List<AggregatedGroup> groups,
|
||||
required Set<String> collapsed,
|
||||
required ValueChanged<String> onToggle,
|
||||
required Widget Function(AggregatedEntry entry) cardBuilder,
|
||||
EdgeInsets padding = const EdgeInsets.fromLTRB(24, 4, 24, 24),
|
||||
}) {
|
||||
final slivers = <Widget>[];
|
||||
for (final group in groups) {
|
||||
final isCollapsed = collapsed.contains(group.key);
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: padding.left, right: padding.right),
|
||||
child: SectionHeader(
|
||||
label: group.label,
|
||||
count: group.entries.length,
|
||||
collapsible: true,
|
||||
collapsed: isCollapsed,
|
||||
onToggle: () => onToggle(group.key),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!isCollapsed) {
|
||||
final entries = group.entries;
|
||||
slivers.add(
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(padding.left, 0, padding.right, 20),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.crossAxisExtent;
|
||||
final columnCount = cardColumnsFor(
|
||||
availableWidth,
|
||||
minCardWidth: kRichCardMinWidth,
|
||||
);
|
||||
final gapTotalWidth = kCardGap * (columnCount - 1);
|
||||
final cardWidth = (availableWidth - gapTotalWidth) / columnCount;
|
||||
|
||||
return SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columnCount,
|
||||
mainAxisExtent: AggregatedMediaCard.height,
|
||||
crossAxisSpacing: kCardGap,
|
||||
mainAxisSpacing: kCardGap,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => SizedBox(
|
||||
width: cardWidth,
|
||||
child: cardBuilder(entries[index]),
|
||||
),
|
||||
childCount: entries.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return slivers;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/emby_ticks.dart';
|
||||
import '../../../shared/widgets/app_card.dart';
|
||||
import '../../../shared/widgets/card_progress_bar.dart';
|
||||
import '../../../shared/widgets/metadata_chip.dart';
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
|
||||
|
||||
class AggregatedMediaCard extends StatefulWidget {
|
||||
final EmbyRawItem item;
|
||||
final String? imageUrl;
|
||||
final String? fallbackImageUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String serverName;
|
||||
final int serverSourceCount;
|
||||
final String? timeLabel;
|
||||
|
||||
final String? networkName;
|
||||
final String? networkLogoUrl;
|
||||
|
||||
final bool showProgress;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onServerSourcesTap;
|
||||
final List<FItem>? contextMenuItems;
|
||||
|
||||
const AggregatedMediaCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.imageUrl,
|
||||
required this.title,
|
||||
required this.serverName,
|
||||
this.serverSourceCount = 1,
|
||||
this.fallbackImageUrl,
|
||||
this.imageHeaders,
|
||||
this.subtitle,
|
||||
this.timeLabel,
|
||||
this.networkName,
|
||||
this.networkLogoUrl,
|
||||
this.showProgress = false,
|
||||
this.onTap,
|
||||
this.onServerSourcesTap,
|
||||
this.contextMenuItems,
|
||||
}) : assert(serverSourceCount > 0);
|
||||
|
||||
static const double height = 84;
|
||||
static const double _thumbWidth = 124;
|
||||
|
||||
@override
|
||||
State<AggregatedMediaCard> createState() => _AggregatedMediaCardState();
|
||||
}
|
||||
|
||||
class _AggregatedMediaCardState extends State<AggregatedMediaCard> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final radius = tokens.radiusCard;
|
||||
final progress = widget.showProgress ? playbackProgress(widget.item) : 0.0;
|
||||
final remaining = widget.showProgress
|
||||
? formatRemainingLabel(widget.item)
|
||||
: null;
|
||||
|
||||
return AppCard(
|
||||
onTap: widget.onTap,
|
||||
contextMenuItems: widget.contextMenuItems,
|
||||
radius: radius,
|
||||
padding: EdgeInsets.zero,
|
||||
onHoverChanged: (h) => setState(() => _hovered = h),
|
||||
child: SizedBox(
|
||||
height: AggregatedMediaCard.height,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_thumbnail(radius, progress),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 10, 8),
|
||||
child: _info(theme, tokens, remaining),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _thumbnail(double radius, double progress) {
|
||||
return SizedBox(
|
||||
width: AggregatedMediaCard._thumbWidth,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SmartImage(
|
||||
url: widget.imageUrl,
|
||||
fallbackUrls: widget.fallbackImageUrl != null
|
||||
? [widget.fallbackImageUrl!]
|
||||
: const [],
|
||||
httpHeaders: widget.imageHeaders,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
),
|
||||
if (_hovered)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.32),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 30,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (progress > 0) CardProgressBar(progress: progress),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _networkBadge(AppTokens tokens) {
|
||||
if (widget.networkLogoUrl != null) {
|
||||
return SizedBox(
|
||||
height: 16,
|
||||
child: SmartImage(
|
||||
url: widget.networkLogoUrl,
|
||||
fit: BoxFit.contain,
|
||||
borderRadius: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
return MetadataChip(
|
||||
label: widget.networkName!,
|
||||
dense: true,
|
||||
foreground: tokens.mutedForeground,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _serverBadge() {
|
||||
final hasMultipleSources = widget.serverSourceCount > 1;
|
||||
if (!hasMultipleSources || widget.onServerSourcesTap == null) {
|
||||
return MetadataChip(label: widget.serverName, dense: true);
|
||||
}
|
||||
|
||||
final label = '${widget.serverName} ${widget.serverSourceCount}服';
|
||||
return Tooltip(
|
||||
message: '选择服务器并查看各自的播放进度',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '$label,选择其他服务器',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onServerSourcesTap,
|
||||
child: MetadataChip(
|
||||
label: label,
|
||||
icon: Icons.keyboard_arrow_down_rounded,
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _info(ThemeData theme, AppTokens tokens, String? remaining) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_serverBadge(),
|
||||
if (widget.networkName != null &&
|
||||
widget.networkName!.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
_networkBadge(tokens),
|
||||
],
|
||||
if (remaining != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
remaining,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (widget.timeLabel != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.timeLabel!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
|
||||
class ServerFilterBar extends StatelessWidget {
|
||||
final List<({String id, String name})> servers;
|
||||
final String? selected;
|
||||
final ValueChanged<String?> onSelect;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const ServerFilterBar({
|
||||
super.key,
|
||||
required this.servers,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
this.padding = const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (servers.length < 2) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_chip(label: '全部', value: null),
|
||||
for (final s in servers) _chip(label: s.name, value: s.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip({required String label, required String? value}) {
|
||||
final active = selected == value;
|
||||
final child = Text(label);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FButton(
|
||||
variant: active ? FButtonVariant.primary : FButtonVariant.ghost,
|
||||
onPress: () => onSelect(value),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user