refactor: extract shared mixins and helpers, drop dead abstractions

Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
This commit is contained in:
edde746
2026-07-26 06:09:48 +02:00
parent 61344f7862
commit 352b88109b
217 changed files with 6813 additions and 8773 deletions
+4 -5
View File
@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'app_logger.dart';
import 'device_channel.dart';
enum AndroidStartupPhase {
nativeOnCreate('native_on_create'),
@@ -34,7 +34,6 @@ enum AndroidUiState {
/// Best-effort bridge for the newest Android 11+ historical process exit.
abstract final class AndroidExitDiagnostics {
static const _channel = MethodChannel('com.plezy/device');
static const _allowedReasons = {'crash', 'native_crash', 'anr', 'low_memory', 'user_requested', 'other'};
static const _allowedAbis = {'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86', 'unknown'};
static const _allowedCodecContexts = {
@@ -132,7 +131,7 @@ abstract final class AndroidExitDiagnostics {
static Future<void> _persistStartupPhase(String phase) async {
try {
await _channel.invokeMethod<bool>('setStartupPhase', phase);
await deviceChannel.invokeMethod<bool>('setStartupPhase', phase);
} catch (_) {
// Native phase persistence is best-effort.
}
@@ -141,7 +140,7 @@ abstract final class AndroidExitDiagnostics {
static Future<void> markUiState(AndroidUiState state) async {
if (!Platform.isAndroid) return;
try {
await _channel.invokeMethod<bool>('setRuntimeUiState', state.id);
await deviceChannel.invokeMethod<bool>('setRuntimeUiState', state.id);
} catch (_) {
// Runtime diagnostics are best-effort and must never affect navigation.
}
@@ -155,7 +154,7 @@ abstract final class AndroidExitDiagnostics {
static Future<void> logPreviousExit() async {
if (!Platform.isAndroid) return;
try {
final raw = await _channel.invokeMapMethod<String, Object?>('getPreviousExit');
final raw = await deviceChannel.invokeMapMethod<String, Object?>('getPreviousExit');
final report = _validate(raw);
if (report == null) return;
+62
View File
@@ -0,0 +1,62 @@
/// Memoizes a `static Future<T> getInstance()` singleton whose construction is
/// cheap but whose initialization is async.
///
/// The instance is published *before* initialization runs, so sync accessors
/// (`isTVSync`, `isReduced`, ...) see it immediately. Concurrent callers await
/// the one in-flight initialization, and a failed initialization rolls the
/// instance back so the next call retries — the `identical` guards keep that
/// rollback safe once a later call has replaced the memoized state.
///
/// The `debug*` members are test hooks; owners re-expose them behind their own
/// `@visibleForTesting` forwarders.
class AsyncSingleton<T extends Object> {
T? _instance;
Future<void>? _initialization;
/// Awaited before each initialization run, to hold initialization open while
/// a test exercises concurrent callers.
Future<void>? debugGate;
/// The memoized instance, which may still be initializing. Null before the
/// first [getInstance] call and after a failed initialization.
T? get instance => _instance;
/// Returns the memoized instance, building it with [create] and running
/// [initialize] on it the first time.
Future<T> getInstance(T Function() create, Future<void> Function(T instance) initialize) async {
final existing = _instance;
if (existing != null) {
final inFlight = _initialization;
if (inFlight != null) await inFlight;
return existing;
}
final instance = create();
_instance = instance;
final initialization = _initialize(instance, initialize);
_initialization = initialization;
try {
await initialization;
} catch (_) {
if (identical(_instance, instance)) _instance = null;
rethrow;
} finally {
if (identical(_initialization, initialization)) _initialization = null;
}
return instance;
}
Future<void> _initialize(T instance, Future<void> Function(T instance) initialize) async {
final gate = debugGate;
if (gate != null) await gate;
await initialize(instance);
}
/// Drops the memoized state and the gate, optionally seeding [instance] so
/// sync accessors can be exercised without initializing.
void debugReset({T? instance}) {
_instance = instance;
_initialization = null;
debugGate = null;
}
}
+5
View File
@@ -0,0 +1,5 @@
import 'package:flutter/services.dart';
/// Native device bridge (TV detection, device name, performance signals,
/// process-exit diagnostics). Implemented per platform under `com.plezy/device`.
const MethodChannel deviceChannel = MethodChannel('com.plezy/device');
+44
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import '../media/ids.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/focusable_action_bar.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
@@ -13,6 +15,7 @@ import '../services/sync_rule_executor.dart';
import 'content_utils.dart';
import 'dialogs.dart';
import 'download_version_utils.dart';
import 'platform_detector.dart';
import 'snackbar_helper.dart';
@visibleForTesting
@@ -461,3 +464,44 @@ Future<void> removeSyncRuleAndSnack(
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
}
}
/// The download / manage-sync-rule app-bar pair shared by the collection and
/// playlist detail screens: one entry that downloads (or edits the existing
/// rule) and, when a rule exists, one that removes it. Both are hidden on
/// Apple TV, which has no downloads UI.
///
/// [hasRule] stays caller-computed so each screen keeps its own
/// `context.select` short-circuit, and [showDownload] carries the screen's
/// own visibility predicate for the first entry.
List<FocusableAction> buildSyncRuleActions(
BuildContext context, {
required String ruleKey,
required String displayTitle,
required bool hasRule,
required bool showDownload,
required VoidCallback onDownload,
}) {
if (PlatformDetector.isAppleTV()) return const [];
return [
if (showDownload)
FocusableAction(
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
onPressed: hasRule
? () => manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: ruleKey)
: onDownload,
iconColor: hasRule ? Colors.teal : null,
),
if (hasRule)
FocusableAction(
icon: Symbols.sync_disabled_rounded,
tooltip: t.downloads.removeSyncRule,
onPressed: () => removeSyncRuleAndSnack(
context,
downloadProvider: context.read<DownloadProvider>(),
globalKey: ruleKey,
displayTitle: displayTitle,
),
),
];
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/widgets.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../media/media_hub.dart';
/// Leading icon for a hub row, shared by every surface that renders hubs from
/// the same backend rows (Discover and a library's Recommended tab).
///
/// Continue Watching is matched on the hub key first so synthesized rows and
/// section-specific `*.inprogress.*` hubs are covered, then on title for
/// backends whose resume row is only recognizable by name (Plex "On Deck").
/// Everything else is keyword-matched on the title; the first match wins, so
/// the more specific keywords are checked before the broader ones.
IconData hubIconFor(MediaHub hub) {
final title = hub.title.toLowerCase();
if (hub.isContinueWatchingHub || title.contains('continue watching') || title.contains('on deck')) {
return Symbols.play_circle_rounded;
}
for (final (keywords, icon) in _titleKeywordIcons) {
if (keywords.any(title.contains)) return icon;
}
return _defaultHubIcon;
}
const _defaultHubIcon = Symbols.auto_awesome_rounded;
/// Title keywords in match order — see [hubIconFor].
const _titleKeywordIcons = <(List<String>, IconData)>[
// Trending/Popular
(['trending'], Symbols.trending_up_rounded),
(['popular', 'imdb'], Symbols.whatshot_rounded),
// Seasonal/Time-based
(['seasonal'], Symbols.calendar_month_rounded),
(['newly', 'new release'], Symbols.new_releases_rounded),
(['recently released', 'recent'], Symbols.schedule_rounded),
// Top/Rated
(['top rated', 'highest rated'], Symbols.star_rounded),
(['top '], Symbols.military_tech_rounded),
// Genre-specific
(['thriller'], Symbols.warning_amber_rounded),
(['comedy', 'comedier'], Symbols.mood_rounded),
(['action'], Symbols.flash_on_rounded),
(['drama'], Symbols.theater_comedy_rounded),
(['fantasy'], Symbols.auto_fix_high_rounded),
(['science', 'sci-fi'], Symbols.rocket_launch_rounded),
(['horror', 'skräck'], Symbols.nights_stay_rounded),
(['romance', 'romantic'], Symbols.favorite_border_rounded),
(['adventure', 'äventyr'], Symbols.explore_rounded),
// Watchlist/Playlists
(['playlist', 'watchlist'], Symbols.playlist_play_rounded),
(['unwatched', 'unplayed'], Symbols.visibility_off_rounded),
(['watched', 'played'], Symbols.visibility_rounded),
// Network/Studio
(['network', 'more from'], Symbols.tv_rounded),
// Actor/Director
(['actor', 'director'], Symbols.person_rounded),
// Decades (80s, 90s, etc.)
(['80', '90', '00'], Symbols.history_rounded),
// Rediscover/Start Watching
(['rediscover', 'start watching'], Symbols.play_arrow_rounded),
// Broad library-hub keywords, last so the specific rows above keep their icons.
(['rated'], Symbols.star_rounded),
(['recommended'], Symbols.thumb_up_rounded),
(['genre'], Symbols.category_rounded),
];
+36
View File
@@ -0,0 +1,36 @@
import '../media/ids.dart';
import '../media/media_item.dart';
import 'global_key_utils.dart';
/// Builds the id filter for a screen showing [items].
///
/// Each item contributes itself plus its parent and grandparent, because an
/// event on a season or show also changes how its episodes render.
Set<String> hierarchicalEventIds(Iterable<MediaItem> items) {
final keys = <String>{};
for (final item in items) {
keys.add(item.id);
if (item.parentId != null) keys.add(item.parentId!);
if (item.grandparentId != null) keys.add(item.grandparentId!);
}
return keys;
}
/// The [hierarchicalEventIds] filter expressed as `serverId:ratingKey` keys.
///
/// Items without a server id fall back to [fallbackServerId]; if that is also
/// missing the whole filter collapses to `null`, which callers use to fall back
/// to id-only matching rather than silently under-matching.
Set<String>? hierarchicalEventGlobalKeys(Iterable<MediaItem> items, {String? fallbackServerId}) {
final keys = <String>{};
for (final item in items) {
final rawServerId = item.serverId ?? fallbackServerId;
if (rawServerId == null) return null;
final serverId = ServerId(rawServerId);
keys.add(buildGlobalKey(serverId, item.id));
if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!));
if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!));
}
return keys;
}
+6 -34
View File
@@ -10,6 +10,7 @@ import 'future_extensions.dart';
import 'isolate_helper.dart';
import 'log_redaction_manager.dart';
import 'managed_http_client.dart';
import 'url_utils.dart';
import '../exceptions/media_server_exceptions.dart';
// Platform-specific imports are conditional
@@ -412,39 +413,13 @@ class MediaServerHttpClient {
/// Append query parameters to an already-parsed URI.
Uri _appendQuery(Uri uri, Map<String, dynamic>? queryParameters) {
if (queryParameters == null || queryParameters.isEmpty) return uri;
final query = MediaServerHttpClient.encodeQueryParameters(queryParameters);
final query = encodeQueryParameters(queryParameters);
if (query.isEmpty) return uri;
final existing = uri.query;
final combined = existing.isEmpty ? query : '$existing&$query';
return uri.replace(query: combined);
}
/// Encode query params with `%20` for spaces (not `+`).
/// Null values are omitted and iterable values are emitted as repeated keys.
static String encodeQueryParameters(Map<String, Object?>? params) {
if (params == null || params.isEmpty) return '';
final parts = <String>[];
void add(String key, Object? value) {
if (value == null) return;
if (value is Iterable) {
for (final item in value) {
add(key, item);
}
return;
}
parts.add(
'${Uri.encodeComponent(key)}='
'${Uri.encodeComponent(value.toString())}',
);
}
for (final entry in params.entries) {
add(entry.key, entry.value);
}
return parts.join('&');
}
static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://');
/// Set the request body, choosing encoding based on the body type.
@@ -461,14 +436,11 @@ class MediaServerHttpClient {
return;
}
// Content type comes from the caller's headers (Jellyfin/Plex put
// `application/json` in their defaults); `request.body` falls back to
// text/plain. Don't add one here — `request.headers` is case-insensitive,
// and the setter above has already filled the key in either way.
request.body = jsonEncode(body);
// http.BaseRequest's headers map is case-sensitive; Jellyfin returns 415
// if both `Content-Type` (from defaults) and `content-type` (added below)
// end up coexisting, so check both casings before adding.
final hasContentType = request.headers.keys.any((k) => k.toLowerCase() == 'content-type');
if (!hasContentType) {
request.headers['content-type'] = 'application/json';
}
}
/// Decode the response body: lenient UTF-8, then JSON parse if applicable.
+45
View File
@@ -118,9 +118,54 @@ Future<void> playTracks(
if (context.mounted) _autoOpenNowPlayingOnTv(context);
}
/// Fetch a track list with [fetch], then play it — the shape every music
/// entry point that needs a server round-trip before playback repeats:
/// availability gate → [MusicPlaybackService.beginPlayIntent] → fetch →
/// mounted/intent re-check → [playTracks]. Guarding the round-trip with the
/// intent keeps a slow fetch from replacing a queue the user started later.
///
/// [onError] reports a failed fetch and runs only while the intent is still
/// current and [context] mounted; passing null instead lets the failure
/// propagate to the caller's own error boundary. [onEmpty] handles a
/// successful but empty fetch; passing null hands the empty list to
/// [playTracks] unchanged.
Future<void> playFetchedTracks(
BuildContext context, {
required Future<List<MediaItem>> Function() fetch,
required MusicPlayContext playContext,
void Function(Object error, StackTrace stackTrace)? onError,
VoidCallback? onEmpty,
MediaItem? startTrack,
bool shuffle = false,
}) async {
if (!ensureMusicPlaybackAvailable(context)) return;
final service = context.read<MusicPlaybackService>();
final intent = service.beginPlayIntent();
final List<MediaItem> tracks;
try {
tracks = await fetch();
} catch (error, stackTrace) {
if (!service.isPlayIntentCurrent(intent)) return;
if (onError == null) rethrow;
if (!context.mounted) return;
onError(error, stackTrace);
return;
}
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
if (tracks.isEmpty && onEmpty != null) {
onEmpty();
return;
}
await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
}
/// Play [track] within its album queue: fetch the album's tracks and start
/// at [track]. Falls back to single-track playback when the track has no
/// album, isn't found in it, or the album fetch fails.
///
/// Hand-written rather than routed through [playFetchedTracks]: the fallback
/// must play under the *same* intent as the album fetch, so a stale fallback
/// can never supersede a newer request.
Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) async {
if (!ensureMusicPlaybackAvailable(context)) return;
final service = context.read<MusicPlaybackService>();
+14 -37
View File
@@ -5,6 +5,9 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'async_singleton.dart';
import 'device_channel.dart';
const _androidFeatureTelevision = 'android.hardware.type.television';
const _androidFeatureLeanback = 'android.software.leanback';
const _androidFeatureFireTv = 'amazon.hardware.fire_tv';
@@ -30,10 +33,9 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> fea
/// Service for detecting if the app is running on Android TV or Apple TV.
class TvDetectionService {
static TvDetectionService? _instance;
static Future<void>? _initialization;
static final AsyncSingleton<TvDetectionService> _singleton = AsyncSingleton();
@visibleForTesting
static Future<void>? debugDetectionGate;
static set debugDetectionGate(Future<void>? value) => _singleton.debugGate = value;
static bool? _debugAppleTVOverride;
bool _detected = false;
bool _forceTv = false;
@@ -46,35 +48,12 @@ class TvDetectionService {
/// Get the singleton instance, initializing if needed.
/// Pass [forceTv] to combine a user override with the system-feature check.
static Future<TvDetectionService> getInstance({bool forceTv = false}) async {
final existing = _instance;
if (existing != null) {
final initialization = _initialization;
if (initialization != null) await initialization;
return existing;
}
final instance = TvDetectionService._();
_instance = instance;
final initialization = instance._detect(forceTv);
_initialization = initialization;
try {
await initialization;
} catch (_) {
if (identical(_instance, instance)) _instance = null;
rethrow;
} finally {
if (identical(_initialization, initialization)) _initialization = null;
}
return instance;
}
static Future<TvDetectionService> getInstance({bool forceTv = false}) =>
_singleton.getInstance(TvDetectionService._, (instance) => instance._detect(forceTv));
static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
Future<void> _detect(bool forceTv) async {
final gate = debugDetectionGate;
if (gate != null) await gate;
if (_initialized) return;
final deviceInfo = DeviceInfoPlugin();
@@ -120,7 +99,7 @@ class TvDetectionService {
Future<AndroidTvFeatureDetection?> _getNativeAndroidTvDetection() async {
try {
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getTvDetection');
final result = await deviceChannel.invokeMapMethod<dynamic, dynamic>('getTvDetection');
if (result == null) return null;
final reasonsValue = result['reasons'];
final reasons = reasonsValue is Iterable ? reasonsValue.whereType<String>().toList() : <String>[];
@@ -139,7 +118,7 @@ class TvDetectionService {
static Future<String?> getAndroidDeviceName() async {
if (!Platform.isAndroid) return null;
try {
final name = (await _deviceChannel.invokeMethod<String>('getDeviceName'))?.trim();
final name = (await deviceChannel.invokeMethod<String>('getDeviceName'))?.trim();
return (name == null || name.isEmpty) ? null : name;
} on MissingPluginException {
return null;
@@ -155,10 +134,10 @@ class TvDetectionService {
}
/// Synchronous access after initialization (returns false if not initialized)
static bool isTVSync() => _debugAppleTVOverride ?? _instance?._isTV ?? false;
static bool isTVSync() => _debugAppleTVOverride ?? _singleton.instance?._isTV ?? false;
/// Synchronous Apple TV check (returns false if not initialized or not tvOS).
static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _instance?._isAppleTV == true);
static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _singleton.instance?._isAppleTV == true);
@visibleForTesting
static void debugSetAppleTVOverride(bool? value) {
@@ -167,16 +146,14 @@ class TvDetectionService {
@visibleForTesting
static void debugReset() {
_instance = null;
_initialization = null;
debugDetectionGate = null;
_singleton.debugReset();
_debugAppleTVOverride = null;
}
static List<String> tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const [];
static List<String> tvDetectionReasonsSync() => _singleton.instance?._effectiveDetectionReasons ?? const [];
/// Convenience setter that forwards to the singleton if available.
static void setForceTVSync(bool value) => _instance?.setForceTv(value);
static void setForceTVSync(bool value) => _singleton.instance?.setForceTv(value);
}
class PlatformDetector {
+30
View File
@@ -14,6 +14,36 @@ String stripTrailingSlash(String input) {
return trimmed;
}
/// Encode query params with `%20` for spaces (not `+`).
/// Null values are omitted and iterable values are emitted as repeated keys.
///
/// Used instead of `Uri.queryParameters` (which emits `+` for spaces) wherever
/// the server rejects `+` — Plex's transcode endpoints and Seerr's TMDB-backed
/// `/search` proxy both do.
String encodeQueryParameters(Map<String, Object?>? params) {
if (params == null || params.isEmpty) return '';
final parts = <String>[];
void add(String key, Object? value) {
if (value == null) return;
if (value is Iterable) {
for (final item in value) {
add(key, item);
}
return;
}
parts.add(
'${Uri.encodeComponent(key)}='
'${Uri.encodeComponent(value.toString())}',
);
}
for (final entry in params.entries) {
add(entry.key, entry.value);
}
return parts.join('&');
}
final RegExp _schemePattern = RegExp(r'^[A-Za-z][A-Za-z\d+.-]*://');
/// Canonicalizes a server base URL: trims, strips one trailing `/`, and