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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user