diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 7dd01486..2fd99c78 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -4,11 +4,8 @@ import 'package:drift/native.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; -import 'tables/downloaded_media.dart'; -import 'tables/download_queue.dart'; -import 'tables/api_cache.dart'; -import 'tables/offline_watch_progress.dart'; -import '../models/download_status.dart'; +import 'tables.dart'; +import '../models/download_models.dart'; import '../utils/app_logger.dart'; part 'app_database.g.dart'; diff --git a/lib/database/tables.dart b/lib/database/tables.dart new file mode 100644 index 00000000..89fdc492 --- /dev/null +++ b/lib/database/tables.dart @@ -0,0 +1,97 @@ +import 'package:drift/drift.dart'; + +/// Key-value cache table for Plex API responses. +/// Used for offline support - stores raw JSON responses. +class ApiCache extends Table { + /// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345") + TextColumn get cacheKey => text()(); + + /// JSON response data + TextColumn get data => text()(); + + /// Whether this item is pinned for offline access + BoolColumn get pinned => boolean().withDefault(const Constant(false))(); + + /// Timestamp for cache invalidation (optional future use) + DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {cacheKey}; +} + +@DataClassName('DownloadQueueItem') +class DownloadQueue extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get mediaGlobalKey => text().unique()(); + IntColumn get priority => integer().withDefault(const Constant(0))(); + IntColumn get addedAt => integer()(); + BoolColumn get downloadSubtitles => + boolean().withDefault(const Constant(true))(); + BoolColumn get downloadArtwork => + boolean().withDefault(const Constant(true))(); +} + +@DataClassName('DownloadedMediaItem') +class DownloadedMedia extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get serverId => text()(); + TextColumn get ratingKey => text()(); + TextColumn get globalKey => text().unique()(); + TextColumn get type => text()(); + TextColumn get parentRatingKey => text().nullable()(); + TextColumn get grandparentRatingKey => text().nullable()(); + IntColumn get status => integer()(); + IntColumn get progress => integer().withDefault(const Constant(0))(); + IntColumn get totalBytes => integer().nullable()(); + IntColumn get downloadedBytes => integer().withDefault(const Constant(0))(); + TextColumn get videoFilePath => text().nullable()(); + TextColumn get thumbPath => text().nullable()(); + IntColumn get downloadedAt => integer().nullable()(); + TextColumn get errorMessage => text().nullable()(); + IntColumn get retryCount => integer().withDefault(const Constant(0))(); +} + +/// Queue for offline watch progress and manual watch actions. +/// +/// Stores watch progress updates and manual watch/unwatch actions +/// that need to be synced to the Plex server when back online. +@DataClassName('OfflineWatchProgressItem') +class OfflineWatchProgress extends Table { + /// Auto-incrementing primary key + IntColumn get id => integer().autoIncrement()(); + + /// Server ID this media belongs to + TextColumn get serverId => text()(); + + /// Rating key of the media item + TextColumn get ratingKey => text()(); + + /// Global key (serverId:ratingKey) for easy lookup + TextColumn get globalKey => text()(); + + /// Type of action: 'progress', 'watched', 'unwatched' + TextColumn get actionType => text()(); + + /// Current playback position in milliseconds (for 'progress' actions) + IntColumn get viewOffset => integer().nullable()(); + + /// Duration of the media in milliseconds (for calculating percentage) + IntColumn get duration => integer().nullable()(); + + /// Whether this item should be marked as watched (for progress sync) + /// Auto-set to true when viewOffset >= 90% of duration + BoolColumn get shouldMarkWatched => + boolean().withDefault(const Constant(false))(); + + /// Timestamp when this action was recorded (milliseconds since epoch) + IntColumn get createdAt => integer()(); + + /// Timestamp when this action was last updated (for merging progress updates) + IntColumn get updatedAt => integer()(); + + /// Number of sync attempts (for retry logic) + IntColumn get syncAttempts => integer().withDefault(const Constant(0))(); + + /// Last sync error message + TextColumn get lastError => text().nullable()(); +} diff --git a/lib/database/tables/api_cache.dart b/lib/database/tables/api_cache.dart deleted file mode 100644 index dcafcb5c..00000000 --- a/lib/database/tables/api_cache.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:drift/drift.dart'; - -/// Key-value cache table for Plex API responses. -/// Used for offline support - stores raw JSON responses. -class ApiCache extends Table { - /// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345") - TextColumn get cacheKey => text()(); - - /// JSON response data - TextColumn get data => text()(); - - /// Whether this item is pinned for offline access - BoolColumn get pinned => boolean().withDefault(const Constant(false))(); - - /// Timestamp for cache invalidation (optional future use) - DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); - - @override - Set get primaryKey => {cacheKey}; -} diff --git a/lib/database/tables/download_queue.dart b/lib/database/tables/download_queue.dart deleted file mode 100644 index f4bee463..00000000 --- a/lib/database/tables/download_queue.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:drift/drift.dart'; - -@DataClassName('DownloadQueueItem') -class DownloadQueue extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get mediaGlobalKey => text().unique()(); - IntColumn get priority => integer().withDefault(const Constant(0))(); - IntColumn get addedAt => integer()(); - BoolColumn get downloadSubtitles => - boolean().withDefault(const Constant(true))(); - BoolColumn get downloadArtwork => - boolean().withDefault(const Constant(true))(); -} diff --git a/lib/database/tables/downloaded_media.dart b/lib/database/tables/downloaded_media.dart deleted file mode 100644 index 582d802a..00000000 --- a/lib/database/tables/downloaded_media.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:drift/drift.dart'; - -@DataClassName('DownloadedMediaItem') -class DownloadedMedia extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get serverId => text()(); - TextColumn get ratingKey => text()(); - TextColumn get globalKey => text().unique()(); - TextColumn get type => text()(); - TextColumn get parentRatingKey => text().nullable()(); - TextColumn get grandparentRatingKey => text().nullable()(); - IntColumn get status => integer()(); - IntColumn get progress => integer().withDefault(const Constant(0))(); - IntColumn get totalBytes => integer().nullable()(); - IntColumn get downloadedBytes => integer().withDefault(const Constant(0))(); - TextColumn get videoFilePath => text().nullable()(); - TextColumn get thumbPath => text().nullable()(); - IntColumn get downloadedAt => integer().nullable()(); - TextColumn get errorMessage => text().nullable()(); - IntColumn get retryCount => integer().withDefault(const Constant(0))(); -} diff --git a/lib/database/tables/offline_watch_progress.dart b/lib/database/tables/offline_watch_progress.dart deleted file mode 100644 index c6bf11db..00000000 --- a/lib/database/tables/offline_watch_progress.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:drift/drift.dart'; - -/// Queue for offline watch progress and manual watch actions. -/// -/// Stores watch progress updates and manual watch/unwatch actions -/// that need to be synced to the Plex server when back online. -@DataClassName('OfflineWatchProgressItem') -class OfflineWatchProgress extends Table { - /// Auto-incrementing primary key - IntColumn get id => integer().autoIncrement()(); - - /// Server ID this media belongs to - TextColumn get serverId => text()(); - - /// Rating key of the media item - TextColumn get ratingKey => text()(); - - /// Global key (serverId:ratingKey) for easy lookup - TextColumn get globalKey => text()(); - - /// Type of action: 'progress', 'watched', 'unwatched' - TextColumn get actionType => text()(); - - /// Current playback position in milliseconds (for 'progress' actions) - IntColumn get viewOffset => integer().nullable()(); - - /// Duration of the media in milliseconds (for calculating percentage) - IntColumn get duration => integer().nullable()(); - - /// Whether this item should be marked as watched (for progress sync) - /// Auto-set to true when viewOffset >= 90% of duration - BoolColumn get shouldMarkWatched => - boolean().withDefault(const Constant(false))(); - - /// Timestamp when this action was recorded (milliseconds since epoch) - IntColumn get createdAt => integer()(); - - /// Timestamp when this action was last updated (for merging progress updates) - IntColumn get updatedAt => integer()(); - - /// Number of sync attempts (for retry logic) - IntColumn get syncAttempts => integer().withDefault(const Constant(0))(); - - /// Last sync error message - TextColumn get lastError => text().nullable()(); -} diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart index da06e873..47752804 100644 --- a/lib/focus/input_mode_tracker.dart +++ b/lib/focus/input_mode_tracker.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../services/tv_detection_service.dart'; +import '../utils/platform_detector.dart'; import '../services/gamepad_service.dart'; /// Tracks whether the user is navigating via keyboard/d-pad or pointer (mouse/touch). diff --git a/lib/main.dart b/lib/main.dart index 64964a9e..e470210a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,7 @@ import 'services/macos_titlebar_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/update_service.dart'; import 'services/settings_service.dart'; -import 'services/tv_detection_service.dart'; +import 'utils/platform_detector.dart'; import 'services/gamepad_service.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; diff --git a/lib/models/deletion_progress.dart b/lib/models/deletion_progress.dart deleted file mode 100644 index ac4d6c12..00000000 --- a/lib/models/deletion_progress.dart +++ /dev/null @@ -1,45 +0,0 @@ -class DeletionProgress { - final String globalKey; - final String itemTitle; - final int currentItem; - final int totalItems; - final String? currentOperation; - - const DeletionProgress({ - required this.globalKey, - required this.itemTitle, - required this.currentItem, - required this.totalItems, - this.currentOperation, - }); - - double get progressPercent => - totalItems > 0 ? (currentItem / totalItems) : 0.0; - - int get progressPercentInt => (progressPercent * 100).round(); - - bool get isComplete => currentItem >= totalItems; - - DeletionProgress copyWith({ - String? globalKey, - String? itemTitle, - int? currentItem, - int? totalItems, - String? currentOperation, - }) { - return DeletionProgress( - globalKey: globalKey ?? this.globalKey, - itemTitle: itemTitle ?? this.itemTitle, - currentItem: currentItem ?? this.currentItem, - totalItems: totalItems ?? this.totalItems, - currentOperation: currentOperation ?? this.currentOperation, - ); - } - - @override - String toString() { - return 'DeletionProgress(globalKey: $globalKey, itemTitle: $itemTitle, ' - 'currentItem: $currentItem, totalItems: $totalItems, ' - 'progressPercent: $progressPercentInt%)'; - } -} diff --git a/lib/models/download_progress.dart b/lib/models/download_models.dart similarity index 58% rename from lib/models/download_progress.dart rename to lib/models/download_models.dart index 4ead0da2..a487c20e 100644 --- a/lib/models/download_progress.dart +++ b/lib/models/download_models.dart @@ -1,5 +1,14 @@ -import '../utils/byte_formatter.dart'; -import 'download_status.dart'; +import '../utils/formatters.dart'; + +enum DownloadStatus { + queued, + downloading, + paused, + completed, + failed, + cancelled, + partial, // Some episodes downloaded, but not all (for shows/seasons) +} class DownloadProgress { final String globalKey; @@ -67,3 +76,49 @@ class DownloadProgress { ); } } + +class DeletionProgress { + final String globalKey; + final String itemTitle; + final int currentItem; + final int totalItems; + final String? currentOperation; + + const DeletionProgress({ + required this.globalKey, + required this.itemTitle, + required this.currentItem, + required this.totalItems, + this.currentOperation, + }); + + double get progressPercent => + totalItems > 0 ? (currentItem / totalItems) : 0.0; + + int get progressPercentInt => (progressPercent * 100).round(); + + bool get isComplete => currentItem >= totalItems; + + DeletionProgress copyWith({ + String? globalKey, + String? itemTitle, + int? currentItem, + int? totalItems, + String? currentOperation, + }) { + return DeletionProgress( + globalKey: globalKey ?? this.globalKey, + itemTitle: itemTitle ?? this.itemTitle, + currentItem: currentItem ?? this.currentItem, + totalItems: totalItems ?? this.totalItems, + currentOperation: currentOperation ?? this.currentOperation, + ); + } + + @override + String toString() { + return 'DeletionProgress(globalKey: $globalKey, itemTitle: $itemTitle, ' + 'currentItem: $currentItem, totalItems: $totalItems, ' + 'progressPercent: $progressPercentInt%)'; + } +} diff --git a/lib/models/download_status.dart b/lib/models/download_status.dart deleted file mode 100644 index f763de72..00000000 --- a/lib/models/download_status.dart +++ /dev/null @@ -1,9 +0,0 @@ -enum DownloadStatus { - queued, - downloading, - paused, - completed, - failed, - cancelled, - partial, // Some episodes downloaded, but not all (for shows/seasons) -} diff --git a/lib/models/plex_file_info.dart b/lib/models/plex_file_info.dart index 9fa02073..ae94c400 100644 --- a/lib/models/plex_file_info.dart +++ b/lib/models/plex_file_info.dart @@ -1,4 +1,4 @@ -import '../utils/byte_formatter.dart'; +import '../utils/formatters.dart'; class PlexFileInfo { // Media level properties diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart index 3155faa5..08c159da 100644 --- a/lib/models/plex_media_version.dart +++ b/lib/models/plex_media_version.dart @@ -1,4 +1,4 @@ -import '../utils/byte_formatter.dart'; +import '../utils/formatters.dart'; import '../utils/codec_utils.dart'; class PlexMediaVersion { diff --git a/lib/models/plex_metadata_extensions.dart b/lib/models/plex_metadata_extensions.dart deleted file mode 100644 index eae8e20f..00000000 --- a/lib/models/plex_metadata_extensions.dart +++ /dev/null @@ -1,20 +0,0 @@ -import '../utils/content_type_helper.dart'; -import 'plex_metadata.dart'; - -/// Extension on PlexMetadata for type checking convenience methods -extension PlexMetadataType on PlexMetadata { - String get _lowerType => type.toLowerCase(); - - bool get isShow => _lowerType == ContentTypes.show; - bool get isMovie => _lowerType == ContentTypes.movie; - bool get isSeason => _lowerType == ContentTypes.season; - bool get isEpisode => _lowerType == ContentTypes.episode; - bool get isArtist => _lowerType == ContentTypes.artist; - bool get isAlbum => _lowerType == ContentTypes.album; - bool get isTrack => _lowerType == ContentTypes.track; - bool get isCollection => _lowerType == ContentTypes.collection; - bool get isPlaylist => _lowerType == ContentTypes.playlist; - bool get isClip => _lowerType == ContentTypes.clip; - bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType); - bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType); -} diff --git a/lib/mpv/utils/android_font_loader.dart b/lib/mpv/android_font_loader.dart similarity index 100% rename from lib/mpv/utils/android_font_loader.dart rename to lib/mpv/android_font_loader.dart diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart new file mode 100644 index 00000000..93212cd4 --- /dev/null +++ b/lib/mpv/models.dart @@ -0,0 +1,292 @@ +/// Log level for player messages. +enum PlayerLogLevel { + /// No logging. + none, + + /// Fatal errors only. + fatal, + + /// Errors. + error, + + /// Warnings. + warn, + + /// Informational messages. + info, + + /// Verbose output. + verbose, + + /// Debug messages. + debug, + + /// Trace-level output (very verbose). + trace, +} + +/// Represents an audio track in the media. +class AudioTrack { + /// Unique identifier for the track. + final String id; + + /// Human-readable title of the track. + final String? title; + + /// Language code (e.g., 'eng', 'jpn'). + final String? language; + + /// Audio codec (e.g., 'aac', 'ac3', 'dts'). + final String? codec; + + /// Number of audio channels. + final int? channels; + + /// Alias for channels (media_kit compatibility). + int? get channelsCount => channels; + + /// Sample rate in Hz. + final int? sampleRate; + + /// Bitrate in bits per second. + final int? bitrate; + + /// Whether this is the default track. + final bool isDefault; + + /// Whether this track is forced. + final bool isForced; + + const AudioTrack({ + required this.id, + this.title, + this.language, + this.codec, + this.channels, + this.sampleRate, + this.bitrate, + this.isDefault = false, + this.isForced = false, + }); + + /// Auto-select track. + static const auto = AudioTrack(id: 'auto', title: 'Auto'); + + /// Disable audio. + static const off = AudioTrack(id: 'no', title: 'Off'); + + /// Returns a display name for the track. + String get displayName { + if (title != null && title!.isNotEmpty) return title!; + if (language != null && language!.isNotEmpty) return language!; + return 'Track $id'; + } + + @override + String toString() => 'AudioTrack($id, $displayName)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AudioTrack && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Represents a subtitle track in the media. +class SubtitleTrack { + /// Unique identifier for the track. + final String id; + + /// Human-readable title of the track. + final String? title; + + /// Language code (e.g., 'eng', 'jpn'). + final String? language; + + /// Subtitle codec/format (e.g., 'subrip', 'ass', 'pgs'). + final String? codec; + + /// Whether this is the default track. + final bool isDefault; + + /// Whether this track is forced (e.g., for foreign language segments). + final bool isForced; + + /// Whether this is an external subtitle file. + final bool isExternal; + + /// URI of external subtitle file (if isExternal is true). + final String? uri; + + const SubtitleTrack({ + required this.id, + this.title, + this.language, + this.codec, + this.isDefault = false, + this.isForced = false, + this.isExternal = false, + this.uri, + }); + + /// Create a subtitle track from an external URI. + factory SubtitleTrack.uri(String uri, {String? title, String? language}) { + return SubtitleTrack( + id: 'external:$uri', + title: title, + language: language, + isExternal: true, + uri: uri, + ); + } + + /// Auto-select track. + static const auto = SubtitleTrack(id: 'auto', title: 'Auto'); + + /// Disable subtitles. + static const off = SubtitleTrack(id: 'no', title: 'Off'); + + /// Returns a display name for the track. + String get displayName { + if (title != null && title!.isNotEmpty) return title!; + if (language != null && language!.isNotEmpty) return language!; + if (isExternal) return 'External'; + return 'Track $id'; + } + + @override + String toString() => 'SubtitleTrack($id, $displayName)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SubtitleTrack && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Container for all available tracks in the media. +class Tracks { + /// Available audio tracks. + final List audio; + + /// Available subtitle tracks. + final List subtitle; + + const Tracks({this.audio = const [], this.subtitle = const []}); + + /// Creates a copy with the given fields replaced. + Tracks copyWith({List? audio, List? subtitle}) { + return Tracks( + audio: audio ?? this.audio, + subtitle: subtitle ?? this.subtitle, + ); + } + + @override + String toString() => + 'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})'; +} + +/// Represents the currently selected tracks. +class TrackSelection { + /// Currently selected audio track. + final AudioTrack? audio; + + /// Currently selected subtitle track. + final SubtitleTrack? subtitle; + + const TrackSelection({this.audio, this.subtitle}); + + /// Creates a copy with the given fields replaced. + TrackSelection copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) { + return TrackSelection( + audio: audio ?? this.audio, + subtitle: subtitle ?? this.subtitle, + ); + } + + @override + String toString() => 'TrackSelection(audio: $audio, subtitle: $subtitle)'; +} + +/// Represents an audio output device. +class AudioDevice { + /// Unique identifier for the device. + final String name; + + /// Human-readable description of the device. + final String description; + + const AudioDevice({required this.name, this.description = ''}); + + /// Default/auto audio device. + static const auto = AudioDevice(name: 'auto', description: 'Auto'); + + @override + String toString() => 'AudioDevice($name, $description)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AudioDevice && + runtimeType == other.runtimeType && + name == other.name; + + @override + int get hashCode => name.hashCode; +} + +/// A log entry from the player. +class PlayerLog { + /// The log level of this message. + final PlayerLogLevel level; + + /// The prefix/category of the log message (e.g., 'cplayer', 'ffmpeg'). + final String prefix; + + /// The log message text. + final String text; + + const PlayerLog({ + required this.level, + required this.prefix, + required this.text, + }); + + @override + String toString() => '[$prefix] ${level.name}: $text'; +} + +/// Represents a media source for the player. +class Media { + /// The URI of the media (file path, HTTP URL, etc.). + final String uri; + + /// Optional HTTP headers for network requests. + final Map? headers; + + /// Optional start position for playback. + final Duration? start; + + const Media(this.uri, {this.headers, this.start}); + + @override + String toString() => 'Media($uri)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Media && + runtimeType == other.runtimeType && + uri == other.uri && + start == other.start; + + @override + int get hashCode => uri.hashCode ^ start.hashCode; +} diff --git a/lib/mpv/models/audio_device.dart b/lib/mpv/models/audio_device.dart deleted file mode 100644 index be5dca7d..00000000 --- a/lib/mpv/models/audio_device.dart +++ /dev/null @@ -1,26 +0,0 @@ -/// Represents an audio output device. -class AudioDevice { - /// Unique identifier for the device. - final String name; - - /// Human-readable description of the device. - final String description; - - const AudioDevice({required this.name, this.description = ''}); - - /// Default/auto audio device. - static const auto = AudioDevice(name: 'auto', description: 'Auto'); - - @override - String toString() => 'AudioDevice($name, $description)'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is AudioDevice && - runtimeType == other.runtimeType && - name == other.name; - - @override - int get hashCode => name.hashCode; -} diff --git a/lib/mpv/models/audio_track.dart b/lib/mpv/models/audio_track.dart deleted file mode 100644 index 11e2b565..00000000 --- a/lib/mpv/models/audio_track.dart +++ /dev/null @@ -1,68 +0,0 @@ -/// Represents an audio track in the media. -class AudioTrack { - /// Unique identifier for the track. - final String id; - - /// Human-readable title of the track. - final String? title; - - /// Language code (e.g., 'eng', 'jpn'). - final String? language; - - /// Audio codec (e.g., 'aac', 'ac3', 'dts'). - final String? codec; - - /// Number of audio channels. - final int? channels; - - /// Alias for channels (media_kit compatibility). - int? get channelsCount => channels; - - /// Sample rate in Hz. - final int? sampleRate; - - /// Bitrate in bits per second. - final int? bitrate; - - /// Whether this is the default track. - final bool isDefault; - - /// Whether this track is forced. - final bool isForced; - - const AudioTrack({ - required this.id, - this.title, - this.language, - this.codec, - this.channels, - this.sampleRate, - this.bitrate, - this.isDefault = false, - this.isForced = false, - }); - - /// Auto-select track. - static const auto = AudioTrack(id: 'auto', title: 'Auto'); - - /// Disable audio. - static const off = AudioTrack(id: 'no', title: 'Off'); - - /// Returns a display name for the track. - String get displayName { - if (title != null && title!.isNotEmpty) return title!; - if (language != null && language!.isNotEmpty) return language!; - return 'Track $id'; - } - - @override - String toString() => 'AudioTrack($id, $displayName)'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is AudioTrack && runtimeType == other.runtimeType && id == other.id; - - @override - int get hashCode => id.hashCode; -} diff --git a/lib/mpv/models/media.dart b/lib/mpv/models/media.dart deleted file mode 100644 index 569896bf..00000000 --- a/lib/mpv/models/media.dart +++ /dev/null @@ -1,27 +0,0 @@ -/// Represents a media source for the player. -class Media { - /// The URI of the media (file path, HTTP URL, etc.). - final String uri; - - /// Optional HTTP headers for network requests. - final Map? headers; - - /// Optional start position for playback. - final Duration? start; - - const Media(this.uri, {this.headers, this.start}); - - @override - String toString() => 'Media($uri)'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Media && - runtimeType == other.runtimeType && - uri == other.uri && - start == other.start; - - @override - int get hashCode => uri.hashCode ^ start.hashCode; -} diff --git a/lib/mpv/models/player_log.dart b/lib/mpv/models/player_log.dart deleted file mode 100644 index 1c084570..00000000 --- a/lib/mpv/models/player_log.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'player_log_level.dart'; - -/// A log entry from the player. -class PlayerLog { - /// The log level of this message. - final PlayerLogLevel level; - - /// The prefix/category of the log message (e.g., 'cplayer', 'ffmpeg'). - final String prefix; - - /// The log message text. - final String text; - - const PlayerLog({ - required this.level, - required this.prefix, - required this.text, - }); - - @override - String toString() => '[$prefix] ${level.name}: $text'; -} diff --git a/lib/mpv/models/player_log_level.dart b/lib/mpv/models/player_log_level.dart deleted file mode 100644 index 3f60d694..00000000 --- a/lib/mpv/models/player_log_level.dart +++ /dev/null @@ -1,26 +0,0 @@ -/// Log level for player messages. -enum PlayerLogLevel { - /// No logging. - none, - - /// Fatal errors only. - fatal, - - /// Errors. - error, - - /// Warnings. - warn, - - /// Informational messages. - info, - - /// Verbose output. - verbose, - - /// Debug messages. - debug, - - /// Trace-level output (very verbose). - trace, -} diff --git a/lib/mpv/models/subtitle_track.dart b/lib/mpv/models/subtitle_track.dart deleted file mode 100644 index 24e1fc52..00000000 --- a/lib/mpv/models/subtitle_track.dart +++ /dev/null @@ -1,75 +0,0 @@ -/// Represents a subtitle track in the media. -class SubtitleTrack { - /// Unique identifier for the track. - final String id; - - /// Human-readable title of the track. - final String? title; - - /// Language code (e.g., 'eng', 'jpn'). - final String? language; - - /// Subtitle codec/format (e.g., 'subrip', 'ass', 'pgs'). - final String? codec; - - /// Whether this is the default track. - final bool isDefault; - - /// Whether this track is forced (e.g., for foreign language segments). - final bool isForced; - - /// Whether this is an external subtitle file. - final bool isExternal; - - /// URI of external subtitle file (if isExternal is true). - final String? uri; - - const SubtitleTrack({ - required this.id, - this.title, - this.language, - this.codec, - this.isDefault = false, - this.isForced = false, - this.isExternal = false, - this.uri, - }); - - /// Create a subtitle track from an external URI. - factory SubtitleTrack.uri(String uri, {String? title, String? language}) { - return SubtitleTrack( - id: 'external:$uri', - title: title, - language: language, - isExternal: true, - uri: uri, - ); - } - - /// Auto-select track. - static const auto = SubtitleTrack(id: 'auto', title: 'Auto'); - - /// Disable subtitles. - static const off = SubtitleTrack(id: 'no', title: 'Off'); - - /// Returns a display name for the track. - String get displayName { - if (title != null && title!.isNotEmpty) return title!; - if (language != null && language!.isNotEmpty) return language!; - if (isExternal) return 'External'; - return 'Track $id'; - } - - @override - String toString() => 'SubtitleTrack($id, $displayName)'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SubtitleTrack && - runtimeType == other.runtimeType && - id == other.id; - - @override - int get hashCode => id.hashCode; -} diff --git a/lib/mpv/models/track_selection.dart b/lib/mpv/models/track_selection.dart deleted file mode 100644 index 1a9cbe3e..00000000 --- a/lib/mpv/models/track_selection.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'audio_track.dart'; -import 'subtitle_track.dart'; - -/// Represents the currently selected tracks. -class TrackSelection { - /// Currently selected audio track. - final AudioTrack? audio; - - /// Currently selected subtitle track. - final SubtitleTrack? subtitle; - - const TrackSelection({this.audio, this.subtitle}); - - /// Creates a copy with the given fields replaced. - TrackSelection copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) { - return TrackSelection( - audio: audio ?? this.audio, - subtitle: subtitle ?? this.subtitle, - ); - } - - @override - String toString() => 'TrackSelection(audio: $audio, subtitle: $subtitle)'; -} diff --git a/lib/mpv/models/tracks.dart b/lib/mpv/models/tracks.dart deleted file mode 100644 index 0892600b..00000000 --- a/lib/mpv/models/tracks.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'audio_track.dart'; -import 'subtitle_track.dart'; - -/// Container for all available tracks in the media. -class Tracks { - /// Available audio tracks. - final List audio; - - /// Available subtitle tracks. - final List subtitle; - - const Tracks({this.audio = const [], this.subtitle = const []}); - - /// Creates a copy with the given fields replaced. - Tracks copyWith({List? audio, List? subtitle}) { - return Tracks( - audio: audio ?? this.audio, - subtitle: subtitle ?? this.subtitle, - ); - } - - @override - String toString() => - 'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})'; -} diff --git a/lib/mpv/mpv.dart b/lib/mpv/mpv.dart index 2dc297d4..ab50c50e 100644 --- a/lib/mpv/mpv.dart +++ b/lib/mpv/mpv.dart @@ -49,14 +49,7 @@ export 'player/player_state.dart'; export 'player/player_streams.dart'; // Models -export 'models/media.dart'; -export 'models/audio_device.dart'; -export 'models/audio_track.dart'; -export 'models/subtitle_track.dart'; -export 'models/tracks.dart'; -export 'models/track_selection.dart'; -export 'models/player_log.dart'; -export 'models/player_log_level.dart'; +export 'models.dart'; // Video -export 'video/video.dart'; +export 'video.dart'; diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index e817ac86..ec940c94 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -1,9 +1,6 @@ import 'dart:io' show Platform; -import '../models/audio_device.dart'; -import '../models/media.dart'; -import '../models/audio_track.dart'; -import '../models/subtitle_track.dart'; +import '../models.dart'; import 'player_native.dart'; import 'player_state.dart'; import 'player_streams.dart'; diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 44ebb7cd..09d1594f 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -4,15 +4,8 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; -import '../utils/android_font_loader.dart'; - -import '../models/audio_device.dart'; -import '../models/audio_track.dart'; -import '../models/player_log.dart'; -import '../models/media.dart'; -import '../models/subtitle_track.dart'; -import '../models/track_selection.dart'; -import '../models/tracks.dart'; +import '../android_font_loader.dart'; +import '../models.dart'; import 'player.dart'; import 'player_state.dart'; import 'player_streams.dart'; diff --git a/lib/mpv/player/player_state.dart b/lib/mpv/player/player_state.dart index 475ee3f7..e4f388d8 100644 --- a/lib/mpv/player/player_state.dart +++ b/lib/mpv/player/player_state.dart @@ -1,6 +1,4 @@ -import '../models/audio_device.dart'; -import '../models/tracks.dart'; -import '../models/track_selection.dart'; +import '../models.dart'; /// Immutable snapshot of the current player state. /// diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index cda0ba9e..b1189040 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -1,7 +1,4 @@ -import '../models/audio_device.dart'; -import '../models/player_log.dart'; -import '../models/tracks.dart'; -import '../models/track_selection.dart'; +import '../models.dart'; /// Reactive streams for player state changes. /// diff --git a/lib/mpv/video/video.dart b/lib/mpv/video.dart similarity index 97% rename from lib/mpv/video/video.dart rename to lib/mpv/video.dart index f616a31e..335d58bf 100644 --- a/lib/mpv/video/video.dart +++ b/lib/mpv/video.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../player/player.dart'; -import '../player/video_rect_support.dart'; +import 'player/player.dart'; +import 'player/video_rect_support.dart'; /// Video widget for displaying player output. /// diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 405ecb93..b66068b7 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -2,11 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'dart:collection'; import 'package:flutter/foundation.dart'; -import 'package:plezy/models/plex_metadata_extensions.dart'; +import 'package:plezy/utils/content_utils.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../models/download_status.dart'; -import '../models/download_progress.dart'; -import '../models/deletion_progress.dart'; +import '../models/download_models.dart'; import '../models/plex_metadata.dart'; import '../services/download_manager_service.dart'; import '../services/download_storage_service.dart'; diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 00cfdd2b..0022340f 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -10,7 +10,7 @@ import '../services/server_registry.dart'; import '../providers/multi_server_provider.dart'; import '../providers/plex_client_provider.dart'; import '../i18n/strings.g.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import '../utils/app_logger.dart'; import 'main_screen.dart'; diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 9bdc6ab8..9cabd655 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -12,8 +12,7 @@ import '../utils/app_logger.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../i18n/strings.g.dart'; -import 'libraries/error_state_widget.dart'; -import 'libraries/empty_state_widget.dart'; +import 'libraries/state_messages.dart'; /// Abstract base class for screens displaying media lists (collections/playlists) /// Provides common state management and playback functionality diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index e5307f0c..540cce60 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -9,7 +9,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import '../../services/plex_client.dart'; import '../utils/plex_image_helper.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_metadata_extensions.dart'; +import '../utils/content_utils.dart'; import '../models/plex_hub.dart'; import '../providers/multi_server_provider.dart'; import '../providers/server_state_provider.dart'; @@ -27,11 +27,10 @@ import '../mixins/item_updatable.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; -import '../utils/content_rating_formatter.dart'; import '../utils/layout_constants.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import 'auth_screen.dart'; -import 'libraries/error_state_widget.dart'; +import 'libraries/state_messages.dart'; class DiscoverScreen extends StatefulWidget { final VoidCallback? onBecameVisible; diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index e2de308b..74e2b9ba 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -13,7 +13,7 @@ import '../../widgets/focusable_media_card.dart'; import '../../widgets/media_grid_delegate.dart'; import '../../widgets/download_tree_view.dart'; import '../main_screen.dart'; -import '../libraries/empty_state_widget.dart'; +import '../libraries/state_messages.dart'; import '../../i18n/strings.g.dart'; class DownloadsScreen extends StatefulWidget { diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index f9d3d74a..da318747 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -10,7 +10,7 @@ import '../utils/app_logger.dart'; import '../widgets/media_grid_sliver.dart'; import '../widgets/focused_scroll_scaffold.dart'; import 'libraries/sort_bottom_sheet.dart'; -import 'libraries/error_state_widget.dart'; +import 'libraries/state_messages.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index 48688793..afb27847 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../i18n/strings.g.dart'; -import 'empty_state_widget.dart'; -import 'error_state_widget.dart'; +import 'state_messages.dart'; /// A widget that handles loading, error, empty, and content states /// Provides a consistent UI pattern across the app for data-driven screens diff --git a/lib/screens/libraries/context_menu_wrapper.dart b/lib/screens/libraries/context_menu_wrapper.dart deleted file mode 100644 index c4f540a2..00000000 --- a/lib/screens/libraries/context_menu_wrapper.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/material.dart'; - -/// A menu action item for context menus -class ContextMenuItem { - final String value; - final IconData icon; - final String label; - final bool requiresConfirmation; - final String? confirmationTitle; - final String? confirmationMessage; - final bool isDestructive; - - const ContextMenuItem({ - required this.value, - required this.icon, - required this.label, - this.requiresConfirmation = false, - this.confirmationTitle, - this.confirmationMessage, - this.isDestructive = false, - }); -} diff --git a/lib/screens/libraries/empty_state_widget.dart b/lib/screens/libraries/empty_state_widget.dart deleted file mode 100644 index 2b4717b9..00000000 --- a/lib/screens/libraries/empty_state_widget.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'state_message_widget.dart'; - -/// A reusable widget for displaying empty states throughout the app -class EmptyStateWidget extends StatelessWidget { - /// The message to display - final String message; - - /// Optional subtitle/description below the message - final String? subtitle; - - /// Optional icon to display above the message - final IconData? icon; - - /// Optional size for the icon - final double iconSize; - - /// Optional callback for action button - final VoidCallback? onAction; - - /// Optional label for the action button - final String? actionLabel; - - const EmptyStateWidget({ - super.key, - required this.message, - this.subtitle, - this.icon, - this.iconSize = 64, - this.onAction, - this.actionLabel, - }); - - @override - Widget build(BuildContext context) { - return StateMessageWidget( - message: message, - subtitle: subtitle, - icon: icon, - iconSize: iconSize, - onAction: onAction, - actionLabel: actionLabel, - actionIcon: Symbols.add_rounded, - ); - } -} diff --git a/lib/screens/libraries/error_state_widget.dart b/lib/screens/libraries/error_state_widget.dart deleted file mode 100644 index 8d17d200..00000000 --- a/lib/screens/libraries/error_state_widget.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'state_message_widget.dart'; - -/// A reusable widget for displaying error states throughout the app -class ErrorStateWidget extends StatelessWidget { - /// The error message to display - final String message; - - /// Optional icon to display above the message - final IconData? icon; - - /// Optional callback for retry action - final VoidCallback? onRetry; - - /// Optional label for the retry button - final String? retryLabel; - - const ErrorStateWidget({ - super.key, - required this.message, - this.icon, - this.onRetry, - this.retryLabel, - }); - - @override - Widget build(BuildContext context) { - return StateMessageWidget( - message: message, - icon: icon, - iconColor: Theme.of(context).colorScheme.error, - textColor: Theme.of(context).colorScheme.error, - onAction: onRetry, - actionLabel: retryLabel ?? 'Retry', - actionIcon: Symbols.refresh_rounded, - ); - } -} diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index d4ee2a1b..9ee7167c 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -7,8 +7,7 @@ import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../i18n/strings.g.dart'; import 'folder_tree_item.dart'; -import 'empty_state_widget.dart'; -import 'error_state_widget.dart'; +import 'state_messages.dart'; /// Expandable tree view for browsing library folders /// Shows a hierarchical file/folder structure diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index fab900af..39f3b061 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -17,23 +17,42 @@ import '../../utils/app_logger.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; -import '../../utils/content_type_helper.dart'; +import '../../utils/content_utils.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/focusable_tab_chip.dart'; import '../main_screen.dart'; -import 'context_menu_wrapper.dart'; import '../../services/storage_service.dart'; import '../../mixins/refreshable.dart'; import '../../mixins/item_updatable.dart'; import '../../i18n/strings.g.dart'; import '../../utils/error_message_utils.dart'; -import 'error_state_widget.dart'; -import 'empty_state_widget.dart'; +import 'state_messages.dart'; import 'tabs/library_browse_tab.dart'; import 'tabs/library_recommended_tab.dart'; import 'tabs/library_collections_tab.dart'; import 'tabs/library_playlists_tab.dart'; +/// A menu action item for context menus +class ContextMenuItem { + final String value; + final IconData icon; + final String label; + final bool requiresConfirmation; + final String? confirmationTitle; + final String? confirmationMessage; + final bool isDestructive; + + const ContextMenuItem({ + required this.value, + required this.icon, + required this.label, + this.requiresConfirmation = false, + this.confirmationTitle, + this.confirmationMessage, + this.isDestructive = false, + }); +} + class LibrariesScreen extends StatefulWidget { final VoidCallback? onLibraryOrderChanged; diff --git a/lib/screens/libraries/state_message_widget.dart b/lib/screens/libraries/state_messages.dart similarity index 61% rename from lib/screens/libraries/state_message_widget.dart rename to lib/screens/libraries/state_messages.dart index 094cdc85..d78089e0 100644 --- a/lib/screens/libraries/state_message_widget.dart +++ b/lib/screens/libraries/state_messages.dart @@ -104,3 +104,83 @@ class StateMessageWidget extends StatelessWidget { ); } } + +/// A reusable widget for displaying empty states throughout the app +class EmptyStateWidget extends StatelessWidget { + /// The message to display + final String message; + + /// Optional subtitle/description below the message + final String? subtitle; + + /// Optional icon to display above the message + final IconData? icon; + + /// Optional size for the icon + final double iconSize; + + /// Optional callback for action button + final VoidCallback? onAction; + + /// Optional label for the action button + final String? actionLabel; + + const EmptyStateWidget({ + super.key, + required this.message, + this.subtitle, + this.icon, + this.iconSize = 64, + this.onAction, + this.actionLabel, + }); + + @override + Widget build(BuildContext context) { + return StateMessageWidget( + message: message, + subtitle: subtitle, + icon: icon, + iconSize: iconSize, + onAction: onAction, + actionLabel: actionLabel, + actionIcon: Symbols.add_rounded, + ); + } +} + +/// A reusable widget for displaying error states throughout the app +class ErrorStateWidget extends StatelessWidget { + /// The error message to display + final String message; + + /// Optional icon to display above the message + final IconData? icon; + + /// Optional callback for retry action + final VoidCallback? onRetry; + + /// Optional label for the retry button + final String? retryLabel; + + const ErrorStateWidget({ + super.key, + required this.message, + this.icon, + this.onRetry, + this.retryLabel, + }); + + @override + Widget build(BuildContext context) { + return StateMessageWidget( + message: message, + icon: icon, + iconColor: Theme.of(context).colorScheme.error, + textColor: Theme.of(context).colorScheme.error, + onAction: onRetry, + actionLabel: retryLabel ?? 'Retry', + actionIcon: Symbols.refresh_rounded, + ); + } +} diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index ace75f75..27c8231f 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -16,8 +16,7 @@ import '../../../mixins/library_tab_focus_mixin.dart'; import '../folder_tree_view.dart'; import '../filters_bottom_sheet.dart'; import '../sort_bottom_sheet.dart'; -import '../empty_state_widget.dart'; -import '../error_state_widget.dart'; +import '../state_messages.dart'; import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart' show ViewMode; import '../../../mixins/item_updatable.dart'; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index bbab81b8..00cad5f8 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -13,15 +13,14 @@ import '../widgets/plex_optimized_image.dart'; import '../utils/plex_image_helper.dart'; import '../../services/plex_client.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_metadata_extensions.dart'; -import '../models/download_status.dart'; +import '../utils/content_utils.dart'; +import '../models/download_models.dart'; import '../providers/playback_state_provider.dart'; import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import '../utils/app_logger.dart'; -import '../utils/content_rating_formatter.dart'; -import '../utils/duration_formatter.dart'; +import '../utils/formatters.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index db05deb0..6265d628 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -3,7 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../services/plex_client.dart'; import '../../models/plex_metadata.dart'; -import '../../utils/duration_formatter.dart'; +import '../../utils/formatters.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; import '../../widgets/media_context_menu.dart'; diff --git a/lib/screens/profile/profile_list_tile.dart b/lib/screens/profile/profile_list_tile.dart index af6bbcdf..ed39fa5c 100644 --- a/lib/screens/profile/profile_list_tile.dart +++ b/lib/screens/profile/profile_list_tile.dart @@ -3,7 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../i18n/strings.g.dart'; import '../../models/plex_home_user.dart'; -import '../../theme/theme_helper.dart'; +import '../../theme/mono_tokens.dart'; import 'user_avatar_widget.dart'; enum UserAttribute { admin, restricted, protected } diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 16e7ff49..81d2b698 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -7,7 +7,7 @@ import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import 'profile_list_tile.dart'; import '../../widgets/desktop_app_bar.dart'; -import '../libraries/empty_state_widget.dart'; +import '../libraries/state_messages.dart'; import '../../i18n/strings.g.dart'; class ProfileSwitchScreen extends StatelessWidget { diff --git a/lib/screens/profile/user_avatar_widget.dart b/lib/screens/profile/user_avatar_widget.dart index 7147a8b4..95e4e1ad 100644 --- a/lib/screens/profile/user_avatar_widget.dart +++ b/lib/screens/profile/user_avatar_widget.dart @@ -3,7 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../../models/plex_home_user.dart'; -import '../../theme/theme_helper.dart'; +import '../../theme/mono_tokens.dart'; import '../../i18n/strings.g.dart'; class UserAvatarWidget extends StatelessWidget { diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 87b62630..b681a4ed 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -15,7 +15,7 @@ import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; import '../utils/focus_utils.dart'; -import 'libraries/state_message_widget.dart'; +import 'libraries/state_messages.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 1d484c8f..54a22b22 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -7,19 +7,19 @@ import 'package:provider/provider.dart'; import '../../services/plex_client.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; -import '../models/download_status.dart'; +import '../models/download_models.dart'; import '../providers/download_provider.dart'; import '../services/download_storage_service.dart'; import '../widgets/plex_optimized_image.dart'; import '../models/plex_metadata.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; -import '../utils/duration_formatter.dart'; +import '../utils/formatters.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../mixins/item_updatable.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; class SeasonDetailScreen extends StatefulWidget { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index fa38f6eb..e114a66b 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -15,7 +15,7 @@ import '../../services/plex_client.dart'; import '../services/plex_api_cache.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_metadata_extensions.dart'; +import '../utils/content_utils.dart'; import '../models/plex_media_info.dart'; import '../providers/download_provider.dart'; import '../providers/playback_state_provider.dart'; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 2efdb482..56cd790a 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -4,12 +4,10 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart'; import 'package:path/path.dart' as path; -import 'package:plezy/models/plex_metadata_extensions.dart'; +import 'package:plezy/utils/content_utils.dart'; import '../database/app_database.dart'; import 'settings_service.dart'; -import '../models/download_status.dart'; -import '../models/download_progress.dart'; -import '../models/deletion_progress.dart'; +import '../models/download_models.dart'; import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; import '../services/plex_client.dart'; diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 89d7efb0..2aa48187 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -5,8 +5,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../models/plex_metadata.dart'; -import '../utils/byte_formatter.dart'; -import '../utils/number_formatter.dart'; +import '../utils/formatters.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; @@ -67,12 +66,10 @@ class DownloadStorageService { /// Format episode filename base: S{XX}E{XX} - {Title} String _formatEpisodeFileName(PlexMetadata episode) { - final seCode = NumberFormatter.formatSeasonEpisode( - episode.parentIndex, - episode.index, - ); + final season = padNumber(episode.parentIndex ?? 0, 2); + final ep = padNumber(episode.index ?? 0, 2); final episodeName = _sanitizeFileName(episode.title); - return '$seCode - $episodeName'; + return 'S${season}E$ep - $episodeName'; } /// Check if using custom download path @@ -350,7 +347,7 @@ class DownloadStorageService { int? showYear, }) async { final showDir = await getShowDirectory(metadata, showYear: showYear); - final seasonNum = NumberFormatter.formatSeason(metadata.parentIndex); + final seasonNum = padNumber(metadata.parentIndex ?? 0, 2); return _ensureDirectoryExists( Directory(path.join(showDir.path, 'Season $seasonNum')), ); @@ -641,7 +638,7 @@ class DownloadStorageService { int? showYear, }) { final showFolder = _getShowFolderName(episode, showYear: showYear); - final seasonNum = NumberFormatter.formatSeason(episode.parentIndex); + final seasonNum = padNumber(episode.parentIndex ?? 0, 2); return ['TV Shows', showFolder, 'Season $seasonNum']; } diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 8e33c8b3..bb9a3546 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -3,7 +3,7 @@ import 'package:rate_limiter/rate_limiter.dart'; import 'plex_client.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_metadata_extensions.dart'; +import '../utils/content_utils.dart'; import '../utils/app_logger.dart'; /// Manages OS media controls integration for video playback. diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 18981568..dd510d5f 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -1,7 +1,7 @@ import 'plex_client.dart'; import '../models/plex_media_info.dart'; import '../models/plex_metadata.dart'; -import '../models/download_status.dart'; +import '../models/download_models.dart'; import '../mpv/mpv.dart'; import '../utils/app_logger.dart'; import '../i18n/strings.g.dart'; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 0b7d37f0..13aa9b1e 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -11,7 +11,7 @@ import '../models/plex_library.dart'; import '../models/plex_media_info.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_metadata_extensions.dart'; +import '../utils/content_utils.dart'; import '../models/plex_playlist.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 7c4f19fc..01acfc34 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -4,7 +4,7 @@ import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:plezy/utils/app_logger.dart'; import '../i18n/strings.g.dart'; import 'base_shared_preferences_service.dart'; -import 'tv_detection_service.dart'; +import '../utils/platform_detector.dart'; enum ThemeMode { system, light, dark } diff --git a/lib/services/tv_detection_service.dart b/lib/services/tv_detection_service.dart deleted file mode 100644 index 8f91d36e..00000000 --- a/lib/services/tv_detection_service.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; - -/// Service for detecting if the app is running on Android TV -class TvDetectionService { - static TvDetectionService? _instance; - bool _isTV = false; - bool _initialized = false; - - TvDetectionService._(); - - /// Get the singleton instance, initializing if needed - static Future getInstance() async { - if (_instance == null) { - _instance = TvDetectionService._(); - await _instance!._detect(); - } - return _instance!; - } - - Future _detect() async { - if (_initialized) return; - - if (Platform.isAndroid) { - final deviceInfo = DeviceInfoPlugin(); - final androidInfo = await deviceInfo.androidInfo; - // Check for android.software.leanback feature (standard Android TV detection) - _isTV = androidInfo.systemFeatures.contains('android.software.leanback'); - } - _initialized = true; - } - - bool get isTV => _isTV; - - /// Synchronous access after initialization (returns false if not initialized) - static bool isTVSync() => _instance?._isTV ?? false; -} diff --git a/lib/theme/mono_tokens.dart b/lib/theme/mono_tokens.dart index a56b068b..ede02eff 100644 --- a/lib/theme/mono_tokens.dart +++ b/lib/theme/mono_tokens.dart @@ -1,6 +1,10 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +/// Helper function to access MonoTokens from context +MonoTokens tokens(BuildContext context) => + Theme.of(context).extension()!; + @immutable class MonoTokens extends ThemeExtension { final double radiusSm; diff --git a/lib/theme/theme_helper.dart b/lib/theme/theme_helper.dart deleted file mode 100644 index f5700c28..00000000 --- a/lib/theme/theme_helper.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:flutter/material.dart'; -import 'mono_tokens.dart'; - -/// Helper function to access MonoTokens from context -MonoTokens tokens(BuildContext context) => - Theme.of(context).extension()!; diff --git a/lib/utils/byte_formatter.dart b/lib/utils/byte_formatter.dart deleted file mode 100644 index 6482b255..00000000 --- a/lib/utils/byte_formatter.dart +++ /dev/null @@ -1,61 +0,0 @@ -/// Utility class for formatting byte sizes and speeds -class ByteFormatter { - ByteFormatter._(); - - static const int _kb = 1024; - static const int _mb = _kb * 1024; - 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) { - return '${(bytes / _kb).toStringAsFixed(decimals ?? 1)} KB'; - } - if (bytes < _gb) { - return '${(bytes / _mb).toStringAsFixed(decimals ?? 1)} MB'; - } - return '${(bytes / _gb).toStringAsFixed(decimals ?? 2)} GB'; - } - - /// 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'; - } - if (bytesPerSecond < _mb) { - return '${(bytesPerSecond / _kb).toStringAsFixed(1)} KB/s'; - } - return '${(bytesPerSecond / _mb).toStringAsFixed(1)} MB/s'; - } - - /// 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'; - } - - /// Format bitrate in bps to human-readable string - /// - /// [bps] The bitrate in bits per second - /// Returns formatted string like "8.5 Mbps", "256 Kbps", or "128 bps" - static String formatBitrateBps(int bps) { - const kbps = 1000; - const mbps = kbps * 1000; - - if (bps >= mbps) { - return '${(bps / mbps).toStringAsFixed(2)} Mbps'; - } else if (bps >= kbps) { - return '${(bps / kbps).toStringAsFixed(2)} Kbps'; - } else { - return '$bps bps'; - } - } -} diff --git a/lib/utils/content_rating_formatter.dart b/lib/utils/content_rating_formatter.dart deleted file mode 100644 index 05c3e173..00000000 --- a/lib/utils/content_rating_formatter.dart +++ /dev/null @@ -1,17 +0,0 @@ -/// Utility function to format content ratings by removing country prefixes -String formatContentRating(String? contentRating) { - if (contentRating == null || contentRating.isEmpty) { - return ''; - } - - // Remove common country prefixes like "gb/", "us/", "de/", etc. - // The pattern matches: lowercase letters followed by a forward slash - final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false); - final match = regex.firstMatch(contentRating); - - if (match != null && match.groupCount >= 1) { - return match.group(1) ?? contentRating; - } - - return contentRating; -} diff --git a/lib/utils/content_type_helper.dart b/lib/utils/content_utils.dart similarity index 60% rename from lib/utils/content_type_helper.dart rename to lib/utils/content_utils.dart index c3cd729a..477f3932 100644 --- a/lib/utils/content_type_helper.dart +++ b/lib/utils/content_utils.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../models/plex_metadata.dart'; + /// Content type constants used throughout the app class ContentTypes { ContentTypes._(); @@ -65,3 +67,39 @@ class ContentTypeHelper { } } } + +/// Utility function to format content ratings by removing country prefixes +String formatContentRating(String? contentRating) { + if (contentRating == null || contentRating.isEmpty) { + return ''; + } + + // Remove common country prefixes like "gb/", "us/", "de/", etc. + // The pattern matches: lowercase letters followed by a forward slash + final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false); + final match = regex.firstMatch(contentRating); + + if (match != null && match.groupCount >= 1) { + return match.group(1) ?? contentRating; + } + + return contentRating; +} + +/// Extension on PlexMetadata for type checking convenience methods +extension PlexMetadataType on PlexMetadata { + String get _lowerType => type.toLowerCase(); + + bool get isShow => _lowerType == ContentTypes.show; + bool get isMovie => _lowerType == ContentTypes.movie; + bool get isSeason => _lowerType == ContentTypes.season; + bool get isEpisode => _lowerType == ContentTypes.episode; + bool get isArtist => _lowerType == ContentTypes.artist; + bool get isAlbum => _lowerType == ContentTypes.album; + bool get isTrack => _lowerType == ContentTypes.track; + bool get isCollection => _lowerType == ContentTypes.collection; + bool get isPlaylist => _lowerType == ContentTypes.playlist; + bool get isClip => _lowerType == ContentTypes.clip; + bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType); + bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType); +} diff --git a/lib/utils/duration_formatter.dart b/lib/utils/formatters.dart similarity index 60% rename from lib/utils/duration_formatter.dart rename to lib/utils/formatters.dart index 698b612f..b8536905 100644 --- a/lib/utils/duration_formatter.dart +++ b/lib/utils/formatters.dart @@ -2,6 +2,75 @@ import 'package:duration/duration.dart'; import 'package:duration/locale.dart'; import '../i18n/strings.g.dart'; +/// Formats a number with a minimum number of digits using leading zeros. +/// +/// Example: `padNumber(5, 3)` returns "005" +String padNumber(int number, int width) { + return number.toString().padLeft(width, '0'); +} + +/// Utility class for formatting byte sizes and speeds +class ByteFormatter { + ByteFormatter._(); + + static const int _kb = 1024; + static const int _mb = _kb * 1024; + 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) { + return '${(bytes / _kb).toStringAsFixed(decimals ?? 1)} KB'; + } + if (bytes < _gb) { + return '${(bytes / _mb).toStringAsFixed(decimals ?? 1)} MB'; + } + return '${(bytes / _gb).toStringAsFixed(decimals ?? 2)} GB'; + } + + /// 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'; + } + if (bytesPerSecond < _mb) { + return '${(bytesPerSecond / _kb).toStringAsFixed(1)} KB/s'; + } + return '${(bytesPerSecond / _mb).toStringAsFixed(1)} MB/s'; + } + + /// 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'; + } + + /// Format bitrate in bps to human-readable string + /// + /// [bps] The bitrate in bits per second + /// Returns formatted string like "8.5 Mbps", "256 Kbps", or "128 bps" + static String formatBitrateBps(int bps) { + const kbps = 1000; + const mbps = kbps * 1000; + + if (bps >= mbps) { + return '${(bps / mbps).toStringAsFixed(2)} Mbps'; + } else if (bps >= kbps) { + return '${(bps / kbps).toStringAsFixed(2)} Kbps'; + } else { + return '$bps bps'; + } + } +} + /// Formats a duration in human-readable textual format (e.g., "1h 23m" or "1 hour 23 minutes"). /// Uses localized unit names based on the current app locale. /// Shows hours and minutes only (no seconds). diff --git a/lib/utils/number_formatter.dart b/lib/utils/number_formatter.dart deleted file mode 100644 index 5bd63609..00000000 --- a/lib/utils/number_formatter.dart +++ /dev/null @@ -1,32 +0,0 @@ -/// Utility class for formatting numbers consistently across the app. -class NumberFormatter { - NumberFormatter._(); - - /// Formats a season number with leading zeros (e.g., "01", "02", "10"). - /// - /// Used for consistent season display in file names and UI. - static String formatSeason(int? seasonNumber) { - return (seasonNumber ?? 0).toString().padLeft(2, '0'); - } - - /// Formats an episode number with leading zeros (e.g., "01", "02", "10"). - /// - /// Used for consistent episode display in file names and UI. - static String formatEpisode(int? episodeNumber) { - return (episodeNumber ?? 0).toString().padLeft(2, '0'); - } - - /// Formats a season and episode as "SXXEXX" (e.g., "S01E05", "S12E23"). - /// - /// Commonly used for episode identifiers in file names. - static String formatSeasonEpisode(int? season, int? episode) { - return 'S${formatSeason(season)}E${formatEpisode(episode)}'; - } - - /// Formats a number with a minimum number of digits using leading zeros. - /// - /// Example: `padNumber(5, 3)` returns "005" - static String padNumber(int number, int width) { - return number.toString().padLeft(width, '0'); - } -} diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index acd467af..cb86ec93 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -1,7 +1,43 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; import 'dart:math'; -import '../services/tv_detection_service.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; + +/// Service for detecting if the app is running on Android TV +class TvDetectionService { + static TvDetectionService? _instance; + bool _isTV = false; + bool _initialized = false; + + TvDetectionService._(); + + /// Get the singleton instance, initializing if needed + static Future getInstance() async { + if (_instance == null) { + _instance = TvDetectionService._(); + await _instance!._detect(); + } + return _instance!; + } + + Future _detect() async { + if (_initialized) return; + + if (Platform.isAndroid) { + final deviceInfo = DeviceInfoPlugin(); + final androidInfo = await deviceInfo.androidInfo; + // Check for android.software.leanback feature (standard Android TV detection) + _isTV = androidInfo.systemFeatures.contains('android.software.leanback'); + } + _initialized = true; + } + + bool get isTV => _isTV; + + /// Synchronous access after initialization (returns false if not initialized) + static bool isTVSync() => _instance?._isTV ?? false; +} /// Utility class for platform detection class PlatformDetector { diff --git a/lib/widgets/deletion_progress_dialog.dart b/lib/widgets/deletion_progress_dialog.dart index c335a1d0..879fe981 100644 --- a/lib/widgets/deletion_progress_dialog.dart +++ b/lib/widgets/deletion_progress_dialog.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../models/deletion_progress.dart'; +import '../models/download_models.dart'; import '../i18n/strings.g.dart'; class DeletionProgressDialog extends StatelessWidget { diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index f6393f24..256f8c57 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; -import '../models/download_progress.dart'; -import '../models/download_status.dart'; +import '../models/download_models.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_metadata_extensions.dart'; +import '../utils/content_utils.dart'; /// Represents a node in the download tree class DownloadTreeNode { diff --git a/lib/widgets/horizontal_scroll_with_arrows.dart b/lib/widgets/horizontal_scroll_with_arrows.dart index f9e4f012..c65a25c6 100644 --- a/lib/widgets/horizontal_scroll_with_arrows.dart +++ b/lib/widgets/horizontal_scroll_with_arrows.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import '../utils/platform_detector.dart'; /// A wrapper widget that adds hover-activated navigation arrows to horizontal scrolling content. diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 87986be0..f258b0db 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -3,7 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import '../utils/layout_constants.dart'; import '../focus/locked_hub_controller.dart'; import '../models/plex_hub.dart'; diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index ef0168a3..46cecfd6 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:plezy/models/plex_metadata_extensions.dart'; +import 'package:plezy/utils/content_utils.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -11,11 +11,10 @@ import '../services/download_storage_service.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; -import '../utils/content_rating_formatter.dart'; -import '../utils/duration_formatter.dart'; +import '../utils/formatters.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/snackbar_helper.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; import 'media_context_menu.dart'; import 'media_progress_bar.dart'; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 6a6bede4..ccbcbae5 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -19,7 +19,7 @@ import '../utils/focus_utils.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../utils/smart_deletion_handler.dart'; -import '../theme/theme_helper.dart'; +import '../theme/mono_tokens.dart'; import '../widgets/file_info_bottom_sheet.dart'; import '../widgets/focusable_bottom_sheet.dart'; import '../widgets/focusable_list_tile.dart'; diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 78eab7da..b6d7fd54 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -13,8 +13,8 @@ import '../providers/hidden_libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../services/storage_service.dart'; -import '../theme/theme_helper.dart'; -import '../utils/content_type_helper.dart'; +import '../theme/mono_tokens.dart'; +import '../utils/content_utils.dart'; import '../i18n/strings.g.dart'; /// Tracks focus state for a set of named items, avoiding repeated boilerplate diff --git a/lib/utils/video_control_icons.dart b/lib/widgets/video_controls/icons.dart similarity index 100% rename from lib/utils/video_control_icons.dart rename to lib/widgets/video_controls/icons.dart diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 4a535c5a..5a374737 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -7,7 +7,7 @@ import '../../models/plex_media_info.dart'; import '../../models/plex_metadata.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/player_utils.dart'; -import '../../utils/video_control_icons.dart'; +import 'icons.dart'; import '../../i18n/strings.g.dart'; import 'widgets/video_controls_header.dart'; import 'widgets/video_timeline_bar.dart'; diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index c07daf16..76b3078a 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -6,7 +6,7 @@ import '../../../mpv/mpv.dart'; import '../../../services/plex_client.dart'; import '../../../services/download_storage_service.dart'; import '../../../models/plex_media_info.dart'; -import '../../../utils/duration_formatter.dart'; +import '../../../utils/formatters.dart'; import '../../../utils/provider_extensions.dart'; import '../../../widgets/focusable_bottom_sheet.dart'; import '../../../widgets/focusable_list_tile.dart'; diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 66bb7cdc..d14b3f16 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -7,7 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../mpv/mpv.dart'; import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; -import '../../../utils/duration_formatter.dart'; +import '../../../utils/formatters.dart'; import '../../../utils/platform_detector.dart'; import '../../../widgets/focusable_bottom_sheet.dart'; import '../../../widgets/focusable_list_tile.dart'; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 84522097..bfaed690 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -30,10 +30,10 @@ import '../../services/settings_service.dart'; import '../../utils/platform_detector.dart'; import '../../utils/plex_cache_parser.dart'; import '../../utils/player_utils.dart'; -import '../../theme/theme_helper.dart'; +import '../../theme/mono_tokens.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; -import '../../utils/video_control_icons.dart'; +import 'icons.dart'; import '../../utils/app_logger.dart'; import '../../i18n/strings.g.dart'; import '../../focus/input_mode_tracker.dart'; @@ -513,6 +513,24 @@ class _PlexVideoControlsState extends State } } + /// Show controls in response to pointer activity (mouse/trackpad movement). + void _showControlsFromPointerActivity() { + if (!_showControls) { + setState(() { + _showControls = true; + _controlsFullyHidden = false; + }); + _showLinuxControls(); + // On macOS, keep window controls in sync with the overlay + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + + // Keep the overlay visible while the user is moving the pointer + _restartHideTimerIfPlaying(); + } + void _toggleControls() { setState(() { _showControls = !_showControls; @@ -967,26 +985,15 @@ class _PlexVideoControlsState extends State onBack: () => Navigator.of(context).pop(true), ); }, - child: MouseRegion( - cursor: _showControls - ? SystemMouseCursors.basic - : SystemMouseCursors.none, - onHover: (_) { - // Show controls when mouse moves - if (!_showControls) { - setState(() { - _showControls = true; - _controlsFullyHidden = false; - }); - _showLinuxControls(); - _startHideTimer(); - // On macOS, show traffic lights when controls appear - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - }, - child: Stack( + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerHover: (_) => _showControlsFromPointerActivity(), + child: MouseRegion( + cursor: _showControls + ? SystemMouseCursors.basic + : SystemMouseCursors.none, + onHover: (_) => _showControlsFromPointerActivity(), + child: Stack( children: [ // Invisible tap detector that always covers the full area Positioned.fill( diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart index a9edf491..354a3ffb 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -3,7 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../i18n/strings.g.dart'; -import '../../../utils/duration_formatter.dart'; +import '../../../utils/formatters.dart'; /// Widget displaying active sleep timer status with extend/cancel actions class SleepTimerActiveStatus extends StatelessWidget { diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart index 96316b34..45e8726d 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -4,7 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../mpv/mpv.dart'; import '../../../services/sleep_timer_service.dart'; -import '../../../utils/duration_formatter.dart'; +import '../../../utils/formatters.dart'; import '../../../utils/snackbar_helper.dart'; import '../../../i18n/strings.g.dart'; diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 3c56f55f..efa22a6f 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -4,7 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../mpv/mpv.dart'; import '../../../i18n/strings.g.dart'; -import '../../../utils/duration_formatter.dart'; +import '../../../utils/formatters.dart'; /// Reusable widget for adjusting sync offsets (audio or subtitle) class SyncOffsetControl extends StatefulWidget { diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index fa9aa472..4eea92cc 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; import '../../../models/plex_media_info.dart'; -import '../../../utils/duration_formatter.dart'; +import '../../../utils/formatters.dart'; import 'timeline_slider.dart'; /// Encapsulates the StreamBuilder stack for video timeline with timestamps.