refactor(features): consolidate shared feature primitives

This commit is contained in:
edde746
2026-07-12 17:31:13 +02:00
parent 6506f36843
commit 7ecadffdb1
103 changed files with 1718 additions and 898 deletions
+26 -42
View File
@@ -3,20 +3,17 @@ import 'media_item.dart';
import 'media_kind.dart';
import 'media_server_client.dart';
/// Collect every episode of a show into [out] using the backend's one-shot
/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]
/// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's
/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). Avoids walking
/// show → seasons → episodes client-side, so large series come back in one
/// trip and aren't capped by any per-page Limit.
/// Collect every episode below a show or season into [out] using the backend's
/// one-shot recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]
/// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's
/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). This avoids walking
/// show → seasons → episodes client-side and is not capped by a page size.
///
/// A failure of the underlying call propagates to the caller — both
/// `DownloadProvider.queueDownload` and the sync rule executor wrap their
/// invocations so the user-facing error surfaces / the rule run is rolled
/// back.
Future<void> collectEpisodesForShow(
/// A failure propagates to the caller so download and sync transactions can
/// surface or roll back the operation.
Future<void> collectEpisodes(
MediaServerClient client,
String showRatingKey, {
String parentId, {
required bool unwatchedOnly,
required List<MediaItem> out,
MediaItem? fallback,
@@ -24,28 +21,7 @@ Future<void> collectEpisodesForShow(
}) {
return _collectPlayable(
client,
showRatingKey,
unwatchedOnly: unwatchedOnly,
out: out,
fallback: fallback,
includeSpecials: includeSpecials,
);
}
/// Collect every episode of a single season into [out] via the same
/// one-shot endpoint. On a season the leaves *are* the episodes, so the
/// shape matches the show case.
Future<void> collectEpisodesForSeason(
MediaServerClient client,
String seasonRatingKey, {
required bool unwatchedOnly,
required List<MediaItem> out,
MediaItem? fallback,
bool includeSpecials = true,
}) {
return _collectPlayable(
client,
seasonRatingKey,
parentId,
unwatchedOnly: unwatchedOnly,
out: out,
fallback: fallback,
@@ -60,10 +36,7 @@ Future<MediaItem?> fetchFirstEpisodeForSeason(
String seasonRatingKey, {
String? seriesId,
}) async {
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
final page = seriesId != null && seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(seriesId, seasonRatingKey, start: 0, size: 1)
: await client.fetchChildrenPage(seasonRatingKey, start: 0, size: 1);
final page = await _fetchSeasonPage(client, seasonId: seasonRatingKey, seriesId: seriesId, start: 0, size: 1);
for (final item in page.items) {
if (item.kind == MediaKind.episode) return item;
}
@@ -259,10 +232,7 @@ Future<LibraryPage<MediaItem>> fetchSeasonEpisodePage(
required int start,
required int size,
}) async {
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
final page = seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(show.id, season.id, start: start, size: size)
: await client.fetchChildrenPage(season.id, start: start, size: size);
final page = await _fetchSeasonPage(client, seriesId: show.id, seasonId: season.id, start: start, size: size);
return LibraryPage<MediaItem>(
items: normalizeSeasonEpisodes(page.items, show: show, season: season),
totalCount: page.totalCount,
@@ -270,6 +240,20 @@ Future<LibraryPage<MediaItem>> fetchSeasonEpisodePage(
);
}
Future<LibraryPage<MediaItem>> _fetchSeasonPage(
MediaServerClient client, {
required String seasonId,
required int start,
required int size,
String? seriesId,
}) {
final pagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
if (seriesId != null && pagingClient != null) {
return pagingClient.fetchSeasonEpisodesPage(seriesId, seasonId, start: start, size: size);
}
return client.fetchChildrenPage(seasonId, start: start, size: size);
}
List<MediaItem> normalizeSeasonEpisodes(
List<MediaItem> episodes, {
required MediaItem show,
+112
View File
@@ -0,0 +1,112 @@
import 'dart:async';
/// Acceleration tier shared by video and music timeline key-repeat seeking.
double steppedSeekMultiplier(int repeatCount) {
if (repeatCount <= 5) return 1.5;
if (repeatCount <= 15) return 3.0;
if (repeatCount <= 30) return 6.0;
return 10.0;
}
/// Coalesces a burst of relative timeline steps into one absolute seek.
///
/// The pending target remains pinned until playback reaches it (or the settle
/// ceiling expires), so a slow seek cannot make the next burst rebase from a
/// stale player position.
class DebouncedSeekAccumulator {
DebouncedSeekAccumulator({
required this.currentPosition,
required this.duration,
required this.seek,
this.onChanged,
this.debounce = const Duration(milliseconds: 800),
this.settlePoll = const Duration(seconds: 2),
this.settleTolerance = const Duration(seconds: 3),
this.settleCeiling = const Duration(seconds: 10),
});
final Duration Function() currentPosition;
final Duration Function() duration;
final void Function(Duration target) seek;
final void Function()? onChanged;
final Duration debounce;
final Duration settlePoll;
final Duration settleTolerance;
final Duration settleCeiling;
Duration? _pendingPosition;
Duration? _lastFlushedPosition;
Timer? _debounceTimer;
Timer? _settleTimer;
bool _disposed = false;
Duration? get pendingPosition => _pendingPosition;
void seekBy(Duration delta) {
if (_disposed) return;
final maximum = duration();
if (maximum <= Duration.zero) return;
final base = _pendingPosition ?? currentPosition();
final targetMs = (base + delta).inMilliseconds.clamp(0, maximum.inMilliseconds);
final target = Duration(milliseconds: targetMs);
if (target != _pendingPosition) {
_settleTimer?.cancel();
_settleTimer = null;
_pendingPosition = target;
_lastFlushedPosition = null;
onChanged?.call();
}
_debounceTimer?.cancel();
_debounceTimer = Timer(debounce, flush);
}
void flush() {
if (_disposed) return;
_debounceTimer?.cancel();
_debounceTimer = null;
final target = _pendingPosition;
if (target == null || target == _lastFlushedPosition) return;
_lastFlushedPosition = target;
seek(target);
_scheduleClear(target);
}
void _scheduleClear(Duration target) {
_settleTimer?.cancel();
var elapsed = Duration.zero;
void poll() {
if (_disposed || _pendingPosition != target) return;
elapsed += settlePoll;
if ((currentPosition() - target).abs() <= settleTolerance || elapsed >= settleCeiling) {
_pendingPosition = null;
_lastFlushedPosition = null;
_settleTimer = null;
onChanged?.call();
return;
}
_settleTimer = Timer(settlePoll, poll);
}
_settleTimer = Timer(settlePoll, poll);
}
void cancel() {
_debounceTimer?.cancel();
_debounceTimer = null;
_settleTimer?.cancel();
_settleTimer = null;
_lastFlushedPosition = null;
if (_pendingPosition != null) {
_pendingPosition = null;
onChanged?.call();
}
}
void dispose() {
_disposed = true;
_debounceTimer?.cancel();
_settleTimer?.cancel();
}
}