fix: eliminate cross-app consistency drift
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user