refactor: consolidate

This commit is contained in:
edde746
2025-12-15 01:16:22 +01:00
parent a2a5a5ae55
commit fb17600bb0
83 changed files with 797 additions and 869 deletions
+2 -5
View File
@@ -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';
+97
View File
@@ -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<Column> 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()();
}
-20
View File
@@ -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<Column> get primaryKey => {cacheKey};
}
-13
View File
@@ -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))();
}
-21
View File
@@ -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))();
}
@@ -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()();
}
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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';
-45
View File
@@ -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%)';
}
}
@@ -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%)';
}
}
-9
View File
@@ -1,9 +0,0 @@
enum DownloadStatus {
queued,
downloading,
paused,
completed,
failed,
cancelled,
partial, // Some episodes downloaded, but not all (for shows/seasons)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import '../utils/byte_formatter.dart';
import '../utils/formatters.dart';
class PlexFileInfo {
// Media level properties
+1 -1
View File
@@ -1,4 +1,4 @@
import '../utils/byte_formatter.dart';
import '../utils/formatters.dart';
import '../utils/codec_utils.dart';
class PlexMediaVersion {
-20
View File
@@ -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);
}
+292
View File
@@ -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<AudioTrack> audio;
/// Available subtitle tracks.
final List<SubtitleTrack> subtitle;
const Tracks({this.audio = const [], this.subtitle = const []});
/// Creates a copy with the given fields replaced.
Tracks copyWith({List<AudioTrack>? audio, List<SubtitleTrack>? 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<String, String>? 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;
}
-26
View File
@@ -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;
}
-68
View File
@@ -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;
}
-27
View File
@@ -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<String, String>? 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;
}
-22
View File
@@ -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';
}
-26
View File
@@ -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,
}
-75
View File
@@ -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;
}
-24
View File
@@ -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)';
}
-25
View File
@@ -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<AudioTrack> audio;
/// Available subtitle tracks.
final List<SubtitleTrack> subtitle;
const Tracks({this.audio = const [], this.subtitle = const []});
/// Creates a copy with the given fields replaced.
Tracks copyWith({List<AudioTrack>? audio, List<SubtitleTrack>? subtitle}) {
return Tracks(
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
);
}
@override
String toString() =>
'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})';
}
+2 -9
View File
@@ -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';
+1 -4
View File
@@ -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';
+2 -9
View File
@@ -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';
+1 -3
View File
@@ -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.
///
+1 -4
View File
@@ -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.
///
@@ -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.
///
+2 -4
View File
@@ -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';
+1 -1
View File
@@ -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';
@@ -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
+3 -4
View File
@@ -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;
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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';
@@ -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
@@ -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,
});
}
@@ -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,
);
}
}
@@ -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,
);
}
}
+1 -2
View File
@@ -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
+23 -4
View File
@@ -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;
@@ -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,
);
}
}
@@ -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';
+4 -5
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 }
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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});
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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';
+2 -4
View File
@@ -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';
+6 -9
View File
@@ -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'];
}
+1 -1
View File
@@ -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.
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 }
-38
View File
@@ -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<TvDetectionService> getInstance() async {
if (_instance == null) {
_instance = TvDetectionService._();
await _instance!._detect();
}
return _instance!;
}
Future<void> _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;
}
+4
View File
@@ -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<MonoTokens>()!;
@immutable
class MonoTokens extends ThemeExtension<MonoTokens> {
final double radiusSm;
-6
View File
@@ -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<MonoTokens>()!;
-61
View File
@@ -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';
}
}
}
-17
View File
@@ -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;
}
@@ -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);
}
@@ -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).
-32
View File
@@ -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');
}
}
+38 -2
View File
@@ -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<TvDetectionService> getInstance() async {
if (_instance == null) {
_instance = TvDetectionService._();
await _instance!._detect();
}
return _instance!;
}
Future<void> _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 {
+1 -1
View File
@@ -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 {
+2 -3
View File
@@ -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 {
@@ -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.
+1 -1
View File
@@ -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';
+3 -4
View File
@@ -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';
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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
@@ -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';
@@ -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';
@@ -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';
+29 -22
View File
@@ -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<PlexVideoControls>
}
}
/// 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<PlexVideoControls>
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(
@@ -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 {
@@ -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';
@@ -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 {
@@ -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.