refactor: strip obvious comments
This commit is contained in:
@@ -35,17 +35,12 @@ class LogEntry {
|
||||
/// Estimate the memory size of this log entry in bytes
|
||||
int get estimatedSize {
|
||||
int size = 0;
|
||||
// DateTime: ~8 bytes
|
||||
size += 8;
|
||||
// Level enum: ~4 bytes
|
||||
size += 4;
|
||||
// Message string: 2 bytes per character (UTF-16)
|
||||
size += message.length * 2;
|
||||
// Error string: 2 bytes per character if present
|
||||
if (error != null) {
|
||||
size += error.toString().length * 2;
|
||||
}
|
||||
// Stack trace string: 2 bytes per character if present
|
||||
if (stackTrace != null) {
|
||||
size += stackTrace.toString().length * 2;
|
||||
}
|
||||
@@ -58,25 +53,21 @@ class LogEntry {
|
||||
/// Storage is handled by [MemoryAwareLogPrinter.log()] — this class only
|
||||
/// forwards formatted lines to the console via the default [ConsoleOutput].
|
||||
class MemoryLogOutput extends LogOutput {
|
||||
static const int maxLogSizeBytes = 5 * 1024 * 1024; // 5 MB
|
||||
static const int maxLogSizeBytes = 5 * 1024 * 1024;
|
||||
static final ListQueue<LogEntry> _logs = ListQueue<LogEntry>();
|
||||
static int _currentSize = 0;
|
||||
|
||||
static final _consoleOutput = ConsoleOutput();
|
||||
|
||||
/// Get all stored logs (newest first)
|
||||
static List<LogEntry> getLogs() => _logs.toList().reversed.toList();
|
||||
|
||||
/// Clear all stored logs
|
||||
static void clearLogs() {
|
||||
_logs.clear();
|
||||
_currentSize = 0;
|
||||
}
|
||||
|
||||
/// Get current log buffer size in bytes
|
||||
static int getCurrentSize() => _currentSize;
|
||||
|
||||
/// Get current log buffer size in MB
|
||||
static double getCurrentSizeMB() => _currentSize / (1024 * 1024);
|
||||
|
||||
@override
|
||||
@@ -115,7 +106,6 @@ class MemoryAwareLogPrinter extends LogPrinter {
|
||||
MemoryLogOutput._currentSize -= removed.estimatedSize;
|
||||
}
|
||||
|
||||
// Delegate a redacted event to the wrapped printer for console output.
|
||||
return _wrappedPrinter.log(
|
||||
LogEvent(event.level, message, time: event.time, error: error, stackTrace: event.stackTrace),
|
||||
);
|
||||
@@ -136,7 +126,6 @@ class ProductionFilter extends LogFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Global filter instance
|
||||
final _productionFilter = ProductionFilter();
|
||||
|
||||
/// Centralized logger instance for the application.
|
||||
@@ -161,13 +150,9 @@ Logger appLogger = Logger(
|
||||
void setLoggerLevel(bool debugEnabled) {
|
||||
final newLevel = debugEnabled ? Level.debug : Level.info;
|
||||
|
||||
// Update the filter level
|
||||
_productionFilter.setLevel(newLevel);
|
||||
|
||||
// Recreate the logger instance with the new level
|
||||
// This ensures it works in release mode where Logger.level might be optimized away
|
||||
appLogger = Logger(printer: MemoryAwareLogPrinter(SimplePrinter()), filter: _productionFilter, level: newLevel);
|
||||
|
||||
// Also set the static level for consistency
|
||||
Logger.level = newLevel;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
class CodecUtils {
|
||||
CodecUtils._();
|
||||
|
||||
/// Maps Plex subtitle codec names to file extensions.
|
||||
///
|
||||
/// Returns the appropriate file extension for a given subtitle codec.
|
||||
/// Defaults to 'srt' for unknown or null codecs.
|
||||
static String getSubtitleExtension(String? codec) {
|
||||
if (codec == null) return 'srt';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
/// Content type constants used throughout the app
|
||||
class ContentTypes {
|
||||
ContentTypes._();
|
||||
|
||||
@@ -21,17 +20,13 @@ class ContentTypes {
|
||||
static const Set<String> playableTypes = {movie, episode, clip, track};
|
||||
}
|
||||
|
||||
/// Utility class for content type checking and filtering
|
||||
class ContentTypeHelper {
|
||||
ContentTypeHelper._();
|
||||
|
||||
/// Checks if the given type is music content (artist, album, or track)
|
||||
static bool isMusicContent(String type) => ContentTypes.musicTypes.contains(type.toLowerCase());
|
||||
|
||||
/// Checks if the given type is video content (movie, show, episode, or season)
|
||||
static bool isVideoContent(String type) => ContentTypes.videoTypes.contains(type.toLowerCase());
|
||||
|
||||
/// Checks if the given [MediaLibrary] is a music library.
|
||||
static bool isMusicLibrary(dynamic lib) {
|
||||
if (lib == null) return false;
|
||||
try {
|
||||
@@ -43,7 +38,6 @@ class ContentTypeHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate icon for a given library type
|
||||
static IconData getLibraryIcon(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case ContentTypes.movie:
|
||||
|
||||
@@ -60,10 +60,8 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
|
||||
|
||||
DeletionNotifier._internal();
|
||||
|
||||
/// Filter for events affecting a specific server
|
||||
Stream<DeletionEvent> forServer(String serverId) => stream.where((e) => e.serverId == serverId);
|
||||
|
||||
/// Filter for events affecting a specific item or its children
|
||||
Stream<DeletionEvent> forItem(String itemId) => stream.where((e) => e.affectsItem(itemId));
|
||||
|
||||
/// Emit a deletion event with logging
|
||||
@@ -73,7 +71,6 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
|
||||
super.notify(event);
|
||||
}
|
||||
|
||||
/// Helper to emit a deletion event from a [MediaItem].
|
||||
void notifyDeletedItem({required MediaItem item, bool isDownloadOnly = false}) {
|
||||
notify(
|
||||
DeletionEvent(
|
||||
|
||||
@@ -43,12 +43,10 @@ class DesktopAppBarHelper {
|
||||
rightPadding = DesktopWindowPadding.mobileRight;
|
||||
}
|
||||
|
||||
// If no platform-specific padding needed, return original actions
|
||||
if (rightPadding == null) {
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Add padding to keep actions away from edge
|
||||
return actions != null ? [...actions, SizedBox(width: rightPadding)] : [SizedBox(width: rightPadding)];
|
||||
}
|
||||
|
||||
@@ -61,7 +59,6 @@ class DesktopAppBarHelper {
|
||||
return leading;
|
||||
}
|
||||
|
||||
// Skip left padding when side navigation scope is present in widget tree
|
||||
if (context != null && SideNavigationScope.isPresent(context)) {
|
||||
if (includeGestureDetector) {
|
||||
return GestureDetector(
|
||||
@@ -120,7 +117,6 @@ class DesktopAppBarHelper {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip extra width when side navigation scope is present in widget tree
|
||||
if (context != null && SideNavigationScope.isPresent(context)) {
|
||||
return null;
|
||||
}
|
||||
@@ -165,8 +161,6 @@ class DesktopTitleBarPadding extends StatelessWidget {
|
||||
return child;
|
||||
}
|
||||
|
||||
// Skip left padding when side navigation scope is present in widget tree
|
||||
// (side nav already handles the traffic lights area)
|
||||
if (SideNavigationScope.isPresent(context)) {
|
||||
final right = rightPadding ?? 0.0;
|
||||
if (right == 0.0) {
|
||||
|
||||
@@ -10,8 +10,6 @@ import '../widgets/dialog_action_button.dart';
|
||||
import '../widgets/focusable_list_tile.dart';
|
||||
import 'focus_utils.dart';
|
||||
|
||||
/// Utility functions for showing common dialogs
|
||||
|
||||
const _buttonPadding = EdgeInsets.symmetric(horizontal: 18, vertical: 14);
|
||||
const _buttonShape = StadiumBorder();
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
|
||||
maxCount = customCount;
|
||||
}
|
||||
|
||||
// For unwatched-based options on shows, offer sync vs one-time download
|
||||
if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {
|
||||
final syncChoice = await showOptionPickerDialog<_SyncChoice>(
|
||||
context,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Utility class for common focus operations
|
||||
class FocusUtils {
|
||||
FocusUtils._();
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ String padNumber(int number, int width) {
|
||||
return number.toString().padLeft(width, '0');
|
||||
}
|
||||
|
||||
/// Utility class for formatting byte sizes and speeds
|
||||
class ByteFormatter {
|
||||
ByteFormatter._();
|
||||
|
||||
@@ -19,9 +18,6 @@ class ByteFormatter {
|
||||
static const int _gb = _mb * 1024;
|
||||
|
||||
/// Format bytes to human-readable string (e.g., "1.5 GB", "256.3 MB")
|
||||
///
|
||||
/// [bytes] The number of bytes to format
|
||||
/// [decimals] Number of decimal places (default: 1 for KB/MB, 2 for GB)
|
||||
static String formatBytes(int bytes, {int? decimals}) {
|
||||
if (bytes < _kb) return '$bytes B';
|
||||
if (bytes < _mb) {
|
||||
@@ -34,8 +30,6 @@ class ByteFormatter {
|
||||
}
|
||||
|
||||
/// Format speed in bytes per second to human-readable string
|
||||
///
|
||||
/// [bytesPerSecond] The speed in bytes per second
|
||||
static String formatSpeed(double bytesPerSecond) {
|
||||
if (bytesPerSecond < _kb) {
|
||||
return '${bytesPerSecond.toStringAsFixed(0)} B/s';
|
||||
@@ -47,8 +41,6 @@ class ByteFormatter {
|
||||
}
|
||||
|
||||
/// Format bitrate in kbps to human-readable string
|
||||
///
|
||||
/// [kbps] The bitrate in kilobits per second
|
||||
static String formatBitrate(int kbps) {
|
||||
if (kbps < 1000) return '$kbps kbps';
|
||||
return '${(kbps / 1000).toStringAsFixed(1)} Mbps';
|
||||
@@ -63,17 +55,13 @@ class ByteFormatter {
|
||||
String formatDurationTextual(int milliseconds, {bool abbreviated = true}) {
|
||||
final duration = Duration(milliseconds: milliseconds);
|
||||
|
||||
// Get the appropriate locale for the duration package
|
||||
final durationLocale = _getDurationLocale();
|
||||
|
||||
// Format with abbreviated or full units (h, m) but no seconds
|
||||
return prettyDuration(
|
||||
duration,
|
||||
abbreviated: abbreviated,
|
||||
locale: durationLocale,
|
||||
delimiter: abbreviated ? ' ' : ', ',
|
||||
spacer: '',
|
||||
// Configure to show only hours and minutes
|
||||
tersity: DurationTersity.minute,
|
||||
);
|
||||
}
|
||||
@@ -87,14 +75,12 @@ String formatDurationWithSeconds(Duration duration) {
|
||||
// Get the appropriate locale for the duration package
|
||||
final durationLocale = _getDurationLocale();
|
||||
|
||||
// Format with abbreviated units (h, m, s) including seconds
|
||||
return prettyDuration(
|
||||
duration,
|
||||
abbreviated: true,
|
||||
locale: durationLocale,
|
||||
delimiter: ' ',
|
||||
spacer: '',
|
||||
// Show all non-zero units
|
||||
tersity: DurationTersity.second,
|
||||
);
|
||||
}
|
||||
@@ -105,7 +91,6 @@ String formatDurationWithSeconds(Duration duration) {
|
||||
///
|
||||
/// Used for: video controls, chapters, episode durations.
|
||||
String formatDurationTimestamp(Duration duration) {
|
||||
// Handle negative durations
|
||||
final isNegative = duration.isNegative;
|
||||
final absoluteDuration = duration.abs();
|
||||
|
||||
@@ -130,13 +115,11 @@ String formatSyncOffset(double offsetMs) {
|
||||
final durationLocale = _getDurationLocale();
|
||||
|
||||
if (absMs >= 10000) {
|
||||
// For values >= 10s, show decimal seconds (e.g., "+15.1s")
|
||||
final seconds = (offsetMs.abs() / 1000).toStringAsFixed(1);
|
||||
final unit = durationLocale.second(1, true);
|
||||
return '$sign$seconds$unit';
|
||||
}
|
||||
|
||||
// For values < 10s, show milliseconds (e.g., "+7300ms")
|
||||
final unit = durationLocale.millisecond(1, true);
|
||||
return '$sign$absMs$unit';
|
||||
}
|
||||
@@ -144,7 +127,6 @@ String formatSyncOffset(double offsetMs) {
|
||||
/// Gets the duration package locale based on the current app locale.
|
||||
/// Falls back to English if the locale is not supported by the duration package.
|
||||
DurationLocale _getDurationLocale() {
|
||||
// Get the current locale from slang's LocaleSettings
|
||||
final appLocale = LocaleSettings.currentLocale;
|
||||
final languageCode = appLocale.languageCode;
|
||||
|
||||
@@ -154,7 +136,6 @@ DurationLocale _getDurationLocale() {
|
||||
try {
|
||||
return DurationLocale.fromLanguageCode(languageCode) ?? const EnglishDurationLocale();
|
||||
} catch (e) {
|
||||
// Fallback to English if language code is not supported
|
||||
return const EnglishDurationLocale();
|
||||
}
|
||||
}
|
||||
@@ -188,7 +169,6 @@ String formatFinishTime(Duration remaining, {double rate = 1.0, required bool is
|
||||
return formatClockTime(finishTime, is24Hour: is24Hour);
|
||||
}
|
||||
|
||||
/// Takes a list of strings and returns one long string with each item in the list concatenated by a bullet
|
||||
String toBulletedString(List<String> parts) {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -207,10 +187,8 @@ String formatPlaybackRate(double rate, {bool normalAtOne = false}) {
|
||||
/// If there is any error, `dateString` is returned as is
|
||||
String formatFullDate(String dateString) {
|
||||
try {
|
||||
// Parse the date
|
||||
final date = DateTime.parse(dateString);
|
||||
|
||||
// Create a DateFormat with the full date pattern for the current locale
|
||||
final formatter = DateFormat.yMMMMd(LocaleSettings.currentLocale.languageCode);
|
||||
|
||||
return formatter.format(date);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// Builds a globalKey string from [serverId] and [ratingKey].
|
||||
String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKey';
|
||||
|
||||
/// Separator used by profile-owned rows whose public media identity is still
|
||||
|
||||
@@ -3,7 +3,6 @@ import '../services/settings_service.dart' show LibraryDensity;
|
||||
import 'layout_constants.dart';
|
||||
import 'platform_detector.dart';
|
||||
|
||||
/// Utility class for calculating consistent grid sizes across the app
|
||||
class GridSizeCalculator {
|
||||
static double _lerp(double min, double max, double t) => min + (max - min) * t;
|
||||
|
||||
@@ -68,12 +67,10 @@ class GridSizeCalculator {
|
||||
return availableWidth / columns;
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of a grid with given column count.
|
||||
static bool isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first column of a grid with given column count.
|
||||
static bool isFirstColumn(int index, int columnCount) {
|
||||
return index % columnCount == 0;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@ mixin HierarchicalEventMixin {
|
||||
/// The id of the affected item (Plex ratingKey, Jellyfin GUID, …).
|
||||
String get itemId;
|
||||
|
||||
/// Composite key: serverId:itemId.
|
||||
String get globalKey;
|
||||
|
||||
/// Server this item belongs to.
|
||||
String get serverId;
|
||||
|
||||
/// Parent chain for hierarchical matching.
|
||||
|
||||
@@ -19,7 +19,6 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
|
||||
// Walk up the focus tree dispatching the key event
|
||||
FocusNode? node = focusNode;
|
||||
KeyEventResult result = KeyEventResult.ignored;
|
||||
|
||||
@@ -30,7 +29,6 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
// Send key up event
|
||||
final keyUpEvent = KeyUpEvent(
|
||||
physicalKey: physicalKey,
|
||||
logicalKey: logicalKey,
|
||||
|
||||
@@ -3,42 +3,29 @@ import 'package:flutter/widgets.dart';
|
||||
/// Layout and sizing constants used throughout the application
|
||||
/// Screen width breakpoints for responsive design
|
||||
class ScreenBreakpoints {
|
||||
/// Breakpoint for mobile devices (< 600px)
|
||||
static const double mobile = 600;
|
||||
|
||||
/// Breakpoint for wide tablets / small desktops (900px)
|
||||
/// Used for intermediate responsive layouts
|
||||
static const double wideTablet = 900;
|
||||
|
||||
/// Breakpoint for desktop devices (1200px)
|
||||
static const double desktop = 1200;
|
||||
|
||||
/// Breakpoint for large desktop devices (1600px)
|
||||
static const double largeDesktop = 1600;
|
||||
|
||||
// Legacy alias for backward compatibility
|
||||
static const double tablet = mobile;
|
||||
|
||||
/// Whether width is mobile-sized (< 600px)
|
||||
static bool isMobile(double width) => width < mobile;
|
||||
|
||||
/// Whether width is tablet-sized (600px - 1199px)
|
||||
static bool isTablet(double width) => width >= mobile && width < desktop;
|
||||
|
||||
/// Whether width is wide tablet (900px - 1199px)
|
||||
/// Useful for layouts that need more columns than phone but less than desktop
|
||||
static bool isWideTablet(double width) => width >= wideTablet && width < desktop;
|
||||
|
||||
/// Whether width is desktop-sized (1200px - 1599px)
|
||||
static bool isDesktop(double width) => width >= desktop && width < largeDesktop;
|
||||
|
||||
/// Whether width is large desktop-sized (>= 1600px)
|
||||
static bool isLargeDesktop(double width) => width >= largeDesktop;
|
||||
|
||||
/// Whether width is desktop or larger (>= 1200px)
|
||||
static bool isDesktopOrLarger(double width) => width >= desktop;
|
||||
|
||||
/// Whether width is wide tablet or larger (>= 900px)
|
||||
static bool isWideTabletOrLarger(double width) => width >= wideTablet;
|
||||
}
|
||||
|
||||
@@ -51,19 +38,13 @@ class AppDurations {
|
||||
static const Duration snackBarLong = Duration(seconds: 4);
|
||||
}
|
||||
|
||||
/// Grid layout constants
|
||||
class GridLayoutConstants {
|
||||
/// Default aspect ratio for media card grid cells (poster + text)
|
||||
static const double posterAspectRatio = 2 / 3.3;
|
||||
|
||||
/// Aspect ratio for episode thumbnail image (16:9)
|
||||
static const double episodeThumbnailAspectRatio = 16 / 9;
|
||||
|
||||
/// Aspect ratio for episode thumbnail grid cells (16:9 image + text area)
|
||||
/// This is wider than posterAspectRatio but accounts for ~60px text below
|
||||
static const double episodeGridCellAspectRatio = 1.4;
|
||||
|
||||
/// Grid spacing (edge-to-edge cards)
|
||||
static const double crossAxisSpacing = 0;
|
||||
static const double mainAxisSpacing = 0;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'base_notifier.dart';
|
||||
|
||||
/// Types of library refresh events
|
||||
enum LibraryRefreshType { collections, playlists }
|
||||
|
||||
/// Notifier for triggering refreshes of library tabs.
|
||||
@@ -20,12 +19,10 @@ class LibraryRefreshNotifier extends BaseNotifier<LibraryRefreshType> {
|
||||
/// Stream for playlists tab (backward compatible)
|
||||
Stream<void> get playlistsStream => stream.where((t) => t == LibraryRefreshType.playlists).cast<void>();
|
||||
|
||||
/// Notify that collections have changed
|
||||
void notifyCollectionsChanged() {
|
||||
notify(LibraryRefreshType.collections);
|
||||
}
|
||||
|
||||
/// Notify that playlists have changed
|
||||
void notifyPlaylistsChanged() {
|
||||
notify(LibraryRefreshType.playlists);
|
||||
}
|
||||
|
||||
@@ -103,16 +103,13 @@ class LogRedactionManager {
|
||||
|
||||
/// Redact known sensitive values from the provided message.
|
||||
static String redact(String message) {
|
||||
// Pass 1: IPv4 addresses (regex pattern)
|
||||
var redacted = message.replaceAllMapped(
|
||||
_ipv4Pattern,
|
||||
(match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!),
|
||||
);
|
||||
|
||||
// Pass 2: Strip X-Plex-Token query parameters (pattern-based, no pre-registration needed)
|
||||
redacted = redacted.replaceAll(_plexTokenQueryParam, 'X-Plex-Token=[REDACTED]');
|
||||
|
||||
// Pass 2b: Strip Jellyfin api_key/Quick Connect query parameters and Emby/MediaBrowser headers.
|
||||
redacted = redacted.replaceAll(_jellyfinApiKeyQueryParam, 'api_key=[REDACTED]');
|
||||
redacted = redacted.replaceAll(_jellyfinQuickConnectSecretQueryParam, 'secret=[REDACTED]');
|
||||
redacted = redacted.replaceAllMapped(_embyTokenHeader, (m) {
|
||||
@@ -122,7 +119,6 @@ class LogRedactionManager {
|
||||
});
|
||||
redacted = redacted.replaceAll(_mediaBrowserTokenHeader, 'Token="[REDACTED]"');
|
||||
|
||||
// Pass 3: All tracked values in single pass
|
||||
if (_combinedPattern != null) {
|
||||
redacted = redacted.replaceAllMapped(_combinedPattern!, (match) {
|
||||
final value = match.group(0)!;
|
||||
@@ -155,9 +151,8 @@ class LogRedactionManager {
|
||||
|
||||
/// Add value to set with FIFO eviction if limit exceeded.
|
||||
static void _addWithLimit(Set<String> set, String value, int maxSize) {
|
||||
if (set.contains(value)) return; // Already tracked
|
||||
if (set.contains(value)) return;
|
||||
|
||||
// Evict oldest entries if at capacity
|
||||
while (set.length >= maxSize) {
|
||||
set.remove(set.first);
|
||||
}
|
||||
|
||||
@@ -84,37 +84,26 @@ class MediaImageHelper {
|
||||
|
||||
switch (imageType) {
|
||||
case ImageType.art:
|
||||
// For art/background images, preserve aspect ratio while covering container
|
||||
// Calculate dimensions that ensure the image covers the container without stretching
|
||||
// This mimics BoxFit.cover behavior for the transcoding request
|
||||
|
||||
// Use larger dimensions to ensure coverage while preserving aspect ratio
|
||||
// This will request a slightly larger image that can be cropped by Flutter's BoxFit.cover
|
||||
final coverWidth = targetWidth * 1.1; // 10% larger for better coverage
|
||||
final coverWidth = targetWidth * 1.1;
|
||||
final coverHeight = targetHeight * 1.1;
|
||||
|
||||
return roundDimensions(coverWidth, coverHeight);
|
||||
|
||||
case ImageType.logo:
|
||||
// For logos, use generous bounds to avoid forcing aspect ratio
|
||||
// Prefer width-based scaling for most logos
|
||||
final logoWidth = targetWidth;
|
||||
final logoHeight = targetHeight; // Allow full height flexibility
|
||||
final logoHeight = targetHeight;
|
||||
return roundDimensions(logoWidth, logoHeight);
|
||||
|
||||
case ImageType.thumb:
|
||||
// For episode thumbs, optimize for 16:9 but allow flexibility
|
||||
final thumbHeight = targetHeight;
|
||||
final thumbWidth = min(targetWidth, thumbHeight * (16 / 9));
|
||||
return roundDimensions(thumbWidth, thumbHeight);
|
||||
|
||||
case ImageType.avatar:
|
||||
// For avatars, use square dimensions based on smaller constraint
|
||||
final size = min(targetWidth, targetHeight);
|
||||
return roundDimensions(size, size);
|
||||
|
||||
case ImageType.poster:
|
||||
// For posters, maintain 2:3 aspect ratio (width:height)
|
||||
final calculatedWidth = min(targetWidth, targetHeight * (2 / 3));
|
||||
final calculatedHeight = calculatedWidth * (3 / 2);
|
||||
return roundDimensions(calculatedWidth, calculatedHeight);
|
||||
@@ -138,8 +127,6 @@ class MediaImageHelper {
|
||||
if (thumbPath == null || thumbPath.isEmpty) return '';
|
||||
final basePath = thumbPath;
|
||||
|
||||
// External absolute URLs (EPG provider images, Jellyfin self-absolutized
|
||||
// image URLs).
|
||||
if (basePath.startsWith('http://') || basePath.startsWith('https://')) {
|
||||
// Self-contained Jellyfin URLs already carry their own auth
|
||||
// (`api_key=...`). Append `MaxWidth/MaxHeight` so we still get DPR
|
||||
@@ -147,7 +134,6 @@ class MediaImageHelper {
|
||||
// honours those query params.
|
||||
if (basePath.contains('api_key=')) {
|
||||
if (!enableTranscoding) return basePath;
|
||||
// If size params are already attached, leave them.
|
||||
if (basePath.contains('MaxWidth=') || basePath.contains('maxWidth=') || basePath.contains('Width=')) {
|
||||
return basePath;
|
||||
}
|
||||
@@ -178,9 +164,8 @@ class MediaImageHelper {
|
||||
// size-hint params (`/photo/:/transcode` for Plex, `MaxWidth/MaxHeight`
|
||||
// for Jellyfin). The interface guarantees both honour width/height.
|
||||
if (client == null) {
|
||||
// Offline mode and a relative path — the cached entry should already
|
||||
// exist under whatever URL was originally fetched. Returning empty
|
||||
// matches the pre-refactor behaviour.
|
||||
// Offline + relative path: the cached entry already exists under the
|
||||
// URL originally fetched, so returning '' matches pre-refactor behaviour.
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -240,7 +225,6 @@ class MediaImageHelper {
|
||||
static bool shouldTranscode(String? imagePath) {
|
||||
if (imagePath == null || imagePath.isEmpty) return false;
|
||||
|
||||
// Don't transcode already processed images or external URLs
|
||||
if (imagePath.contains('/photo/:/transcode') ||
|
||||
imagePath.startsWith('http://') ||
|
||||
imagePath.startsWith('https://')) {
|
||||
|
||||
@@ -99,7 +99,6 @@ Future<MediaNavigationResult> navigateToMediaItem(
|
||||
|
||||
case MediaKind.clip:
|
||||
case MediaKind.episode:
|
||||
// For episodes and clips (trailers/extras), start playback directly
|
||||
final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline);
|
||||
if (result == true) {
|
||||
onRefresh?.call(mi.id);
|
||||
@@ -108,7 +107,6 @@ Future<MediaNavigationResult> navigateToMediaItem(
|
||||
|
||||
case MediaKind.movie:
|
||||
if (playDirectly) {
|
||||
// For movies in continue watching, start playback directly
|
||||
final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline);
|
||||
if (result == true) {
|
||||
onRefresh?.call(mi.id);
|
||||
@@ -118,7 +116,6 @@ Future<MediaNavigationResult> navigateToMediaItem(
|
||||
return _showDetail(context, mi, isOffline, onRefresh);
|
||||
|
||||
case MediaKind.season:
|
||||
// Navigate to the parent show with the season tab pre-selected
|
||||
if (mi.parentId != null) {
|
||||
final showStub = MediaItem(
|
||||
id: mi.parentId!,
|
||||
|
||||
@@ -50,7 +50,6 @@ void throwIfHttpError(MediaServerResponse r) {
|
||||
class AbortController {
|
||||
final _completer = Completer<void>();
|
||||
|
||||
/// The future that triggers abort when completed.
|
||||
Future<void> get trigger => _completer.future;
|
||||
|
||||
bool get isAborted => _completer.isCompleted;
|
||||
@@ -201,7 +200,6 @@ class MediaServerHttpClient {
|
||||
|
||||
final mergedHeaders = <String, String>{...defaultHeaders, ...?headers};
|
||||
|
||||
// Build the request — use AbortableRequest when abort is provided
|
||||
final http.Request request;
|
||||
if (abort != null) {
|
||||
request = http.AbortableRequest(method, uri, abortTrigger: abort.trigger);
|
||||
@@ -213,12 +211,10 @@ class MediaServerHttpClient {
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
// Phase 1: send + receive headers (connect timeout)
|
||||
final streamed = await _client
|
||||
.send(request)
|
||||
.namedTimeout(timeout ?? connectTimeout, operation: '$method ${uri.path} connect');
|
||||
|
||||
// Phase 2: consume body (receive timeout)
|
||||
final bytes = await streamed.stream.toBytes().namedTimeout(
|
||||
timeout ?? receiveTimeout,
|
||||
operation: '$method ${uri.path} receive',
|
||||
@@ -320,13 +316,10 @@ class MediaServerHttpClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// Map or List → JSON encode
|
||||
request.body = jsonEncode(body);
|
||||
// Only set content-type if the caller hasn't already. http.BaseRequest's
|
||||
// headers map is case-sensitive, so we must check both common casings —
|
||||
// Jellyfin returns 415 if a `Content-Type: application/json` from the
|
||||
// default headers ends up coexisting with a lowercase `content-type:
|
||||
// application/json; charset=utf-8` we'd append below.
|
||||
// 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';
|
||||
|
||||
@@ -3,18 +3,14 @@
|
||||
/// timeouts are kept here so the budgets per phase are visible at a
|
||||
/// glance.
|
||||
class MediaServerTimeouts {
|
||||
// ── Per-server HTTP request budgets (apply to both backends) ───
|
||||
/// HTTP connect timeout for individual HTTP requests to a media server.
|
||||
static const connect = Duration(seconds: 10);
|
||||
|
||||
/// HTTP receive timeout for streaming/large responses from a media server.
|
||||
static const receive = Duration(seconds: 120);
|
||||
|
||||
/// Retry budget for home `/hubs` startup calls. These endpoints can be slow
|
||||
/// while Plex wakes idle disks, but should not block forever.
|
||||
static const homeHubAttemptTimeouts = [Duration(seconds: 10), Duration(seconds: 5), Duration(milliseconds: 2500)];
|
||||
|
||||
// ── Plex server discovery / endpoint racing ────────────────────
|
||||
/// Timeout for probing a cached/preferred endpoint before falling back to
|
||||
/// the full candidate race (used in [PlexServer.findBestWorkingConnection]).
|
||||
static const preferredEndpointProbe = Duration(milliseconds: 1500);
|
||||
@@ -31,14 +27,10 @@ class MediaServerTimeouts {
|
||||
/// default 10s connect budget is too tight on Fire-TV cold starts.
|
||||
static const tune = Duration(seconds: 30);
|
||||
|
||||
// ── plex.tv (auth provider) ─────────────────────────────────────
|
||||
/// HTTP connect timeout for plex.tv / clients.plex.tv API requests.
|
||||
static const plexTvConnect = Duration(seconds: 5);
|
||||
|
||||
/// HTTP receive timeout for plex.tv / clients.plex.tv API responses.
|
||||
static const plexTvReceive = Duration(seconds: 10);
|
||||
|
||||
// ── Jellyfin auth flow ──────────────────────────────────────────
|
||||
/// Probe + token-validate timeout — Jellyfin servers respond fast on
|
||||
/// `/System/Info/Public` and `/Users/Me`.
|
||||
static const jellyfinProbe = Duration(seconds: 8);
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'platform_detector.dart';
|
||||
|
||||
/// Helper class for managing device orientation preferences across the app.
|
||||
class OrientationHelper {
|
||||
/// Restores default orientation preferences based on device type.
|
||||
///
|
||||
@@ -17,7 +16,6 @@ class OrientationHelper {
|
||||
if (isPhone) {
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
||||
} else {
|
||||
// For tablets and desktop, allow all orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
|
||||
@@ -76,16 +76,11 @@ class TvDetectionService {
|
||||
static void setForceTVSync(bool value) => _instance?.setForceTv(value);
|
||||
}
|
||||
|
||||
/// Utility class for platform detection
|
||||
class PlatformDetector {
|
||||
/// Detects if running on a TV platform (Android TV or Apple TV).
|
||||
/// Requires TvDetectionService to be initialized.
|
||||
static bool isTV() {
|
||||
return TvDetectionService.isTVSync();
|
||||
}
|
||||
|
||||
/// Detects if running specifically on Apple TV (tvOS).
|
||||
/// Requires TvDetectionService to be initialized.
|
||||
static bool isAppleTV() {
|
||||
return TvDetectionService.isAppleTVSync();
|
||||
}
|
||||
@@ -111,7 +106,6 @@ class PlatformDetector {
|
||||
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
|
||||
}
|
||||
|
||||
/// Detects if running on a handheld mobile device (phone or tablet, not TV).
|
||||
static bool isHandheld(BuildContext context) {
|
||||
return isMobile(context) && !isTV();
|
||||
}
|
||||
@@ -138,11 +132,9 @@ class PlatformDetector {
|
||||
// Convert diagonal from logical pixels to inches (assuming 160 DPI as baseline)
|
||||
final diagonalInches = diagonal / (devicePixelRatio * 160 / 2.54);
|
||||
|
||||
// Consider devices with diagonal >= 7 inches as tablets
|
||||
return diagonalInches >= 7.0;
|
||||
}
|
||||
|
||||
/// Detects if the device is a phone (mobile but not tablet)
|
||||
static bool isPhone(BuildContext context) {
|
||||
return isHandheld(context) && !isTablet(context);
|
||||
}
|
||||
|
||||
@@ -4,24 +4,17 @@
|
||||
class PlexCacheParser {
|
||||
PlexCacheParser._();
|
||||
|
||||
/// Extract the Metadata list from a cached response
|
||||
///
|
||||
/// Returns null if MediaContainer or Metadata is not present
|
||||
static List<dynamic>? extractMetadataList(Map<String, dynamic>? cached) {
|
||||
if (cached == null) return null;
|
||||
return cached['MediaContainer']?['Metadata'] as List?;
|
||||
}
|
||||
|
||||
/// Extract the first metadata item from a cached response
|
||||
///
|
||||
/// Returns null if no metadata exists
|
||||
static Map<String, dynamic>? extractFirstMetadata(Map<String, dynamic>? cached) {
|
||||
final list = extractMetadataList(cached);
|
||||
if (list == null || list.isEmpty) return null;
|
||||
return list.first as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Extract Chapter list from the first metadata item
|
||||
static List<dynamic>? extractChapters(Map<String, dynamic>? cached) {
|
||||
final metadata = extractFirstMetadata(cached);
|
||||
if (metadata == null) return null;
|
||||
|
||||
@@ -16,7 +16,6 @@ extension ProviderExtensions on BuildContext {
|
||||
|
||||
HiddenLibrariesProvider get hiddenLibraries => Provider.of<HiddenLibrariesProvider>(this, listen: false);
|
||||
|
||||
// Direct profile settings access (nullable)
|
||||
MediaServerUserProfile? get profileSettings => userProfile.profileSettings;
|
||||
|
||||
/// Internal: resolve a [PlexClient] from a serverId or fall back to the
|
||||
@@ -50,22 +49,16 @@ extension ProviderExtensions on BuildContext {
|
||||
return client;
|
||||
}
|
||||
|
||||
/// Get PlexClient for a specific server ID. Throws if unavailable.
|
||||
PlexClient getPlexClientForServer(String serverId) => _requireClient(serverId, fallback: false);
|
||||
|
||||
/// Get PlexClient for a specific server ID, or null if unavailable.
|
||||
PlexClient? tryGetPlexClientForServer(String? serverId) {
|
||||
if (serverId == null) return null;
|
||||
final provider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
return provider.getPlexClientForServer(serverId);
|
||||
}
|
||||
|
||||
/// Get PlexClient for a library, falling back to the first online server
|
||||
/// when the library has no serverId. Throws if no client is available.
|
||||
PlexClient getPlexClientForLibrary(MediaLibrary library) => _requireClient(library.serverId);
|
||||
|
||||
/// Get client for a serverId, falling back to the first online server.
|
||||
/// Throws if no client is available.
|
||||
PlexClient getPlexClientWithFallback(String? serverId) => _requireClient(serverId);
|
||||
|
||||
// ── Backend-neutral helpers ──────────────────────────────────────
|
||||
@@ -80,7 +73,6 @@ extension ProviderExtensions on BuildContext {
|
||||
return _resolvePrioritized(serverId, provider.onlineServerIds, provider.getClientForServer);
|
||||
}
|
||||
|
||||
/// Get a [MediaServerClient] for the given serverId, or null.
|
||||
MediaServerClient? tryGetMediaClientForServer(String? serverId) {
|
||||
if (serverId == null) return null;
|
||||
final provider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
@@ -96,8 +88,6 @@ extension ProviderExtensions on BuildContext {
|
||||
return c;
|
||||
}
|
||||
|
||||
/// Get a [MediaServerClient] for [library], falling back to the first
|
||||
/// online server when the library has no serverId. Throws if none.
|
||||
MediaServerClient getMediaClientForLibrary(MediaLibrary library) {
|
||||
final c = _resolveMediaClient(library.serverId);
|
||||
if (c == null) throw Exception(t.errors.noClientAvailable);
|
||||
|
||||
@@ -16,7 +16,6 @@ class RatingInfo {
|
||||
RatingInfo? parseRatingImage(String? imageUri, double? value) {
|
||||
if (imageUri == null || value == null) return null;
|
||||
|
||||
// Rotten Tomatoes critic ratings
|
||||
if (imageUri.startsWith('rottentomatoes://image.rating.')) {
|
||||
final suffix = imageUri.substring('rottentomatoes://image.rating.'.length);
|
||||
final percent = '${(value * 10).toStringAsFixed(0)}%';
|
||||
@@ -29,12 +28,10 @@ RatingInfo? parseRatingImage(String? imageUri, double? value) {
|
||||
};
|
||||
}
|
||||
|
||||
// IMDb
|
||||
if (imageUri.startsWith('imdb://')) {
|
||||
return RatingInfo('assets/rating_icons/imdb.svg', value.toStringAsFixed(1));
|
||||
}
|
||||
|
||||
// TMDB
|
||||
if (imageUri.startsWith('themoviedb://')) {
|
||||
return RatingInfo('assets/rating_icons/tmdb.svg', '${(value * 10).toStringAsFixed(0)}%');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ class SmartDeletionHandler {
|
||||
bool dialogShown = false;
|
||||
bool deletionComplete = false;
|
||||
|
||||
// Start a timer to show dialog after delay
|
||||
Future.delayed(Duration(milliseconds: delayMs), () {
|
||||
if (!deletionComplete && context.mounted) {
|
||||
dialogShown = true;
|
||||
@@ -35,7 +34,6 @@ class SmartDeletionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show progress dialog and listen to updates
|
||||
static void _showProgressDialog(BuildContext context, DownloadProvider _, String globalKey) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -44,7 +42,6 @@ class SmartDeletionHandler {
|
||||
builder: (context, provider, child) {
|
||||
final progress = provider.getDeletionProgress(globalKey);
|
||||
|
||||
// If no progress, show simple fallback
|
||||
if (progress == null) {
|
||||
return AlertDialog(
|
||||
content: Row(
|
||||
|
||||
@@ -10,25 +10,10 @@ final rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
final mainScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// Types of snackbars available in the app
|
||||
enum SnackBarType {
|
||||
/// Standard informational snackbar
|
||||
info,
|
||||
|
||||
/// Success snackbar (green background)
|
||||
success,
|
||||
|
||||
/// Error snackbar (red background)
|
||||
error,
|
||||
}
|
||||
enum SnackBarType { info, success, error }
|
||||
|
||||
/// Utility functions for showing snackbars throughout the application
|
||||
|
||||
/// Shows a snackbar with the specified type
|
||||
///
|
||||
/// [context] The build context
|
||||
/// [message] The message to display
|
||||
/// [type] The type of snackbar (info, success, error)
|
||||
/// [duration] Optional duration override
|
||||
void showSnackBar(BuildContext context, String message, {SnackBarType type = SnackBarType.info, Duration? duration}) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -43,19 +28,10 @@ void showSnackBar(BuildContext context, String message, {SnackBarType type = Sna
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a standard snackbar with a message
|
||||
///
|
||||
/// [context] The build context
|
||||
/// [message] The message to display
|
||||
/// [duration] Optional duration, defaults to 3 seconds
|
||||
void showAppSnackBar(BuildContext context, String message, {Duration? duration}) {
|
||||
showSnackBar(context, message, type: SnackBarType.info, duration: duration);
|
||||
}
|
||||
|
||||
/// Shows an error snackbar with a message
|
||||
///
|
||||
/// [context] The build context
|
||||
/// [message] The error message to display
|
||||
void showErrorSnackBar(BuildContext context, String message) {
|
||||
showSnackBar(context, message, type: SnackBarType.error);
|
||||
}
|
||||
@@ -77,10 +53,6 @@ void showMainSnackBar(String message, {Duration duration = AppDurations.snackBar
|
||||
..showSnackBar(SnackBar(content: Text(message), duration: duration));
|
||||
}
|
||||
|
||||
/// Shows a success snackbar with a message
|
||||
///
|
||||
/// [context] The build context
|
||||
/// [message] The success message to display
|
||||
void showSuccessSnackBar(BuildContext context, String message) {
|
||||
showSnackBar(context, message, type: SnackBarType.success);
|
||||
}
|
||||
|
||||
@@ -79,13 +79,9 @@ Set<String> _subtitleCodecAliases(String? codec) {
|
||||
|
||||
String _metadataToken(String value) => value.trim().toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]+'), '_');
|
||||
|
||||
/// Utility for building track labels for audio and subtitle tracks.
|
||||
class TrackLabelBuilder {
|
||||
TrackLabelBuilder._();
|
||||
|
||||
/// Build a label for an audio track.
|
||||
///
|
||||
/// Combines title, language, codec, and channel count.
|
||||
static String buildAudioLabel({
|
||||
String? title,
|
||||
String? language,
|
||||
@@ -109,9 +105,6 @@ class TrackLabelBuilder {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a label for a subtitle track.
|
||||
///
|
||||
/// Combines title, language, and codec (with friendly codec names).
|
||||
static String buildSubtitleLabel({
|
||||
String? title,
|
||||
String? language,
|
||||
|
||||
@@ -55,7 +55,6 @@ Future<bool?> navigateToVideoPlayer(
|
||||
bool usePushReplacement = false,
|
||||
bool isOffline = false,
|
||||
}) async {
|
||||
// Extract context-dependent values before any async operations
|
||||
final navigator = Navigator.of(context);
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
// Use the manager-routed lookup so Jellyfin items don't trip the
|
||||
@@ -63,7 +62,6 @@ Future<bool?> navigateToVideoPlayer(
|
||||
final manager = context.read<MultiServerProvider>().serverManager;
|
||||
final mediaClient = isOffline ? null : manager.getClient(metadata.serverId ?? '');
|
||||
|
||||
// Load saved media version preference if not explicitly provided
|
||||
int mediaIndex = selectedMediaIndex ?? 0;
|
||||
if (selectedMediaIndex == null) {
|
||||
try {
|
||||
@@ -73,9 +71,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
if (savedPreference != null) {
|
||||
mediaIndex = savedPreference;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors loading preference, use default
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Check if external player is enabled
|
||||
@@ -85,7 +81,6 @@ Future<bool?> navigateToVideoPlayer(
|
||||
bool launched = false;
|
||||
|
||||
if (isOffline) {
|
||||
// Offline mode: resolve local file path for the external player
|
||||
final globalKey = metadata.globalKey;
|
||||
final videoPath = await downloadProvider.getVideoFilePath(globalKey);
|
||||
if (videoPath != null && context.mounted) {
|
||||
@@ -102,7 +97,6 @@ Future<bool?> navigateToVideoPlayer(
|
||||
}
|
||||
|
||||
if (launched) return null;
|
||||
// Fall through to built-in player on failure
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('External player launch failed, falling back to built-in player', error: e);
|
||||
@@ -174,7 +168,6 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
|
||||
|
||||
appLogger.d('Returned from playback, refreshing metadata');
|
||||
|
||||
// Refresh data when returning from video player (skip if offline)
|
||||
if (!isOffline && onRefresh != null) {
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'base_notifier.dart';
|
||||
import 'global_key_utils.dart';
|
||||
import 'hierarchical_event_mixin.dart';
|
||||
|
||||
/// Types of watch state changes
|
||||
enum WatchStateChangeType { watched, unwatched, progressUpdate }
|
||||
|
||||
/// Event representing a watch state change with parent chain for hierarchical invalidation
|
||||
@@ -82,10 +81,8 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
|
||||
|
||||
WatchStateNotifier._internal();
|
||||
|
||||
/// Filter for events affecting a specific server
|
||||
Stream<WatchStateEvent> forServer(String serverId) => stream.where((e) => e.serverId == serverId);
|
||||
|
||||
/// Filter for events affecting a specific item or its children
|
||||
Stream<WatchStateEvent> forItem(String itemId) => stream.where((e) => e.affectsItem(itemId));
|
||||
|
||||
/// Emit a watch state event with logging
|
||||
|
||||
Reference in New Issue
Block a user