Files
plezy/lib/services/system_shelf_service.dart
T
edde746 352b88109b 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.
2026-07-26 06:09:48 +02:00

320 lines
11 KiB
Dart

import 'dart:io' show Platform;
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import 'settings_service.dart' show EpisodePosterMode;
/// Syncs Continue Watching content to platform launcher surfaces.
///
/// Android uses the Watch Next row. tvOS uses the app's Top Shelf extension.
class SystemShelfService {
static const int schemaVersion = 2;
static const MethodChannel _androidChannel = MethodChannel('com.plezy/watch_next');
static const MethodChannel _tvosChannel = MethodChannel('com.plezy/system_shelf');
static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
static final SystemShelfService _instance = SystemShelfService._internal();
static SystemShelfService? _testingInstance;
factory SystemShelfService() => _testingInstance ?? _instance;
SystemShelfService._internal() : _channelOverride = null, _supportOverride = null {
_androidChannel.setMethodCallHandler(_handleMethodCall);
_tvosChannel.setMethodCallHandler(_handleMethodCall);
}
@visibleForTesting
SystemShelfService.forTesting({required MethodChannel channel, Future<bool> Function()? isSupported})
: _channelOverride = channel,
_supportOverride = isSupported;
@visibleForTesting
static void debugOverrideInstance(SystemShelfService? service) {
_testingInstance = service;
}
final MethodChannel? _channelOverride;
final Future<bool> Function()? _supportOverride;
String? _activeOwner;
int _generation = 0;
Future<void> _mutationTail = Future<void>.value();
@visibleForTesting
String? get debugActiveOwner => _activeOwner;
@visibleForTesting
int get debugGeneration => _generation;
@visibleForTesting
Future<void> debugReset() async {
await _mutationTail;
_activeOwner = null;
_generation = 0;
_mutationTail = Future<void>.value();
}
/// Callback for warm-start launcher surface taps.
ValueChanged<String>? onShelfItemTap;
MethodChannel? get _channel {
final override = _channelOverride;
if (override != null) return override;
if (Platform.isAndroid) return _androidChannel;
if (Platform.isIOS && (_tvosBuild || PlatformDetector.isAppleTV())) return _tvosChannel;
return null;
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
if (call.method != 'onWatchNextTap' && call.method != 'onShelfItemTap') {
return null;
}
final args = call.arguments;
final contentId = args is Map ? args['contentId'] as String? : null;
final callback = onShelfItemTap;
if (contentId == null || callback == null) return false;
callback(contentId);
return true;
}
@visibleForTesting
Future<dynamic> handleMethodCallForTesting(MethodCall call) => _handleMethodCall(call);
/// Establishes the only owner allowed to publish launcher shelf state.
///
/// Ownership changes are synchronous. Native mutations remain serialized
/// behind any clear already queued for the previous owner.
void beginProfileSession(String profileId) {
if (profileId.isEmpty) {
throw ArgumentError.value(profileId, 'profileId', 'must not be empty');
}
if (_activeOwner == profileId) return;
_activeOwner = profileId;
_generation++;
}
/// Invalidates [profileId] synchronously, then clears its native shelf after
/// every already-dispatched mutation has settled.
Future<void> endProfileSession(String profileId) async {
if (_activeOwner != profileId) return;
_activeOwner = null;
final generation = ++_generation;
await _enqueueMutation<void>(() async {
final channel = _channel;
if (channel == null) return;
await _invokeGuarded<bool>(
channel,
'clear',
arguments: _envelope(profileId, generation),
label: 'Failed to clear system shelf',
severe: true,
);
});
}
bool _owns(String profileId, int generation) {
return _activeOwner == profileId && _generation == generation;
}
Future<T?> _enqueueMutation<T>(Future<T> Function() mutation) {
final completer = Completer<T?>();
_mutationTail = _mutationTail
.then((_) async {
try {
completer.complete(await mutation());
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
})
.catchError((Object error, StackTrace stackTrace) {
appLogger.w('System shelf mutation failed', error: error, stackTrace: stackTrace);
});
return completer.future;
}
/// Owner-scoped envelope every mutating native call carries.
static Map<String, dynamic> _envelope(String profileId, int generation, [Map<String, dynamic>? extra]) {
return {'schemaVersion': schemaVersion, 'ownerId': profileId, 'generation': generation, ...?extra};
}
/// Invokes [method] on [channel], logging channel failures under [label] (at
/// error level when [severe]) and returning null instead of throwing.
/// Errors that are not channel failures log [failureLabel] when given.
Future<T?> _invokeGuarded<T>(
MethodChannel channel,
String method, {
Map<String, dynamic>? arguments,
required String label,
String? failureLabel,
bool severe = false,
}) async {
final log = severe ? appLogger.e : appLogger.w;
try {
return await channel.invokeMethod<T>(method, arguments);
} on MissingPluginException catch (e) {
log('$label: native channel missing', error: e);
} on PlatformException catch (e) {
log('$label: native platform error', error: e);
} catch (e) {
log(failureLabel ?? label, error: e);
}
return null;
}
/// Get a pending deep link from cold start (consumed on first call).
Future<String?> getInitialDeepLink() async {
final channel = _channel;
if (channel == null) return null;
return _invokeGuarded<String>(
channel,
'getInitialDeepLink',
label: 'System shelf initial deep link failed',
failureLabel: 'Failed to get system shelf initial deep link',
);
}
/// Check whether the current platform has a launcher shelf integration.
Future<bool> isSupported() async {
final override = _supportOverride;
if (override != null) return override();
final channel = _channel;
if (channel == null) return false;
return await _invokeGuarded<bool>(
channel,
'isSupported',
label: 'System shelf unsupported',
failureLabel: 'System shelf unsupported: native support check failed',
) ??
false;
}
/// Sync Continue Watching items for the currently active [profileId].
Future<bool> syncFromContinueWatching(
String profileId,
List<MediaItem> continueWatchingItems,
MediaServerClient Function(ServerId serverId) getClientForServerId, {
bool hideSpoilers = false,
}) async {
final channel = _channel;
if (channel == null || _activeOwner != profileId) return false;
final generation = _generation;
final items = continueWatchingItems
.map((item) {
return _convertToShelfItem(item, getClientForServerId, hideSpoilers: hideSpoilers);
})
.toList(growable: false);
if (!_owns(profileId, generation)) return false;
final supported = await isSupported();
if (!_owns(profileId, generation) || !supported) return false;
final result = await _enqueueMutation<bool>(() async {
if (!_owns(profileId, generation)) return false;
return await _invokeGuarded<bool>(
channel,
'sync',
arguments: _envelope(profileId, generation, {'items': items}),
label: 'Failed to sync system shelf',
severe: true,
) ??
false;
});
return result ?? false;
}
/// Remove a single launcher shelf item for the current profile owner.
Future<bool> removeItem(String profileId, ServerId serverId, String ratingKey) async {
final channel = _channel;
if (channel == null || _activeOwner != profileId) return false;
final generation = _generation;
final result = await _enqueueMutation<bool>(() async {
if (!_owns(profileId, generation)) return false;
return await _invokeGuarded<bool>(
channel,
'remove',
arguments: _envelope(profileId, generation, {'contentId': _buildContentId(serverId, ratingKey)}),
label: 'Failed to remove system shelf item',
severe: true,
) ??
false;
});
return result ?? false;
}
/// Build a content ID. Format: plezy_{serverId}_{ratingKey}
static String _buildContentId(ServerId? serverId, String ratingKey) {
return 'plezy_${serverId ?? 'unknown'}_$ratingKey';
}
/// Parse a content ID back to (serverId, ratingKey), or null if invalid.
static (ServerId serverId, String ratingKey)? parseContentId(String contentId) {
if (!contentId.startsWith('plezy_')) return null;
final parts = contentId.substring(6).split('_');
if (parts.length < 2) return null;
return (ServerId(parts.first), parts.sublist(1).join('_'));
}
Map<String, dynamic> _convertToShelfItem(
MediaItem item,
MediaServerClient Function(ServerId serverId) getClientForServerId, {
bool hideSpoilers = false,
}) {
final contentId = _buildContentId(serverIdOrNull(item.serverId), item.id);
String? posterSourceUri;
try {
if (item.serverId != null) {
final client = getClientForServerId(ServerId(item.serverId!));
String? thumbPath;
if (hideSpoilers && item.shouldHideSpoiler) {
thumbPath = item.spoilerSafeArt;
}
thumbPath ??= item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true);
if (thumbPath != null) {
posterSourceUri = client.thumbnailUrl(thumbPath, width: 640, height: 360);
}
}
} catch (_) {
appLogger.w('Failed to prepare system shelf artwork');
}
final String title;
final String? episodeTitle;
if (item.kind == MediaKind.episode && item.grandparentTitle != null) {
title = item.grandparentTitle!;
episodeTitle = item.title;
} else {
title = item.title ?? '';
episodeTitle = null;
}
final lastEngagementTime = item.lastViewedAt != null
? item.lastViewedAt! * 1000
: DateTime.now().millisecondsSinceEpoch;
return {
'contentId': contentId,
'title': title,
'episodeTitle': episodeTitle,
'description': item.summary,
'posterSourceUri': posterSourceUri,
'type': item.kind.name,
'duration': item.durationMs ?? 0,
'lastPlaybackPosition': item.viewOffsetMs ?? 0,
'lastEngagementTime': lastEngagementTime,
'seriesTitle': item.grandparentTitle,
'seasonNumber': item.parentIndex,
'episodeNumber': item.index,
};
}
}