fix: eliminate cross-app consistency drift

This commit is contained in:
edde746
2026-07-13 23:13:53 +02:00
parent ec249d2ed4
commit d5f3e581df
142 changed files with 4751 additions and 1075 deletions
+19 -18
View File
@@ -1,24 +1,25 @@
import '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import 'app_logger.dart';
import '../exceptions/media_server_exceptions.dart';
/// Shared helpers for translating network errors into user-friendly messages.
String mapHttpErrorToMessage(MediaServerHttpException error, {required String context}) {
switch (error.type) {
case MediaServerHttpErrorType.connectionTimeout:
case MediaServerHttpErrorType.receiveTimeout:
return t.errors.connectionTimeout(context: context);
case MediaServerHttpErrorType.connectionError:
return t.errors.connectionFailed;
default:
appLogger.e('Error loading $context', error: error);
final msg = error.message.isNotEmpty ? error.message : t.common.unknown;
return t.errors.failedToLoad(context: context, error: msg);
/// Logs a load failure once and returns a localized, user-safe message.
///
/// The returned text never includes exception or server response content.
String localizedLoadErrorMessage(Object error, StackTrace stackTrace, {required String context}) {
appLogger.e('Error loading $context', error: error, stackTrace: stackTrace);
if (error is MediaServerHttpException) {
switch (error.type) {
case MediaServerHttpErrorType.connectionTimeout:
case MediaServerHttpErrorType.receiveTimeout:
return t.errors.connectionTimeout(context: context);
case MediaServerHttpErrorType.connectionError:
return t.errors.connectionFailed;
case MediaServerHttpErrorType.cancelled:
case MediaServerHttpErrorType.unknown:
break;
}
}
}
/// Generic fallback for unexpected errors.
String mapUnexpectedErrorToMessage(dynamic error, {required String context}) {
appLogger.e('Unexpected error in $context', error: error);
return t.errors.failedToLoad(context: context, error: error.toString());
return t.errors.unableToLoad(context: context);
}
+3 -3
View File
@@ -184,10 +184,10 @@ String formatRating(double value) =>
final RegExp _trailingZeroPattern = RegExp(r'\.?0+$');
/// Format a playback rate for display (e.g. 1.25 → "1.25x", 2.0 → "2x").
/// When [normalAtOne] is true, 1.0 renders as "Normal" for menu labels;
/// the in-player pill passes false to keep a numeric indicator.
/// When [normalAtOne] is true, 1.0 renders with the localized normal-speed
/// label for menus; the in-player pill passes false to keep a numeric indicator.
String formatPlaybackRate(double rate, {bool normalAtOne = false}) {
if (normalAtOne && (rate - 1.0).abs() < 0.005) return 'Normal';
if (normalAtOne && (rate - 1.0).abs() < 0.005) return t.videoSettings.normalSpeed;
return '${rate.toStringAsFixed(2).replaceFirst(_trailingZeroPattern, '')}x';
}
+60
View File
@@ -1,7 +1,15 @@
import 'dart:convert';
import 'dart:math';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/widgets.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../services/device_performance.dart';
import '../services/image_cache_service.dart';
import '../services/settings_service.dart' show EpisodePosterMode;
import 'platform_detector.dart';
/// Image types for different transcoding strategies
@@ -10,6 +18,7 @@ enum ImageType {
art, // Wide background art
thumb, // 16:9 episode thumbnails
logo, // Variable ratio clear logos
heroLogo, // Large hero clear logos
avatar, // Square-ish user avatars
square, // 1:1 music artwork (albums, artists, tracks)
}
@@ -107,6 +116,7 @@ class MediaImageHelper {
return roundDimensions(coverWidth, coverHeight);
case ImageType.logo:
case ImageType.heroLogo:
final logoWidth = targetWidth;
final logoHeight = targetHeight;
return roundDimensions(logoWidth, logoHeight);
@@ -243,6 +253,7 @@ class MediaImageHelper {
ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight),
ImageType.art => (1920, 1080),
ImageType.logo => (600, 300),
ImageType.heroLogo => (1000, 500),
ImageType.avatar => (300, 300),
};
@@ -262,6 +273,55 @@ class MediaImageHelper {
return ResizeImage(provider, width: width, height: height, policy: ResizeImagePolicy.fit);
}
/// Selects the decode/transcode shape used by media cards and their
/// prefetchers. Keeping this derived from [MediaItem.cardShape] prevents the
/// renderer and prefetch pipeline from assigning different cache budgets.
static ImageType cardImageType(MediaItem item, EpisodePosterMode episodePosterMode, {bool mixedHubContext = false}) {
return switch (item.cardShape(episodePosterMode, mixedHubContext: mixedHubContext)) {
CardShape.square => ImageType.square,
CardShape.wide => ImageType.thumb,
CardShape.poster => ImageType.poster,
};
}
/// Creates the final provider for server-hosted artwork.
///
/// The disk key deliberately depends only on the fully bucketed URL. Decode
/// dimensions belong to Flutter's memory-cache key and must not fragment the
/// shared disk cache during small layout changes.
static ImageProvider serverArtworkProvider({
required String imageUrl,
required int memWidth,
required int memHeight,
String? cacheKey,
}) {
final provider = CachedNetworkImageProvider(
imageUrl,
cacheKey: cacheKey ?? _serverArtworkCacheKey(imageUrl),
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
);
return boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
}
static final _serverArtworkCacheKeys = <String, String>{};
static const _serverArtworkCacheKeyLimit = 512;
static String _serverArtworkCacheKey(String imageUrl) {
final cached = _serverArtworkCacheKeys.remove(imageUrl);
if (cached != null) {
_serverArtworkCacheKeys[imageUrl] = cached;
return cached;
}
final key = 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}';
if (_serverArtworkCacheKeys.length >= _serverArtworkCacheKeyLimit) {
_serverArtworkCacheKeys.remove(_serverArtworkCacheKeys.keys.first);
}
_serverArtworkCacheKeys[imageUrl] = key;
return key;
}
/// Determines if an image path is suitable for transcoding
static bool shouldTranscode(String? imagePath) {
if (imagePath == null || imagePath.isEmpty) return false;
+12 -9
View File
@@ -140,8 +140,8 @@ bool shouldOpenEpisodeDetailsForActivation({
/// For artists/albums, navigates to the music detail screens; tracks start
/// playback in their album queue.
///
/// The [onRefresh] callback is invoked with the item's id after returning from
/// the detail screen, allowing the caller to refresh state.
/// The [onRefresh] callback is invoked with the source item after returning
/// from the detail screen, preserving its server-qualified identity.
///
/// Set [isOffline] to true for downloaded content without server access.
///
@@ -156,13 +156,16 @@ bool shouldOpenEpisodeDetailsForActivation({
Future<MediaNavigationResult> navigateToMediaItem(
BuildContext context,
Object item, {
void Function(String)? onRefresh,
void Function(MediaItem source)? onRefresh,
bool isOffline = false,
bool playDirectly = false,
}) async {
if (item is MediaPlaylist) {
await Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistDetailScreen(playlist: item)));
return MediaNavigationResult.navigated;
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (context) => PlaylistDetailScreen(playlist: item)),
);
return result == true ? MediaNavigationResult.listRefreshNeeded : MediaNavigationResult.navigated;
}
if (item is! MediaItem) {
@@ -233,7 +236,7 @@ Future<MediaNavigationResult> navigateToMediaItem(
}
final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline);
if (result == true && context.mounted) {
onRefresh?.call(mi.id);
onRefresh?.call(mi);
}
return MediaNavigationResult.navigated;
@@ -241,7 +244,7 @@ Future<MediaNavigationResult> navigateToMediaItem(
if (playDirectly && !shouldOpenContinueWatchingDetails) {
final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline);
if (result == true && context.mounted) {
onRefresh?.call(mi.id);
onRefresh?.call(mi);
}
return MediaNavigationResult.navigated;
}
@@ -259,7 +262,7 @@ Future<MediaNavigationResult> navigateToMediaItemDetails(
BuildContext context,
MediaItem mi, {
bool isOffline = false,
void Function(String)? onRefresh,
void Function(MediaItem source)? onRefresh,
MediaItem? metadataOverride,
}) async {
// Catalog stand-ins (Explore tab) must never reach MediaDetailScreen — it
@@ -284,7 +287,7 @@ Future<MediaNavigationResult> navigateToMediaItemDetails(
),
);
if (result == true && context.mounted) {
onRefresh?.call(mi.id);
onRefresh?.call(mi);
}
return MediaNavigationResult.navigated;
}