refactor(features): consolidate shared feature primitives
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user