refactor: consolidate
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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()();
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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))();
|
||||
}
|
||||
@@ -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,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
@@ -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';
|
||||
|
||||
@@ -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%)';
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
enum DownloadStatus {
|
||||
queued,
|
||||
downloading,
|
||||
paused,
|
||||
completed,
|
||||
failed,
|
||||
cancelled,
|
||||
partial, // Some episodes downloaded, but not all (for shows/seasons)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../utils/byte_formatter.dart';
|
||||
import '../utils/formatters.dart';
|
||||
|
||||
class PlexFileInfo {
|
||||
// Media level properties
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../utils/byte_formatter.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/codec_utils.dart';
|
||||
|
||||
class PlexMediaVersion {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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,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,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,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+80
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>()!;
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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,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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user